etm-powersync-app/lib/screens/energy_screen.dart
Patrick Schurig 23a84fec43 feat(dashboard): le bilan du JOUR — et deux bugs d'unité trouvés en le posant
L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages
instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
« aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois.

MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une
intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que
ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon
représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence
rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli
silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit.

DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune
documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en
W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille —
LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh.

  1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh
     s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans
     connaître l'ordre de grandeur attendu.
  2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un
     facteur mille contre une vraie box.

LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le
seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il
tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur
cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce
qui ne se recoupe pas.

Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via
GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes
clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de
soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine.
Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de
fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence.

Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux
d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut
alors qu'il n'y a rien à autoconsommer.

VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a
été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 ·
export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9.

Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES
QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe
« aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en
en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-28 07:57:57 +02:00

908 lines
32 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/energy_ratios.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.bgOf(context),
body: CustomScrollView(
slivers: [
// AppBar
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bgOf(context),
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.mutedOf(context),
size: 20,
),
onPressed: _pickDate,
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Icon(Icons.refresh_rounded,
color: EtmTokens.mutedOf(context), 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 (kWh)',
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));
// INTERIM (§2.2) — bande autoconso via la formule canonique du seam.
autoSpots .add(FlSpot(
x, EnergyRatiosInterim.selfConsumptionPower(d.productionW, d.acquisitionW) / 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.mutedOf(context));
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.lineOf(context), 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++) {
// Les compteurs sont en kWh — le graphe traçait leur différence en la nommant « Wh »
// et en la divisant encore par mille à l'affichage : une journée de 78 kWh s'y lisait
// « 78 Wh ». Corrigé le 2026-08-27, l'unité vérifiée par recoupement sur `.75`.
final prodKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionKwh - _data[i - 1].totalProductionKwh);
final consoKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionKwh - _data[i - 1].totalConsumptionKwh);
maxE = [maxE, prodKwh, consoKwh].reduce(max);
groups.add(BarChartGroupData(
x: i,
barsSpace: 2,
barRods: [
BarChartRodData(
toY: prodKwh, color: EtmTokens.amber, width: bw,
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
),
BarChartRodData(
toY: consoKwh, color: EtmTokens.blue, width: bw,
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
),
],
));
}
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
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.lineOf(context), 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;
/// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne
/// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une
/// journée entière.
String _fmtWh(double v) {
if (v.abs() < 1) return '${(v * 1000).toStringAsFixed(0)} Wh';
return '${v.toStringAsFixed(1)} kWh';
}
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.surfaceOf(context),
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.mutedOf(context))),
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.surfaceOf(context),
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.mutedOf(context),
),
),
),
),
);
}).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.surfaceOf(context),
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.mutedOf(context))),
],
),
],
),
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.faintOf(context), size: 32),
const SizedBox(height: 8),
Text('Aucune donnée',
style: EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))),
],
),
)
: 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.mutedOf(context))),
],
);
}
}
// ─────────────────────────── 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.surfaceOf(context),
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.mutedOf(context))),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: EtmTokens.bgOf(context),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.cloud_outlined, color: EtmTokens.faintOf(context), 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.mutedOf(context))),
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.faintOf(context))),
],
),
),
],
),
),
],
),
);
}
}