NymeaService
- batterySOCSource : retourne {'thingId': 'sim-battery', 'stateName': 'soc'}
en simulation au lieu de null — le SOC est désormais fetchable
- fetchHistory en simulation : données SOC réalistes en % (0-100) avec
courbe charge solaire 8h-15h (20→85%) puis décharge soir (85→20%)
au lieu de valeurs sinus 100-900 W incorrectes
EnergyScreen
- initState : appelle startSimulation() si non connecté (comme les autres écrans)
- leftTitles : interval explicite (yMax/4) + SideTitleWidget pour ancrage correct
- gridData : horizontalInterval aligné sur yInterval
- rightTitles (SOC %) : SideTitleWidget + interval aligné
- bottomTitles : SideTitleWidget sur les deux graphes
- barChart leftTitles : interval + SideTitleWidget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
899 lines
31 KiB
Dart
899 lines
31 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' show max, min;
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/energy_data.dart';
|
|
import '../models/nymea_models.dart';
|
|
import '../services/nymea_service.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
import '../main.dart' show DrawerMenuButton;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// EnergyScreen — historique énergétique
|
|
//
|
|
// 4 KPIs · sélecteur période · line chart double axe (kW / SOC%) · bar chart
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _Tab {
|
|
final String label;
|
|
final Duration range;
|
|
final String sampleRate;
|
|
final bool showTime;
|
|
const _Tab(this.label, this.range, this.sampleRate, {required this.showTime});
|
|
}
|
|
|
|
class EnergyScreen extends StatefulWidget {
|
|
const EnergyScreen({super.key});
|
|
|
|
@override
|
|
State<EnergyScreen> createState() => _EnergyScreenState();
|
|
}
|
|
|
|
class _EnergyScreenState extends State<EnergyScreen> {
|
|
static const _tabs = [
|
|
_Tab('Heures', Duration(hours: 24), 'SampleRate15Mins', showTime: true),
|
|
_Tab('Jour', Duration(days: 7), 'SampleRate1Hour', showTime: false),
|
|
_Tab('Semaine', Duration(days: 28), 'SampleRate1Day', showTime: false),
|
|
_Tab('Mois', Duration(days: 365), 'SampleRate1Week', showTime: false),
|
|
];
|
|
|
|
int _tabIdx = 0;
|
|
List<PowerBalanceEntry> _data = [];
|
|
List<HistoryEntry> _socData = [];
|
|
bool _loading = true;
|
|
bool _noData = false;
|
|
DateTime? _selectedDate;
|
|
Timer? _refreshTimer;
|
|
|
|
NymeaService? _nymeaService;
|
|
bool _initialSocFetched = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_nymeaService = context.read<NymeaService>();
|
|
if (!_nymeaService!.connected) _nymeaService!.startSimulation();
|
|
_nymeaService!.addListener(_onServiceChangedForSoc);
|
|
_fetch();
|
|
});
|
|
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) {
|
|
if (mounted) _fetch();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_refreshTimer?.cancel();
|
|
_nymeaService?.removeListener(_onServiceChangedForSoc);
|
|
super.dispose();
|
|
}
|
|
|
|
void _onServiceChangedForSoc() {
|
|
if (!mounted || _initialSocFetched || _loading) return;
|
|
if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) {
|
|
_initialSocFetched = true;
|
|
_fetchSocOnly();
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchSocOnly() async {
|
|
if (!mounted) return;
|
|
final tab = _tabs[_tabIdx];
|
|
final to = _anchorDate();
|
|
final service = context.read<NymeaService>();
|
|
final socSource = service.batterySOCSource;
|
|
if (socSource == null) return;
|
|
final soc = await service.fetchHistory(
|
|
thingId: socSource['thingId']!,
|
|
stateTypeName: socSource['stateName']!,
|
|
from: to.subtract(tab.range),
|
|
to: to,
|
|
sampleRate: tab.sampleRate,
|
|
);
|
|
if (mounted && soc.isNotEmpty) setState(() => _socData = soc);
|
|
}
|
|
|
|
Future<void> _fetch() async {
|
|
if (!mounted) return;
|
|
setState(() { _loading = true; _noData = false; });
|
|
final tab = _tabs[_tabIdx];
|
|
final to = _anchorDate();
|
|
final from = to.subtract(tab.range);
|
|
final service = context.read<NymeaService>();
|
|
final socSource = service.batterySOCSource;
|
|
|
|
final results = await Future.wait<dynamic>([
|
|
service.fetchPowerBalanceLogs(from: from, to: to, sampleRate: tab.sampleRate),
|
|
socSource != null
|
|
? service.fetchHistory(
|
|
thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!,
|
|
from: from, to: to, sampleRate: tab.sampleRate)
|
|
: Future<List<HistoryEntry>>.value([]),
|
|
]);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_data = results[0] as List<PowerBalanceEntry>;
|
|
_socData = results[1] as List<HistoryEntry>;
|
|
_loading = false;
|
|
_noData = _data.isEmpty;
|
|
});
|
|
}
|
|
|
|
DateTime _anchorDate() => _selectedDate != null
|
|
? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59)
|
|
: DateTime.now();
|
|
|
|
Future<void> _pickDate() async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _selectedDate ?? DateTime.now(),
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime.now(),
|
|
builder: (ctx, child) => Theme(
|
|
data: Theme.of(ctx).copyWith(
|
|
colorScheme: const ColorScheme.light(
|
|
primary: EtmTokens.green,
|
|
onPrimary: Colors.white,
|
|
),
|
|
),
|
|
child: child!,
|
|
),
|
|
);
|
|
if (picked != null && mounted) {
|
|
setState(() { _selectedDate = picked; _initialSocFetched = false; });
|
|
_fetch();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<NymeaService>(
|
|
builder: (context, service, _) {
|
|
return Scaffold(
|
|
backgroundColor: EtmTokens.bg,
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
// AppBar
|
|
SliverAppBar(
|
|
floating: true,
|
|
backgroundColor: EtmTokens.bg,
|
|
elevation: 0,
|
|
leading: const DrawerMenuButton(),
|
|
leadingWidth: 64,
|
|
title: Text('Énergie',
|
|
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.calendar_today_rounded,
|
|
color: _selectedDate != null ? EtmTokens.blue : EtmTokens.muted,
|
|
size: 20,
|
|
),
|
|
onPressed: _pickDate,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.refresh_rounded,
|
|
color: EtmTokens.muted, size: 20),
|
|
onPressed: _fetch,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
|
|
sliver: SliverList(
|
|
delegate: SliverChildListDelegate([
|
|
// KPI 2x2
|
|
_KpiSection(service: service),
|
|
const SizedBox(height: 16),
|
|
|
|
// Sélecteur période
|
|
_PeriodSelector(
|
|
tabs: _tabs,
|
|
selectedIndex: _tabIdx,
|
|
onSelect: (i) {
|
|
if (_tabIdx != i) {
|
|
setState(() { _tabIdx = i; _initialSocFetched = false; });
|
|
_fetch();
|
|
}
|
|
},
|
|
),
|
|
|
|
// Chip date sélectionnée
|
|
if (_selectedDate != null) ...[
|
|
const SizedBox(height: 10),
|
|
_DateChip(
|
|
date: _selectedDate!,
|
|
onClear: () {
|
|
setState(() => _selectedDate = null);
|
|
_fetch();
|
|
},
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
|
|
// Graphe Puissances · SOC %
|
|
_EtmChartCard(
|
|
title: 'Puissances · SOC %',
|
|
subtitle: 'kW (gauche) · % batterie (droite)',
|
|
legend: [
|
|
_LegendDot(EtmTokens.amber, 'Production'),
|
|
_LegendDot(EtmTokens.blue, 'Consommation'),
|
|
_LegendDot(EtmTokens.green, 'Autoconso'),
|
|
_LegendDot(EtmTokens.green, 'SOC %', dashed: true),
|
|
],
|
|
height: 220,
|
|
loading: _loading,
|
|
noData: _noData,
|
|
child: _buildLineChart(),
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Graphe Bilan énergétique
|
|
_EtmChartCard(
|
|
title: 'Bilan énergétique (Wh)',
|
|
subtitle: 'Énergie par période',
|
|
legend: [
|
|
_LegendDot(EtmTokens.amber, 'Production'),
|
|
_LegendDot(EtmTokens.blue, 'Consommation'),
|
|
],
|
|
height: 200,
|
|
loading: _loading,
|
|
noData: _noData,
|
|
child: _buildBarChart(),
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Météo & prévision — placeholder
|
|
_ForecastPlaceholder(),
|
|
]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── ① Line chart : puissances + SOC ────────────────────────────────────────
|
|
|
|
Widget _buildLineChart() {
|
|
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
|
|
|
|
final prodSpots = <FlSpot>[];
|
|
final consoSpots = <FlSpot>[];
|
|
final autoSpots = <FlSpot>[];
|
|
final socSpots = <FlSpot>[];
|
|
|
|
for (int i = 0; i < _data.length; i++) {
|
|
final d = _data[i];
|
|
final x = i.toDouble();
|
|
// Toujours en kW pour l'axe gauche
|
|
prodSpots .add(FlSpot(x, d.productionW / 1000));
|
|
consoSpots.add(FlSpot(x, d.consumptionW / 1000));
|
|
autoSpots .add(FlSpot(x, d.autoconsommationW / 1000));
|
|
}
|
|
|
|
// Plage Y gauche en kW
|
|
final allKw = _data.expand((d) => [d.productionW / 1000, d.consumptionW / 1000]).toList();
|
|
final maxKw = allKw.isEmpty ? 2.5 : allKw.reduce(max);
|
|
final yMax = max(maxKw * 1.15, 0.5); // min 0.5 kW pour éviter y écrasé
|
|
|
|
// SOC normalisé sur l'échelle kW (100% → yMax)
|
|
final hasSoc = _socData.length > 1;
|
|
if (hasSoc) {
|
|
final n = _socData.length;
|
|
final xMax = (_data.length - 1).toDouble();
|
|
for (int i = 0; i < n; i++) {
|
|
socSpots.add(FlSpot(
|
|
xMax * i / (n - 1),
|
|
_socData[i].value * yMax / 100.0,
|
|
));
|
|
}
|
|
}
|
|
|
|
final xInterval = _xInterval();
|
|
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted);
|
|
final yInterval = yMax / 4; // 5 graduations : 0, 25%, 50%, 75%, 100% de yMax
|
|
|
|
return LineChart(
|
|
LineChartData(
|
|
clipData: const FlClipData.all(),
|
|
minY: 0,
|
|
maxY: yMax,
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
horizontalInterval: yInterval,
|
|
getDrawingHorizontalLine: (_) =>
|
|
FlLine(color: EtmTokens.line, strokeWidth: 1),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
titlesData: FlTitlesData(
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
// Axe gauche : kW
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 46,
|
|
interval: yInterval,
|
|
getTitlesWidget: (v, meta) {
|
|
if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet
|
|
return SideTitleWidget(
|
|
meta: meta,
|
|
child: Text(
|
|
'${v.toStringAsFixed(1)}kW',
|
|
style: labelStyle,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Axe droit : SOC %
|
|
rightTitles: hasSoc
|
|
? AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 40,
|
|
interval: yInterval,
|
|
getTitlesWidget: (v, meta) {
|
|
if (v == meta.max) return const SizedBox.shrink();
|
|
final pct = (v / yMax * 100).round();
|
|
if (pct < 0 || pct > 100) return const SizedBox.shrink();
|
|
return SideTitleWidget(
|
|
meta: meta,
|
|
child: Text(
|
|
'$pct%',
|
|
style: EtmTokens.sans(size: 9, color: EtmTokens.green),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
)
|
|
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
// Axe bas : temps
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 22,
|
|
interval: xInterval,
|
|
getTitlesWidget: (v, meta) {
|
|
final idx = v.round();
|
|
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
|
return SideTitleWidget(
|
|
meta: meta,
|
|
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
lineBarsData: [
|
|
_line(prodSpots, EtmTokens.amber),
|
|
_line(consoSpots, EtmTokens.blue),
|
|
_line(autoSpots, EtmTokens.green),
|
|
if (hasSoc) _line(socSpots, EtmTokens.green, dashed: true, width: 1.5),
|
|
],
|
|
lineTouchData: LineTouchData(
|
|
touchTooltipData: LineTouchTooltipData(
|
|
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85),
|
|
getTooltipItems: (spots) => spots.map((s) {
|
|
final isSoc = hasSoc && s.barIndex == 3;
|
|
final label = isSoc
|
|
? '${(s.y / yMax * 100).toStringAsFixed(0)} %'
|
|
: '${s.y.toStringAsFixed(2)} kW';
|
|
return LineTooltipItem(
|
|
label,
|
|
EtmTokens.mono(size: 11, color: s.bar.color ?? Colors.white),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
LineChartBarData _line(List<FlSpot> spots, Color color,
|
|
{bool dashed = false, double width = 2.0}) =>
|
|
LineChartBarData(
|
|
spots: spots,
|
|
isCurved: true,
|
|
curveSmoothness: 0.25,
|
|
color: color,
|
|
barWidth: width,
|
|
dotData: const FlDotData(show: false),
|
|
dashArray: dashed ? [4, 5] : null,
|
|
belowBarData: BarAreaData(
|
|
show: !dashed,
|
|
color: color.withValues(alpha: 0.07),
|
|
),
|
|
);
|
|
|
|
// ── ② Bar chart : bilan énergétique ────────────────────────────────────────
|
|
|
|
Widget _buildBarChart() {
|
|
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
|
|
|
|
final groups = <BarChartGroupData>[];
|
|
double maxE = 1.0;
|
|
final bw = _barWidth();
|
|
|
|
for (int i = 0; i < _data.length; i++) {
|
|
final prodWh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh);
|
|
final consoWh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh);
|
|
maxE = [maxE, prodWh, consoWh].reduce(max);
|
|
groups.add(BarChartGroupData(
|
|
x: i,
|
|
barsSpace: 2,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: prodWh, color: EtmTokens.amber, width: bw,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
|
|
),
|
|
BarChartRodData(
|
|
toY: consoWh, color: EtmTokens.blue, width: bw,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
|
|
),
|
|
],
|
|
));
|
|
}
|
|
|
|
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted);
|
|
|
|
return BarChart(
|
|
BarChartData(
|
|
maxY: maxE * 1.15,
|
|
alignment: BarChartAlignment.spaceAround,
|
|
barTouchData: BarTouchData(
|
|
touchTooltipData: BarTouchTooltipData(
|
|
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85),
|
|
getTooltipItem: (group, _, rod, __) => BarTooltipItem(
|
|
_fmtWh(rod.toY),
|
|
EtmTokens.mono(size: 10, color: rod.color ?? Colors.white),
|
|
),
|
|
),
|
|
),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
getDrawingHorizontalLine: (_) => FlLine(color: EtmTokens.line, strokeWidth: 1),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
titlesData: FlTitlesData(
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 52,
|
|
interval: maxE * 1.15 / 4,
|
|
getTitlesWidget: (v, meta) {
|
|
if (v == meta.max) return const SizedBox.shrink();
|
|
return SideTitleWidget(
|
|
meta: meta,
|
|
child: Text(_fmtWh(v), style: labelStyle),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 22,
|
|
getTitlesWidget: (v, meta) {
|
|
final idx = v.toInt();
|
|
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
|
final step = _xInterval().toInt().clamp(1, _data.length);
|
|
if (idx % step != 0) return const SizedBox.shrink();
|
|
return SideTitleWidget(
|
|
meta: meta,
|
|
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
barGroups: groups,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
double _xInterval() =>
|
|
_data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0;
|
|
|
|
double _barWidth() =>
|
|
_data.length > 40 ? 3 : _data.length > 20 ? 5 : 8;
|
|
|
|
String _fmtWh(double v) {
|
|
if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)} kWh';
|
|
return '${v.toStringAsFixed(0)} Wh';
|
|
}
|
|
|
|
String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime
|
|
? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'
|
|
: '${t.day}/${t.month}';
|
|
}
|
|
|
|
// ─────────────────────────── KPI section ───────────────────────────────────────
|
|
|
|
class _KpiSection extends StatelessWidget {
|
|
final NymeaService service;
|
|
const _KpiSection({required this.service});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final d = service.energyData;
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_KpiTile(
|
|
icon: Icons.wb_sunny_rounded,
|
|
color: EtmTokens.amber,
|
|
bgColor: EtmTokens.amberSoft,
|
|
label: 'Production',
|
|
value: _kwh(d.dayProductionWh),
|
|
),
|
|
const SizedBox(width: 14),
|
|
_KpiTile(
|
|
icon: Icons.home_rounded,
|
|
color: EtmTokens.blue,
|
|
bgColor: EtmTokens.blueSoft,
|
|
label: 'Consommation',
|
|
value: _kwh(d.daySelfConsumptionWh),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
_KpiTile(
|
|
icon: Icons.recycling_rounded,
|
|
color: EtmTokens.green,
|
|
bgColor: EtmTokens.greenSoft,
|
|
label: 'Autoconsommation',
|
|
value: '${d.selfConsumptionRate.toStringAsFixed(0)}%',
|
|
),
|
|
const SizedBox(width: 14),
|
|
_KpiTile(
|
|
icon: Icons.euro_rounded,
|
|
color: EtmTokens.greenDark,
|
|
bgColor: EtmTokens.greenSoft,
|
|
label: 'Gains',
|
|
value: '${d.dayGains.toStringAsFixed(2)} €',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
static String _kwh(double wh) => '${(wh / 1000).toStringAsFixed(2)} kWh';
|
|
}
|
|
|
|
class _KpiTile extends StatelessWidget {
|
|
final IconData icon;
|
|
final Color color;
|
|
final Color bgColor;
|
|
final String label;
|
|
final String value;
|
|
|
|
const _KpiTile({
|
|
required this.icon,
|
|
required this.color,
|
|
required this.bgColor,
|
|
required this.label,
|
|
required this.value,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.card,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
|
boxShadow: EtmTokens.cardShadow,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 36, height: 36,
|
|
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)),
|
|
child: Icon(icon, color: color, size: 20),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
|
|
const SizedBox(height: 2),
|
|
Text(value,
|
|
style: EtmTokens.mono(size: 15, weight: FontWeight.w700, color: color),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── Sélecteur période ─────────────────────────────────
|
|
|
|
class _PeriodSelector extends StatelessWidget {
|
|
final List<_Tab> tabs;
|
|
final int selectedIndex;
|
|
final ValueChanged<int> onSelect;
|
|
|
|
const _PeriodSelector({
|
|
required this.tabs,
|
|
required this.selectedIndex,
|
|
required this.onSelect,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.card,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
|
boxShadow: EtmTokens.cardShadow,
|
|
),
|
|
child: Row(
|
|
children: tabs.asMap().entries.map((e) {
|
|
final selected = selectedIndex == e.key;
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => onSelect(e.key),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.symmetric(vertical: 9),
|
|
decoration: BoxDecoration(
|
|
color: selected ? EtmTokens.green : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radius - 4),
|
|
),
|
|
child: Text(
|
|
e.value.label,
|
|
textAlign: TextAlign.center,
|
|
style: EtmTokens.sans(
|
|
size: 13,
|
|
weight: selected ? FontWeight.w600 : FontWeight.w400,
|
|
color: selected ? Colors.white : EtmTokens.muted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── Chip date ─────────────────────────────────────────
|
|
|
|
class _DateChip extends StatelessWidget {
|
|
final DateTime date;
|
|
final VoidCallback onClear;
|
|
const _DateChip({required this.date, required this.onClear});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: GestureDetector(
|
|
onTap: onClear,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.blueSoft,
|
|
borderRadius: BorderRadius.circular(99),
|
|
border: Border.all(color: EtmTokens.blue.withValues(alpha: 0.4)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.calendar_today_rounded, size: 12, color: EtmTokens.blue),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'${date.day.toString().padLeft(2, '0')}/'
|
|
'${date.month.toString().padLeft(2, '0')}/'
|
|
'${date.year}',
|
|
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.blue),
|
|
),
|
|
const SizedBox(width: 6),
|
|
const Icon(Icons.close_rounded, size: 12, color: EtmTokens.blue),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── Carte graphe ──────────────────────────────────────
|
|
|
|
class _EtmChartCard extends StatelessWidget {
|
|
final String title;
|
|
final String subtitle;
|
|
final List<Widget> legend;
|
|
final double height;
|
|
final bool loading;
|
|
final bool noData;
|
|
final Widget child;
|
|
|
|
const _EtmChartCard({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.legend,
|
|
required this.height,
|
|
required this.loading,
|
|
required this.noData,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.card,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
|
boxShadow: EtmTokens.cardShadow,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title,
|
|
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
|
Text(subtitle,
|
|
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(spacing: 14, runSpacing: 4, children: legend),
|
|
const SizedBox(height: 14),
|
|
SizedBox(
|
|
height: height,
|
|
child: loading
|
|
? Center(child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: EtmTokens.green))
|
|
: noData
|
|
? Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.bar_chart_rounded,
|
|
color: EtmTokens.faint, size: 32),
|
|
const SizedBox(height: 8),
|
|
Text('Aucune donnée',
|
|
style: EtmTokens.sans(size: 13, color: EtmTokens.muted)),
|
|
],
|
|
),
|
|
)
|
|
: child,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── Légende ───────────────────────────────────────────
|
|
|
|
class _LegendDot extends StatelessWidget {
|
|
final Color color;
|
|
final String label;
|
|
final bool dashed;
|
|
|
|
const _LegendDot(this.color, this.label, {this.dashed = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
dashed
|
|
? Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Container(width: 5, height: 2, color: color),
|
|
const SizedBox(width: 2),
|
|
Container(width: 5, height: 2, color: color),
|
|
])
|
|
: Container(
|
|
width: 14, height: 3,
|
|
decoration: BoxDecoration(
|
|
color: color, borderRadius: BorderRadius.circular(2))),
|
|
const SizedBox(width: 5),
|
|
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── Météo placeholder ─────────────────────────────────
|
|
|
|
class _ForecastPlaceholder extends StatelessWidget {
|
|
const _ForecastPlaceholder();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.card,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
|
boxShadow: EtmTokens.cardShadow,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Météo & prévision',
|
|
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
|
Text('aujourd\'hui',
|
|
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.bg,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 30),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Prévisions non disponibles',
|
|
style: EtmTokens.sans(size: 13, weight: FontWeight.w500,
|
|
color: EtmTokens.muted)),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Configurez un provider dans Tarifs & Héos '
|
|
'pour activer les prévisions PV et tarifaires.',
|
|
style: EtmTokens.sans(size: 11, color: EtmTokens.faint)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|