etm-powersync-app/lib/screens/energy_screen.dart
Patrick Schurig d6240984ee feat(énergie): naviguer de période en période, et un sous-titre en moins
DEUX DEMANDES DE PATRICK.

1. Une barre de navigation sous les onglets, à la place de la puce de date :

     ‹        3 sept. → 9 sept.        ›   Aujourd'hui

   Le libellé suit la granularité de l'onglet — « 9 sept. 2026 », « 3 → 9 sept. »,
   « sept. 2026 », « 2026 » — parce que dire « jour » en montrant une semaine est
   exactement l'erreur que les onglets faisaient avant d'être corrigés.

   Le sélecteur existait déjà dans la barre du haut, mais il permettait de sauter
   à un jour, pas de reculer d'une semaine puis d'une autre. Or lire une
   consommation, c'est presque toujours la comparer à la période précédente : ce
   geste-là doit coûter un appui, pas l'ouverture d'un calendrier et le calcul
   mental de la date d'avant.

   La flèche « suivant » est GRISÉE, pas masquée : une commande qui disparaît
   laisse chercher où elle est passée, une commande grisée dit « il n'y a rien
   après ». Et elle est bornée au présent — sans ça on part dans un futur où la
   box n'a rien, avec un graphe vide qui ne dit pas qu'il l'est PARCE QU'on est
   allé trop loin.

2. Le sous-titre « le bloc orange est partagé : ce qui a été produit ET gardé »
   est supprimé.

Et une variable morte retirée avec sa raison : le soutiré n'est pas relu de
`totalAcquisition` mais dérivé de `consommation − autoconsommé`. Le relire
ferait une pile dont les segments ne rejoignent pas leur sommet dès qu'un
compteur dérive — un trou dans la barre que personne ne saurait lire.
L'identité des compteurs se vérifie ailleurs, sur le bilan du jour, où elle a
une phrase pour se dire.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 10:44:44 +02:00

2021 lines
85 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:math' show max;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/consumer_series.dart';
import '../models/daily_balance.dart';
import '../models/energy_data.dart';
import '../models/nymea_models.dart';
import '../providers/load_config_provider.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
// ─────────────────────────────────────────────────────────────────────────────
/// **La barre de navigation de période** — deux flèches, un libellé qui ouvre le calendrier.
///
/// Lire une consommation, c'est presque toujours la comparer à la période précédente. Le
/// geste le plus fréquent doit donc coûter un appui, pas l'ouverture d'un calendrier et le
/// calcul mental de la date d'avant.
class _NavPeriode extends StatelessWidget {
final String libelle;
final bool peutAvancer;
final bool surAujourdhui;
final VoidCallback onReculer;
final VoidCallback onAvancer;
final VoidCallback onChoisir;
final VoidCallback onAujourdhui;
const _NavPeriode({
required this.libelle,
required this.peutAvancer,
required this.surAujourdhui,
required this.onReculer,
required this.onAvancer,
required this.onChoisir,
required this.onAujourdhui,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(color: EtmTokens.lineOf(context)),
),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(children: [
IconButton(
icon: const Icon(Icons.chevron_left_rounded, size: 22),
color: EtmTokens.inkOf(context),
onPressed: onReculer,
tooltip: 'Période précédente',
),
Expanded(
child: TextButton(
onPressed: onChoisir,
child: Text(libelle,
textAlign: TextAlign.center,
style: EtmTokens.sans(
size: 13.5,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
),
),
// ⚠️ La flèche « suivant » est DÉSACTIVÉE, pas masquée : une commande qui
// disparaît laisse chercher où elle est passée, là où une commande grisée dit
// « il n'y a rien après ».
IconButton(
icon: const Icon(Icons.chevron_right_rounded, size: 22),
color: peutAvancer ? EtmTokens.inkOf(context) : muted.withValues(alpha: 0.35),
onPressed: peutAvancer ? onAvancer : null,
tooltip: 'Période suivante',
),
if (!surAujourdhui)
TextButton(
onPressed: onAujourdhui,
style: TextButton.styleFrom(minimumSize: const Size(0, 40)),
child: Text('Aujourd\'hui',
style: EtmTokens.sans(size: 12, color: EtmTokens.ecoOf(context))),
),
]),
);
}
}
/// Une ligne d'explication sous un graphe — elle ne sort que quand elle DIT quelque chose.
class _NoteEcran extends StatelessWidget {
final IconData icon;
final String texte;
const _NoteEcran({required this.icon, required this.texte});
@override
Widget build(BuildContext context) {
final c = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 14, color: c),
const SizedBox(width: 7),
Expanded(
child: Text(texte,
style: EtmTokens.sans(size: 10.5, color: c, height: 1.35))),
]),
);
}
}
/// Comment une vue se dessine — et ce n'est pas un détail de style.
///
/// `courbes` porte des **puissances** (kW) échantillonnées ; `barres` porte des **énergies**
/// (kWh) accumulées sur la période. Une semaine de puissances instantanées ne veut rien
/// dire : au pas du jour, ce qu'on peut lire est ce qui a été produit et consommé, pas ce
/// qui passait à un instant.
enum _Rendu { courbes, barres }
class _Tab {
final String label;
final Duration range;
final String sampleRate;
final bool showTime;
final _Rendu rendu;
/// Vrai si la plage se cale sur **une journée civile** (00:00 → 24:00) plutôt que sur
/// les N dernières heures. C'est ce que « Jour » promet, et ce qu'il ne faisait pas.
final bool journeeCivile;
const _Tab(this.label, this.range, this.sampleRate,
{required this.showTime,
this.rendu = _Rendu.courbes,
this.journeeCivile = false});
}
class EnergyScreen extends StatefulWidget {
const EnergyScreen({super.key});
@override
State<EnergyScreen> createState() => _EnergyScreenState();
}
class _EnergyScreenState extends State<EnergyScreen> {
/// 🔴 **Chaque onglet portait le nom d'un cran EN DESSOUS de sa plage** — « Jour »
/// montrait 7 jours, « Semaine » 28, « Mois » 365. Quatre étiquettes, quatre mensonges,
/// et personne ne pouvait obtenir la journée que le premier promettait. Corrigé le
/// 2026-09-09 ; l'onglet « Année » manquait, puisque c'est « Mois » qui la montrait.
static const _tabs = [
// 72 h au quart d'heure : la fenêtre n'en montre que 12, mais on peut remonter sur
// trois jours sans changer d'onglet (demande de Patrick, 2026-09-09).
_Tab('Heures', Duration(hours: 72), 'SampleRate15Mins', showTime: true),
_Tab('Jour', Duration(days: 1), 'SampleRate1Hour',
showTime: true, rendu: _Rendu.barres, journeeCivile: true),
_Tab('Semaine', Duration(days: 7), 'SampleRate1Day',
showTime: false, rendu: _Rendu.barres),
_Tab('Mois', Duration(days: 30), 'SampleRate1Day',
showTime: false, rendu: _Rendu.barres),
_Tab('Année', Duration(days: 365), 'SampleRate1Month',
showTime: false, rendu: _Rendu.barres),
];
int _tabIdx = 0;
List<PowerBalanceEntry> _data = [];
List<HistoryEntry> _socData = [];
ConsumerHistory _conso = ConsumerHistory.vide;
/// Le bilan du JOUR — indépendant de l'onglet de période choisi juste en dessous.
/// C'est délibéré : les tuiles disent la journée, les graphes disent la fenêtre.
DailyBalance? _bilan;
/// **Le premier point visible** de la fenêtre glissante, en index d'échantillon.
///
/// La vue « Heures » porte 96 points (24 h au quart d'heure) : tout montrer d'un coup
/// écrase le détail, et il n'y a alors rien à faire glisser. On en montre donc la moitié
/// et on se déplace dedans.
///
/// ⚠️ **On borne minX/maxX, on ne DÉCOUPE pas les données.** Découper renumérotrait les
/// index, et l'axe du bas — qui lit `_data[x.round()]` — désignerait alors les mauvaises
/// heures. Les séries restent entières, la fenêtre ne fait que cadrer.
double _fenetreDebut = 0;
/// **Douze heures visibles à la fois**, quelle que soit la profondeur des données.
///
/// C'était « la moitié du jeu » : avec 24 h de données, ça donnait 12 h — mais en passant
/// à 72 h ça en aurait donné 36, et la fenêtre aurait grandi avec les données au lieu de
/// rester une fenêtre. Une durée fixe garde la même densité de lecture quel que soit
/// l'historique chargé.
static const Duration _fenetreDuree = Duration(hours: 12);
int? get _fenetreTaille {
final tab = _tabs[_tabIdx];
if (tab.rendu != _Rendu.courbes) return null; // seules les courbes glissent
final pas = _pasEchantillon(tab).inSeconds;
if (pas <= 0) return null;
final points = _fenetreDuree.inSeconds ~/ pas;
return _data.length > points + 1 ? points : null;
}
bool get _glissable => _fenetreTaille != null;
/// Dernier point visible. Sans fenêtre, c'est tout le jeu de données.
double get _fenetreFin {
final taille = _fenetreTaille;
final dernier = (_data.length - 1).toDouble();
if (taille == null) return dernier;
return (_fenetreDebut + taille).clamp(0, dernier).toDouble();
}
/// Déplace la fenêtre, **bornée aux données** : on ne glisse pas dans le vide.
///
/// Le vide serait pire qu'une butée — il ferait croire qu'il n'y a rien à cet endroit,
/// alors qu'il n'y a simplement pas d'endroit.
void _glisser(double deltaPoints) {
final taille = _fenetreTaille;
if (taille == null) return;
final maxDebut = (_data.length - 1 - taille).toDouble();
setState(() =>
_fenetreDebut = (_fenetreDebut + deltaPoints).clamp(0, max(0, maxDebut)));
}
/// Vrai quand la box a livré ses Things ET qu'aucun ne porte de batterie.
///
/// Les deux moitiés comptent : tant que les Things ne sont pas chargés, « pas de
/// batterie » et « on ne sait pas encore » sont indiscernables, et annoncer l'absence
/// trop tôt serait une absence côté APP présentée comme une absence côté BOX.
bool get _sansBatterie =>
(_nymeaService?.thingsLoaded ?? false) &&
_nymeaService?.batterySOCSource == null;
bool get _aUneBatterie => _nymeaService?.batterySOCSource != null;
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>();
// 🔴 **Plus d'auto-simulation ici.** L'écran faisait
// `if (!connected) startSimulation()` : monté avant que la WebSocket soit établie —
// le cas NORMAL, puisqu'on y arrive en tapant l'installation — il basculait tout le
// service en démo, et n'en ressortait jamais.
//
// Vu à l'écran le 2026-09-08, box parfaitement joignable : une sinusoïde orange et
// une courbe de SOC à 75 % sur une installation **qui n'a pas de batterie**. Rien ne
// distinguait ces données inventées des vraies — ni couleur, ni mention, ni forme.
// C'est la fabrication sans même l'étiquette qui la blanchit.
//
// Le tableau de bord avait retiré la même chose pour la même raison : la démo est un
// CHOIX, pris depuis l'écran Installations, jamais un repli silencieux.
_nymeaService!.addListener(_onServiceChanged);
_fetch();
});
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) {
if (mounted) _fetch();
});
}
@override
void dispose() {
_refreshTimer?.cancel();
_nymeaService?.removeListener(_onServiceChanged);
super.dispose();
}
/// Rattrape ce qui n'était pas encore disponible au montage.
///
/// Deux cas, et le premier est la raison d'être de ce listener depuis qu'on ne simule
/// plus : la **connexion** peut arriver après l'écran. Sans ce rattrapage, l'écran
/// resterait vide jusqu'au tick de cinq minutes.
void _onServiceChanged() {
if (!mounted || _loading) return;
final svc = _nymeaService;
if (svc == null) return;
if (svc.connected && _data.isEmpty) {
_fetch();
return;
}
if (!_initialSocFetched && svc.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 (from, to) = _fenetreRequete(tab);
final service = context.read<NymeaService>();
final socSource = service.batterySOCSource;
// **La liste des consommateurs vient de `GetLoadConfig`, pas d'une devinette.**
//
// Chaque charge déclarée porte son `meterThingId` — vérifié renseigné sur les quatre
// charges de `.75` le 2026-09-08 — plus son `label` et son `domain`. On demande donc
// l'historique DES COMPTEURS DES CHARGES DÉCLARÉES : rien n'est deviné à partir d'un
// nom d'appareil, et une charge sans compteur déclaré n'entre simplement pas.
final cfg = context.read<LoadConfigProvider>();
if (cfg.entries.isEmpty) {
cfg
..attach(service)
..load();
}
final appareils = [
for (final e in cfg.entries)
if (e.meterThingId.isNotEmpty)
(id: e.meterThingId, label: e.label, domaine: e.domain)
];
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([]),
service.fetchThingPowerLogs(
appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate),
service.fetchDailyBalance(),
]);
if (!mounted) return;
setState(() {
_data = results[0] as List<PowerBalanceEntry>;
_socData = results[1] as List<HistoryEntry>;
_conso = results[2] as ConsumerHistory;
_bilan = results[3] as DailyBalance?;
// **La fenêtre s'ouvre sur le plus RÉCENT.** Avec 72 h chargées, la caler au début
// montrerait avant-hier soir à l'ouverture de l'écran — la seule vue dont personne
// n'a besoin en premier. Recalculée à chaque lecture : garder l'ancienne cadrerait
// une plage d'index qui ne désigne plus les mêmes heures.
final taille = _fenetreTaille;
_fenetreDebut =
taille == null ? 0 : max(0, _data.length - 1 - taille).toDouble();
_loading = false;
_noData = _data.isEmpty;
});
}
/// La fenêtre demandée à la box, **et pourquoi elle déborde d'un échantillon**.
///
/// Les barres portent des ÉNERGIES, obtenues par différence de compteurs cumulés entre
/// deux échantillons. Il faut donc un point AVANT le début de la période, sans quoi la
/// première barre n'a pas de prédécesseur — et une barre sans prédécesseur ne vaut pas
/// zéro, elle n'existe pas. Demander une heure de plus coûte un point et évite d'inventer
/// la première.
///
/// Pour « Jour », la fenêtre se cale sur la **journée civile** 00:00 → 24:00 : c'est ce
/// que l'onglet promet. Les autres restent glissantes à partir de maintenant.
(DateTime, DateTime) _fenetreRequete(_Tab tab) {
final ancre = _anchorDate();
// 🔴 **Toutes les vues en barres se calent sur minuit**, pas seulement « Jour ».
//
// Sans ça, une plage de 7 jours partant de 08:12 coupe deux journées en deux : le
// premier et le dernier échantillon tombent le même jour civil, l'axe affiche « 6/9 »
// deux fois, et on obtient huit barres pour une semaine. Vu à l'écran le 2026-09-09.
// Une barre journalière doit couvrir une journée entière ou ne pas exister.
final fin = (tab.journeeCivile || tab.rendu == _Rendu.barres)
? DateTime(ancre.year, ancre.month, ancre.day).add(const Duration(days: 1))
: ancre;
final debut = fin.subtract(tab.range);
if (tab.rendu != _Rendu.barres) return (debut, fin);
return (debut.subtract(_pasEchantillon(tab)), fin);
}
static Duration _pasEchantillon(_Tab tab) => switch (tab.sampleRate) {
'SampleRate15Mins' => const Duration(minutes: 15),
'SampleRate1Hour' => const Duration(hours: 1),
'SampleRate1Day' => const Duration(days: 1),
'SampleRate1Week' => const Duration(days: 7),
'SampleRate1Month' => const Duration(days: 31),
_ => const Duration(hours: 1),
};
DateTime _anchorDate() => _selectedDate != null
? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59)
: DateTime.now();
/// Le nom de la période affichée, à la granularité de l'onglet.
///
/// « 9 sept. » n'est pas « 3 – 9 sept. » : dire « jour » quand on montre une semaine est
/// exactement l'erreur que les onglets faisaient avant d'être corrigés.
String _libellePeriode() {
const mois = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'
];
final tab = _tabs[_tabIdx];
final fin = _anchorDate();
final debut = fin.subtract(tab.range - const Duration(days: 1));
String jour(DateTime d) => '${d.day} ${mois[d.month - 1]}';
return switch (tab.label) {
'Année' => '${fin.year}',
'Mois' => '${mois[fin.month - 1]} ${fin.year}',
'Jour' || 'Heures' => '${jour(fin)} ${fin.year}',
_ => '${jour(debut)} → ${jour(fin)}',
};
}
/// Avancer n'a de sens que si la période suivante a commencé.
///
/// Sans cette borne, on part dans un futur où la box n'a rien : un graphe vide qui ne
/// dit pas qu'il est vide **parce qu'on est allé trop loin**.
bool _peutAvancer() =>
_selectedDate != null &&
_anchorDate().add(_tabs[_tabIdx].range).isBefore(DateTime.now());
void _decalerPeriode(int sens) {
final tab = _tabs[_tabIdx];
final base = _selectedDate ?? DateTime.now();
final cible = base.add(tab.range * sens);
setState(() {
// Revenir sur aujourd'hui remet le mode « suivi », où la fenêtre se recale à chaque
// lecture. Rester sur une date figée l'aurait gelée sans le dire.
final maintenant = DateTime.now();
_selectedDate = cible.isAfter(maintenant) ? null : cible;
_initialSocFetched = false;
});
_fetch();
}
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(bilan: _bilan),
const SizedBox(height: 16),
// Sélecteur période
_PeriodSelector(
tabs: _tabs,
selectedIndex: _tabIdx,
onSelect: (i) {
if (_tabIdx != i) {
setState(() { _tabIdx = i; _initialSocFetched = false; });
_fetch();
}
},
),
// **Naviguer de période en période**, pas seulement choisir une date.
//
// Le sélecteur de la barre du haut permettait de sauter à un jour, pas
// de reculer d'une semaine puis d'une autre. Or lire une consommation,
// c'est presque toujours la comparer à la période précédente : le geste
// le plus fréquent doit être une flèche, pas un calendrier.
const SizedBox(height: 10),
_NavPeriode(
libelle: _libellePeriode(),
peutAvancer: _peutAvancer(),
onReculer: () => _decalerPeriode(-1),
onAvancer: () => _decalerPeriode(1),
onChoisir: _pickDate,
surAujourdhui: _selectedDate == null,
onAujourdhui: () {
setState(() => _selectedDate = null);
_fetch();
},
),
const SizedBox(height: 16),
// ── Vues d'ÉNERGIE : une seule carte, en barres ──────────────
//
// Au pas du jour, une puissance instantanée ne veut rien dire : ce
// qu'on peut lire est ce qui a été produit et consommé. Les deux
// graphes de puissance n'ont donc pas de version « semaine » — ils
// sont remplacés, pas empilés avec.
if (_tabs[_tabIdx].rendu == _Rendu.barres) ...[
_EtmChartCard(
title: 'Bilan énergétique',
subtitle: 'kWh par période',
legend: [
_LegendDot(EtmTokens.selfUse, 'Autoconsommé'),
_LegendDot(EtmTokens.amber, 'Exporté'),
_LegendDot(EtmTokens.blue, 'Soutiré'),
],
height: 240,
loading: _loading,
noData: _noData,
child: _buildEnergyBars(),
),
const SizedBox(height: 14),
_EtmChartCard(
title: 'Où est passée la consommation',
subtitle: 'kWh par période, par charge déclarée',
legend: [
for (final s in _conso.series)
_LegendDot(
_couleursSeries(context)[s.thingId] ??
EtmTokens.mutedOf(context),
s.label),
const _LegendDot(EtmTokens.brand, 'Reste du foyer'),
],
height: 240,
loading: _loading,
noData: _noData,
child: _buildConsumerEnergyBars(),
),
_notesConsommateurs(),
] else ...[
// Graphe Puissances (· SOC % quand il y a une batterie)
//
// ⚠️ **La légende ne promet pas une courbe qui n'existe pas.**
// « SOC % » y figurait en dur : sur une installation sans batterie,
// l'écran annonçait donc une série qu'aucune donnée ne pouvait
// remplir. Vu à l'écran le 2026-09-08 — c'est le même défaut que
// partout ailleurs (montrer ce qu'on n'a pas), pris par la légende
// au lieu du tracé.
_EtmChartCard(
title: _aUneBatterie
? 'Puissances · SOC %'
: 'Puissances',
subtitle: _aUneBatterie
? 'kW — le sommet de la pile est la production · % batterie (droite)'
: 'kW — le sommet de la pile est la production',
legend: [
_LegendDot(EtmTokens.selfUse, 'Autoconsommé'),
_LegendDot(EtmTokens.amber, 'Exporté'),
_LegendDot(EtmTokens.blue, 'Consommation'),
if (_aUneBatterie)
_LegendDot(EtmTokens.ev, 'SOC %', dashed: true),
],
height: 220,
loading: _loading,
noData: _noData,
// Sur les DEUX cartes : elles partagent le cadrage, mais si on
// défile jusqu'à ne voir que la seconde, elle doit se suffire.
sousLegende: _bandeauPlage(context),
child: _glissant(_buildLineChart()),
),
// La note d'absence de batterie va SOUS ce graphe-ci : c'est ici que
// le SOC aurait été tracé, donc c'est ici qu'il manque.
if (_sansBatterie)
const _NoteEcran(
icon: Icons.battery_unknown_rounded,
texte: 'Aucune batterie sur cette installation : ni charge, ni '
'décharge, ni état de charge. Rien n\'est masqué — il n\'y a '
'rien à tracer.',
),
const SizedBox(height: 14),
// Graphe : qui consomme
_EtmChartCard(
title: 'Qui consomme',
subtitle: 'Puissance empilée, par charge déclarée',
legend: [
const _LegendDot(EtmTokens.brand, 'Reste du foyer'),
for (final s in _conso.series)
_LegendDot(
_couleursSeries(context)[s.thingId] ??
EtmTokens.mutedOf(context),
s.label),
],
height: 220,
loading: _loading,
noData: _noData,
sousLegende: _bandeauPlage(context),
child: _glissant(_buildConsumerChart()),
),
// L'indice ne sort que si le geste existe : une aide permanente
// cesse d'être lue, et celle-ci serait fausse sur les autres vues.
if (_glissable)
const _NoteEcran(
icon: Icons.swipe_rounded,
texte: 'Glissez horizontalement pour remonter sur trois jours. '
'Les deux graphes gardent le même cadrage.',
),
],
// Les notes parlent des séries par consommateur : elles n'ont aucun
// sens sous un graphe d'énergie qui n'en montre aucune. Vu à l'écran :
// « V2C Trydan n'est mesurée que depuis 00:00 » sous les barres de la
// semaine, où aucune V2C n'est tracée.
_notesConsommateurs(),
const SizedBox(height: 14),
// Météo & prévision — placeholder
_ForecastPlaceholder(),
]),
),
),
],
),
);
},
);
}
// ── ① Line chart : puissances + SOC ────────────────────────────────────────
/// **Ce qu'on regarde, avec sa date** — `null` hors vue glissable.
///
/// Sur 24 h, « 09:00 » désignait sans ambiguïté ce matin. Sur trois jours, la même
/// étiquette peut être l'un de trois matins, et l'axe seul ne le dit pas : il n'affiche
/// que des heures. La plage visible porte donc le jour, faute de quoi l'écran répond
/// exactement à côté de la question « c'était quand ? ».
String? _plageVisible() {
if (!_glissable || _data.isEmpty) return null;
final a = _data[_fenetreDebut.round().clamp(0, _data.length - 1)].timestamp;
final b = _data[_fenetreFin.round().clamp(0, _data.length - 1)].timestamp;
String jour(DateTime t) => '${t.day}/${t.month}';
String heure(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
return jour(a) == jour(b)
? '${jour(a)} · ${heure(a)} → ${heure(b)}'
: '${jour(a)} ${heure(a)} → ${jour(b)} ${heure(b)}';
}
/// La plage visible, prête à être posée sous une légende. `null` quand il n'y a rien à
/// qualifier — une vue non glissable montre tout, sa plage est celle de l'onglet.
Widget? _bandeauPlage(BuildContext context) {
final t = _plageVisible();
if (t == null) return null;
return Row(children: [
Icon(Icons.date_range_rounded, size: 13, color: EtmTokens.mutedOf(context)),
const SizedBox(width: 6),
Text(t,
style: EtmTokens.mono(
size: 11.5,
weight: FontWeight.w600,
color: EtmTokens.mutedOf(context))),
]);
}
/// Rend un graphe **glissable à l'horizontale** quand la vue s'y prête.
///
/// Le geste convertit des pixels en points d'échantillon : glisser d'une largeur d'écran
/// déplace d'une fenêtre entière, ce qui rend le mouvement indépendant de la densité des
/// données. Hors vue « Heures », le graphe est rendu tel quel — envelopper un widget
/// non glissable dans un détecteur de geste volerait le défilement vertical de la page
/// pour rien.
Widget _glissant(Widget graphe) {
if (!_glissable) return graphe;
final taille = _fenetreTaille!;
return LayoutBuilder(
builder: (context, c) => GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) {
final largeur = c.maxWidth <= 0 ? 1.0 : c.maxWidth;
_glisser(-d.delta.dx / largeur * taille);
},
child: graphe,
),
);
}
Widget _buildLineChart({bool axeGauche = true}) {
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();
// 🔴 **La MAGNITUDE, et c'est ici que le signe se corrige — pas au transport.**
//
// `.75` publie `production` NÉGATIF : −103 W ce matin. Le champ reste brut dans
// `PowerBalanceEntry` (§7.1 du contrat), donc c'est au rendu de prendre la valeur
// absolue. Sans ça, deux courbes disparaissaient pour la même raison — signalé par
// Patrick le 2026-09-08 : la production tracée à −0,1 kW sous une échelle qui monte
// à 3,1, et l'autoconsommation clouée à 0 parce que `production − export` est
// toujours négatif.
//
// `.abs()` est licite **sur une production** : une installation ne produit pas une
// puissance négative, donc le signe ne porte aucune information à préserver. Ce
// n'est pas vrai du réseau, où le signe DIT le sens — d'où `gridImportW` /
// `gridExportW` plutôt qu'un `.abs()` là-bas.
final prodW = d.productionW.abs();
// INTERIM (§2.2) — bande autoconso via la formule canonique du seam.
final selfW = EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW);
// `prodSpots` porte désormais le SOMMET DE LA PILE, c'est-à-dire la production :
// autoconsommé + exporté. L'égalité est exacte par construction — l'exporté est
// défini comme `production − autoconsommé` — donc la pile ne peut pas mentir sur
// son total.
prodSpots .add(FlSpot(x, prodW / 1000));
autoSpots .add(FlSpot(x, selfW / 1000));
consoSpots.add(FlSpot(x, d.consumptionW.abs() / 1000));
}
// Plage Y gauche en kW
// 🔴 **L'échelle suit ce qui est VISIBLE, pas tout le jeu chargé.**
//
// Calculée sur les 72 h, une pointe d'avant-hier écrase le détail d'aujourd'hui : vu à
// l'écran le 2026-09-09, un axe à 11,9 kW pour une fenêtre qui ne dépasse pas 2,5 —
// les courbes rampaient au ras de l'abscisse. Le glissement servait alors à déplacer
// une loupe sans grossissement.
//
// C'est le passage à 72 h qui l'a créé : sur 24 h, fenêtre et jeu de données avaient
// presque la même amplitude, et le défaut ne pouvait pas se voir.
final iDebut = _fenetreDebut.floor().clamp(0, _data.length - 1);
final iFin = _fenetreFin.ceil().clamp(0, _data.length - 1);
final allKw = _data
.sublist(iDebut, iFin + 1)
.expand((d) => [d.productionW.abs() / 1000, d.consumptionW.abs() / 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(),
minX: _fenetreDebut,
maxX: _fenetreFin,
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: axeGauche,
reservedSize: axeGauche ? 46 : 0,
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.ev),
),
);
},
),
)
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
// Axe bas : temps
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: xInterval,
getTitlesWidget: (v, meta) {
// ⚠️ **Aucune étiquette sur les deux BORDS.** fl_chart en pose une à `min`
// et à `max` en plus de celles de l'intervalle : quand les bornes ne
// tombent pas sur un multiple, elles se superposent à leur voisine —
// « 20:4500 » à gauche, « 08:1509:00 » à droite, vus à l'écran le
// 2026-09-09. Deux heures imprimées l'une sur l'autre ne se lisent ni
// l'une ni l'autre.
//
// On ne perd rien : la ligne de plage sous le graphe donne les deux bornes
// exactes, avec leur jour — ce que l'axe ne pouvait pas dire de toute
// façon sur trois jours.
if (v <= meta.min || v >= meta.max) return const SizedBox.shrink();
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),
);
},
),
),
),
// ⚠️ **On EMPILE, on ne superpose pas.** Validé par Patrick le 2026-09-08.
//
// Superposées, autoconsommation et production sont identiques au watt près dès
// que rien n'est exporté — et deux traits pleins superposés n'en laissent voir
// qu'un, quelles que soient leurs couleurs. Le problème est géométrique, pas
// chromatique : changer de teinte ne l'aurait pas réglé, et le pointillé essayé
// avant n'était qu'un contournement.
//
// Empilées, il n'y a plus de recouvrement possible :
//
// jaune PV → réseau (exporté) ┐
// orange PV → maison (autoconsommé) ┘ sommet = production totale
//
// La production cesse d'être une courbe : elle EST le haut de la pile. Et quand
// rien n'est exporté, la bande jaune a une hauteur nulle — ce qui est vrai, et se
// lit d'un coup d'œil : « tout ce qui est produit est consommé sur place ».
// L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre deux
// traits.
//
// Tracé du haut vers le bas : chaque aire est remplie jusqu'à zéro et la suivante
// repeint par-dessus. Même procédé que l'empilement par consommateur.
lineBarsData: [
_aire(prodSpots, EtmTokens.amber), // sommet = production
_aire(autoSpots, EtmTokens.selfUse), // repeint la part autoconsommée
_line(consoSpots, EtmTokens.blue),
// Le SOC est en indigo, pas en vert pointillé : le vert pointillé a servi à
// l'autoconsommation le temps d'un essai, et sur une installation AVEC batterie
// les deux auraient été indiscernables. Aucun banc sans batterie ne pouvait le
// montrer.
if (hasSoc) _line(socSpots, EtmTokens.ev, 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(),
),
),
),
);
}
/// Une bande PLEINE de l'empilement — remplie jusqu'à zéro, sans courbure.
///
/// Pas de `isCurved` ici : une spline peut passer sous zéro entre deux points, et une
/// bande d'énergie qui plonge sous l'axe affiche une grandeur qui n'existe pas.
LineChartBarData _aire(List<FlSpot> spots, Color color) => LineChartBarData(
spots: spots,
isCurved: false,
color: color,
barWidth: 1.2,
dotData: const FlDotData(show: false),
belowBarData:
BarAreaData(show: true, color: color.withValues(alpha: 0.62)),
);
LineChartBarData _line(List<FlSpot> spots, Color color,
{bool dashed = false, double width = 2.0, bool fill = false}) =>
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.25,
color: color,
barWidth: width,
dotData: const FlDotData(show: false),
dashArray: dashed ? [4, 5] : null,
// Aucun remplissage par défaut depuis l'empilement : un voile, même à 7 %,
// recouvre les bandes et fausse leurs teintes. La consommation est une LIGNE
// au-dessus de la pile, pas une surface de plus.
belowBarData: BarAreaData(
show: fill && !dashed,
color: color.withValues(alpha: 0.07),
),
);
/// **Les périodes d'une vue en barres, regroupées par période CIVILE.**
///
/// 🔴 Ne pas se contenter des paires d'échantillons consécutifs. La box a changé de fuseau
/// le 2026-09-06 (`Europe/London` → `Europe/Paris`) et sa frontière de journée s'est
/// déplacée d'une heure : l'historique porte **deux échantillons pour le 06/09**, à 00:00
/// et à 01:00. Sur le graphe, ça donnait huit barres pour sept jours, deux étiquettes
/// « 6/9 », et une « journée » d'une heure à côté de journées entières.
///
/// La couture est **permanente** — elle est dans les données passées, pas dans une
/// requête. Tout écran qui compare des périodes doit donc grouper par période civile,
/// jamais par indices consécutifs.
///
/// On garde le **premier** échantillon de chaque période, et l'énergie d'une période est
/// la différence avec le premier de la suivante. Aucune énergie n'est perdue : la sliver
/// d'une heure est absorbée dans la journée à laquelle elle appartient.
List<({PowerBalanceEntry a, PowerBalanceEntry b})> _periodes() {
if (_data.length < 2) return const [];
final parPeriode = <String, PowerBalanceEntry>{};
for (final e in _data) {
final k = _clePeriode(e.timestamp);
// `putIfAbsent` : on garde le PREMIER de la période, pas le dernier.
parPeriode.putIfAbsent(k, () => e);
}
final cles = parPeriode.keys.toList()..sort();
return [
for (int i = 1; i < cles.length; i++)
(a: parPeriode[cles[i - 1]]!, b: parPeriode[cles[i]]!)
];
}
/// La clé de regroupement, selon la granularité de l'onglet. Triable en ordre
/// lexicographique — d'où le zéro devant.
String _clePeriode(DateTime t) {
final tab = _tabs[_tabIdx];
String d2(int v) => v.toString().padLeft(2, '0');
return switch (tab.sampleRate) {
'SampleRate1Hour' => '${t.year}-${d2(t.month)}-${d2(t.day)}T${d2(t.hour)}',
'SampleRate1Month' => '${t.year}-${d2(t.month)}',
_ => '${t.year}-${d2(t.month)}-${d2(t.day)}',
};
}
// ── ③ Barres d'ÉNERGIE — vues Jour / Semaine / Mois / Année ────────────────
/// Une période, deux barres empilées, et le bloc qu'elles partagent.
///
/// ## Pourquoi pas la production sous l'axe
///
/// Un diagramme divergent — consommation au-dessus, production en dessous — est une
/// convention défendable, et je l'ai écartée pour une raison propre à ce dépôt : la box
/// publie déjà `production` avec un signe négatif, et ce défaut nous a coûté deux écrans
/// et trois jours. Dessiner la production **sous l'axe** rendrait une capture d'écran
/// correcte indiscernable de ce bug. Une production n'est pas une quantité négative ;
/// l'afficher comme telle entretient exactement la confusion qu'on vient de payer.
///
/// ## Ce que les deux barres disent, et ce que le bloc commun ajoute
///
/// ```
/// Production = autoconsommé + exporté
/// Consommation = autoconsommé + soutiré
/// ```
///
/// Le segment **autoconsommé** est le même des deux côtés, à la même couleur : c'est ce
/// qui a été produit ET gardé. Deux barres simples (production, consommation) diraient
/// laquelle est la plus haute ; celles-ci disent en plus **où est passée la différence**,
/// et c'est la question qu'on se pose devant un bilan.
///
/// ## L'énergie vient des CUMULS, jamais des puissances
///
/// Chaque barre est une différence de compteurs entre deux échantillons. Une période dont
/// l'une des deux bornes ne publie pas ses cumuls n'a **pas de barre** — pas une barre à
/// zéro : « la box ne l'a pas publié » et « rien n'a été consommé » sont deux états, et
/// une nuit à 0 kWh serait indiscernable d'un trou.
Widget _buildEnergyBars() {
if (_loading || _noData || _data.length < 2) return const SizedBox.shrink();
final groupes = <BarChartGroupData>[];
var maxKwh = 0.5;
final orange = EtmTokens.selfUse;
final jaune = EtmTokens.amber;
// Le soutiré passe du rouge au BLEU (demande de Patrick, 2026-09-09) : avec l'orange
// partagé sur les deux barres, le rouge en faisait une image trop chaude, et le bleu
// est déjà la couleur de la maison partout ailleurs dans l'app.
final bleu = EtmTokens.blue;
final periodes = _periodes();
for (int i = 0; i < periodes.length; i++) {
final a = periodes[i].a, b = periodes[i].b;
if (!a.totalsPresent || !b.totalsPresent) continue;
double delta(double x, double y) => max(0.0, y - x);
final prod = delta(a.totalProductionKwh, b.totalProductionKwh);
final conso = delta(a.totalConsumptionKwh, b.totalConsumptionKwh);
final exp = delta(a.totalReturnKwh, b.totalReturnKwh);
// Le soutiré n'est pas relu du compteur d'import : c'est `conso - autoconsommé`,
// par construction de la pile. Prendre `totalAcquisition` ferait une pile dont les
// segments ne rejoignent pas leur sommet dès qu'un compteur dérive — un trou dans la
// barre que personne ne saurait lire. L'identité des compteurs se vérifie ailleurs,
// sur le bilan du jour, où elle a une phrase pour se dire.
// Autoconsommé = ce qui a été produit et n'est pas parti. Borné à la production :
// un export supérieur à la production est une incohérence de compteurs, pas une
// autoconsommation négative.
final self = (prod - exp).clamp(0.0, prod);
maxKwh = [maxKwh, prod, conso].reduce(max);
groupes.add(BarChartGroupData(x: i, barsSpace: 2, barRods: [
BarChartRodData(
toY: prod,
width: _largeurBarre(),
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
rodStackItems: [
BarChartRodStackItem(0, self, orange),
BarChartRodStackItem(self, prod, jaune),
],
color: jaune,
),
BarChartRodData(
toY: conso,
width: _largeurBarre(),
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
rodStackItems: [
BarChartRodStackItem(0, self.clamp(0.0, conso), orange),
BarChartRodStackItem(self.clamp(0.0, conso), conso, bleu),
],
color: bleu,
),
]));
}
if (groupes.isEmpty) return const SizedBox.shrink();
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
return BarChart(
BarChartData(
// ⚠️ `barGroups` était OUBLIÉ : les groupes étaient calculés, `maxY` en découlait —
// donc l'axe montait bien à la bonne hauteur — et aucune barre n'était dessinée.
// Un graphe vide sous un axe juste, ce qui rend le défaut difficile à lire : tout
// ce qui entoure les données avait l'air correct.
barGroups: groupes,
maxY: maxKwh * 1.15,
alignment: BarChartAlignment.spaceAround,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.9),
getTooltipItem: (g, _, rod, __) => BarTooltipItem(
'${rod.toY.toStringAsFixed(2)} kWh',
EtmTokens.mono(size: 11, color: Colors.white),
),
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
// 54 px et pas de décimale au-dessus de 10 : « 80.0kWh » ne tenait pas dans
// 46 px et se coupait en « 80.0kW / h ». Une unité coupée en deux lignes se
// lit comme une autre unité.
reservedSize: 54,
getTitlesWidget: (v, meta) => v == meta.max
? const SizedBox.shrink()
: SideTitleWidget(
meta: meta,
child: Text(
'${v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1)} kWh',
style: labelStyle)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: _xIntervalPeriodes(),
getTitlesWidget: (v, meta) {
final p = _periodes();
final idx = v.round();
if (idx < 0 || idx >= p.length) return const SizedBox.shrink();
// Étiquetée par le DÉBUT de la période : « 6/9 » désigne la journée du 6,
// pas l'instant où on a arrêté de la compter.
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(p[idx].a.timestamp), style: labelStyle));
},
),
),
),
),
);
}
/// Des barres lisibles quel qu'en soit le nombre : 24 heures, 30 jours ou 12 mois ne
/// tiennent pas à la même largeur, et une barre de 1 px n'est plus une barre.
/// Espacement des étiquettes, compté en PÉRIODES et non en échantillons.
double _xIntervalPeriodes() {
final n = _periodes().length;
return n > 8 ? (n / 6).ceilToDouble() : 1.0;
}
double _largeurBarre() {
final n = _periodes().length;
if (n <= 14) return 9;
if (n <= 32) return 5;
return 3;
}
// ── ④ Répartition de la CONSOMMATION, en énergie ───────────────────────────
/// Où est passée la consommation de chaque période — une barre empilée par période.
///
/// Le pendant en énergie du graphe par consommateur : là-bas des puissances instantanées,
/// ici des **kWh accumulés**, obtenus par différence du compteur cumulé de chaque
/// appareil (`totalConsumption`), jamais par intégration d'une puissance.
///
/// Le **reste du foyer** ferme la barre à la consommation totale de la période. Il est
/// licite pour la même raison que dans la vue Heures : chaque part est un compteur dédié,
/// le total est le compteur du foyer, la soustraction ne porte que du mesuré. Et un reste
/// **négatif** n'est pas raboté — il prouverait qu'un sous-compteur n'est pas dans le
/// périmètre du total.
///
/// ⚠️ Une charge dont le compteur n'a pas bougé sur la période donne **0 kWh, qui est une
/// mesure**. Une charge dont le cumul manque à l'une des deux bornes n'a **rien** — pas un
/// zéro. Les deux se ressemblent à l'écran et ne veulent pas dire la même chose.
Widget _buildConsumerEnergyBars() {
if (_loading || _noData || _data.length < 2 || _conso.estVide) {
return const SizedBox.shrink();
}
final couleurs = _couleursSeries(context);
final groupes = <BarChartGroupData>[];
var maxKwh = 0.5;
final periodes = _periodes();
for (int i = 0; i < periodes.length; i++) {
final a = periodes[i].a, b = periodes[i].b;
if (!a.totalsPresent || !b.totalsPresent) continue;
final total =
max(0.0, b.totalConsumptionKwh - a.totalConsumptionKwh);
final parts = <(Color, double)>[];
var somme = 0.0;
for (final serie in _conso.series) {
final e = serie.energieEntre(a.timestamp, b.timestamp);
if (e == null) continue; // pas de cumul : pas de part, pas un zéro
somme += e;
parts.add((couleurs[serie.thingId] ?? EtmTokens.mutedOf(context), e));
}
final reste = total - somme;
final piles = <BarChartRodStackItem>[];
var bas = 0.0;
for (final (c, v) in parts) {
piles.add(BarChartRodStackItem(bas, bas + v, c));
bas += v;
}
piles.add(BarChartRodStackItem(bas, bas + reste, EtmTokens.brand));
maxKwh = max(maxKwh, max(total, bas + reste));
groupes.add(BarChartGroupData(x: i, barRods: [
BarChartRodData(
toY: bas + reste,
width: _largeurBarre() * 1.6,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
rodStackItems: piles,
color: EtmTokens.brand,
),
]));
}
if (groupes.isEmpty) return const SizedBox.shrink();
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
return BarChart(
BarChartData(
barGroups: groupes,
maxY: maxKwh * 1.15,
alignment: BarChartAlignment.spaceAround,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.9),
getTooltipItem: (g, _, rod, __) => BarTooltipItem(
'${rod.toY.toStringAsFixed(2)} kWh',
EtmTokens.mono(size: 11, color: Colors.white),
),
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 54,
getTitlesWidget: (v, meta) => v == meta.max
? const SizedBox.shrink()
: SideTitleWidget(
meta: meta,
child: Text(
'${v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1)} kWh',
style: labelStyle)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: _xIntervalPeriodes(),
getTitlesWidget: (v, meta) {
final p = _periodes();
final idx = v.round();
if (idx < 0 || idx >= p.length) return const SizedBox.shrink();
// Étiquetée par le DÉBUT de la période : « 6/9 » désigne la journée du 6,
// pas l'instant où on a arrêté de la compter.
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(p[idx].a.timestamp), style: labelStyle));
},
),
),
),
),
);
}
// ── ② Empilement par CONSOMMATEUR ──────────────────────────────────────────
/// Qui consomme, dans le temps — une aire par charge déclarée, plus le reste du foyer.
///
/// ## Le RESTE est licite ici, et il ne l'était pas sur le Sankey
///
/// `CLAUDE.md` interdit « pas de RESTE sans mesure exhaustive » — la règle est née d'un
/// Sankey qui calculait « Autres = 22 % » à partir de parts **inventées**. Ici chaque
/// part est un **compteur dédié** et le total est le compteur du foyer : la soustraction
/// ne porte que des grandeurs mesurées.
///
/// 🔴 **Et elle porte son propre contrôle** : si un sous-compteur n'est pas réellement à
/// l'intérieur du total du foyer — une borne sur une alimentation séparée, par exemple —
/// le reste devient **négatif**. On ne le rabote pas à zéro : un reste négatif est la
/// preuve d'un périmètre faux, et l'écraser effacerait la seule chose qui le dise.
/// « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » — exactement ça.
/// La couleur de chaque série, **rang dans son domaine compris**.
///
/// Calculée à un seul endroit pour que la légende et le tracé ne puissent pas diverger :
/// deux sources de couleur, c'est une légende qui finit par désigner la mauvaise bande.
Map<String, Color> _couleursSeries(BuildContext context) {
final rangs = <String, int>{};
final out = <String, Color>{};
for (final s in _conso.series) {
final base = EtmTokens.domaineOf(context, s.domaine);
if (base == null) {
out[s.thingId] = EtmTokens.mutedOf(context);
continue;
}
final cle = s.domaine ?? '';
final rang = rangs[cle] ?? 0;
rangs[cle] = rang + 1;
out[s.thingId] = EtmTokens.nuance(base, rang);
}
return out;
}
Widget _buildConsumerChart({bool axeGauche = true}) {
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
if (_conso.estVide) return const SizedBox.shrink();
final series = _conso.series;
final n = _data.length;
// ⚠️ **Alignement par INSTANT, pas par index** — et une série peut légitimement
// commencer au milieu du graphe.
//
// La V2C Trydan a reçu son comptage le 2026-09-08 à 10:57 (`powersync-plugin-v2c
// +etm14`) : sur une fenêtre de 24 h elle rend **1 point contre 45** aux autres. Le
// filtre précédent (« même longueur, sinon on ignore ») l'aurait écartée du tracé
// **en la laissant dans la légende** — une légende qui promet une bande qu'on ne
// dessine pas, le défaut même qu'on venait de corriger sur l'autre graphe.
//
// Une série ne contribue donc que **là où elle a des points**. Avant son premier
// point, elle n'apporte rien à la pile — et l'énergie qu'elle a consommée est
// comptée dans le reste du foyer, ce qui est **arithmétiquement juste** : le total
// reste le compteur du foyer, seule la RÉPARTITION est incomplète. C'est ce que dit
// la note, et c'est une phrase différente de « elle a consommé zéro ».
final index = {
for (final s in series)
s.thingId: {
for (final p in s.points) p.t.millisecondsSinceEpoch: p.powerW
}
};
double? valeurAt(ConsumerSeries s, int i) =>
index[s.thingId]?[_data[i].timestamp.millisecondsSinceEpoch];
// Cumuls empilés, base = reste du foyer.
final restes = <double>[];
for (int i = 0; i < n; i++) {
final somme =
series.fold<double>(0, (a, s) => a + (valeurAt(s, i) ?? 0));
restes.add(_data[i].consumptionW.abs() - somme);
}
final resteNegatif = restes.any((v) => v < -1);
final alignees = series;
final couleurs = _couleursSeries(context);
final bandes = <({String label, Color couleur, List<double> valeurs})>[
(
label: 'Reste du foyer',
couleur: EtmTokens.brand,
valeurs: restes
),
for (final s in alignees)
(
label: s.label,
couleur: couleurs[s.thingId] ?? EtmTokens.mutedOf(context),
valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i) ?? 0.0]
),
];
// Cumul croissant, puis tracé du HAUT vers le BAS : chaque aire remplie jusqu'à zéro,
// la suivante repeint par-dessus. C'est ce qui donne l'empilement sans `betweenBars`.
final cumuls = <List<double>>[];
for (int b = 0; b < bandes.length; b++) {
final prec = b == 0 ? List<double>.filled(n, 0.0) : cumuls[b - 1];
cumuls.add([for (int i = 0; i < n; i++) prec[i] + bandes[b].valeurs[i]]);
}
// Même règle que le graphe de flux : l'échelle suit la fenêtre, sinon les deux
// graphes ne se comparent plus — l'un cadré sur ce qu'on voit, l'autre sur tout.
final iDebut = _fenetreDebut.floor().clamp(0, n - 1);
final iFin = _fenetreFin.ceil().clamp(0, n - 1);
final maxKw = cumuls.isEmpty
? 1.0
: cumuls.last
.sublist(iDebut, iFin + 1)
.map((v) => v / 1000)
.fold<double>(0.5, max);
final yMax = max(maxKw * 1.15, 0.5);
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
return Stack(children: [
LineChart(
LineChartData(
clipData: const FlClipData.all(),
minX: _fenetreDebut,
maxX: _fenetreFin,
minY: resteNegatif ? -yMax * 0.25 : 0,
maxY: yMax,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yMax / 4,
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: axeGauche,
reservedSize: axeGauche ? 46 : 0,
interval: yMax / 4,
getTitlesWidget: (v, meta) => v == meta.max
? const SizedBox.shrink()
: SideTitleWidget(
meta: meta,
child: Text('${v.toStringAsFixed(1)}kW', style: labelStyle)),
),
),
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: [
for (int b = bandes.length - 1; b >= 0; b--)
LineChartBarData(
spots: [
for (int i = 0; i < n; i++)
FlSpot(i.toDouble(), cumuls[b][i] / 1000)
],
isCurved: false,
color: bandes[b].couleur,
barWidth: 1.2,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: bandes[b].couleur.withValues(alpha: 0.55)),
),
],
lineTouchData: const LineTouchData(enabled: false),
),
),
]);
}
/// Ce que le graphe des consommateurs **ne peut pas** montrer, nommé sous lui.
///
/// Une charge absente de l'historique ne se dessine pas à zéro : une ligne plate
/// affirmerait « elle n'a rien tiré », quand la vérité est « elle n'a pas de passé ».
/// Vu sur `.75` : la V2C Trydan ne déclare pas d'interface `smartmeter`, donc elle
/// n'entre pas dans `GetThingPowerLogs` — alors que sa puissance instantanée est lisible.
Widget _notesConsommateurs() {
final manquants = _conso.sansHistorique;
// **Une série qui COMMENCE au milieu du graphe.**
//
// Cas rencontré le 2026-09-08 : la V2C a reçu son comptage à 10:57, donc sur 24 h
// elle rend un point sur quarante-cinq. Le total de la pile reste juste — le foyer
// est mesuré de bout en bout — mais la RÉPARTITION ne vaut qu'à partir de là. Le
// taire ferait lire « cette charge n'a rien tiré avant », qui est le mensonge d'hier
// sous une autre forme : ce n'est plus la présence de la série qui est en jeu, c'est
// son début.
final debut = _data.isEmpty ? null : _data.first.timestamp;
final tardives = <(String, DateTime)>[
for (final s in _conso.series)
if (s.points.isNotEmpty &&
debut != null &&
s.points.first.t.isAfter(debut.add(const Duration(minutes: 2))))
(s.label, s.points.first.t)
];
if (manquants.isEmpty && tardives.isEmpty && !_conso.signeIndetermine) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (manquants.isNotEmpty)
_NoteEcran(
icon: Icons.timeline_rounded,
texte: manquants.length == 1
? '${manquants.first} : pas d\'historique de puissance — cet appareil ne '
'publie pas de comptage. Sa puissance instantanée reste lisible.'
: '${manquants.join(', ')} : pas d\'historique de puissance.',
),
for (final (label, t) in tardives)
_NoteEcran(
icon: Icons.start_rounded,
texte: '$label n\'est mesurée que depuis '
'${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')} : avant, sa consommation est '
'comptée dans le reste du foyer. Le total est juste, la répartition ne '
'l\'est qu\'à partir de cette heure.',
),
if (_conso.signeIndetermine)
_NoteEcran(
icon: Icons.help_outline_rounded,
texte: 'Tous les sous-compteurs lisent 0 W sur la période. C\'est une mesure, '
'pas un trou — mais tant qu\'aucune valeur non nulle n\'est vue, la '
'convention de signe de ces compteurs reste invérifiée.',
),
]),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// L'espacement des étiquettes de l'axe du bas, calculé sur ce qui est **visible**.
///
/// Le calculer sur le jeu entier donnerait six étiquettes réparties sur 24 h alors que la
/// fenêtre n'en montre que douze : on en verrait trois, mal placées. L'axe doit suivre le
/// cadrage, pas les données.
double _xInterval() {
final visibles = (_fenetreFin - _fenetreDebut).round();
final n = visibles > 0 ? visibles : _data.length;
return n > 8 ? (n / 6).ceilToDouble() : 1.0;
}
/// 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 _fmtTime(DateTime t) => _tabs[_tabIdx].showTime
? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'
: '${t.day}/${t.month}';
}
// ─────────────────────────── KPI section ───────────────────────────────────────
/// **Les six tuiles du haut — la JOURNÉE, et rien d'autre.**
///
/// Elles lisaient `service.energyData` : `dayProductionWh` y porte en réalité le compteur
/// **cumulé depuis l'origine** de l'installation. Vu à l'écran le 2026-09-08 :
/// « Production 62946.74… » au-dessus d'un graphe qui montre 24 heures. Et « Gains
/// 8789.84 € » n'avait aucune source du tout — un prix du kWh que personne n'a saisi.
///
/// Elles lisent maintenant [DailyBalance], le même objet que le tableau de bord : une
/// différence de compteurs sur la fenêtre `periodStartedAt → maintenant`, publiée par la
/// box et non dérivée d'un fuseau.
///
/// ## Chaque taux porte son dénominateur
///
/// Décidé après le 2026-09-08 : « 70 % » se lisait comme une bonne matinée alors que
/// l'installation avait produit **79 Wh** et acheté 6,4 kWh. Le nombre était exact ; la
/// question à laquelle il répond n'intéressait personne.
///
/// Un seuil aurait demandé une constante inventée — et celle que j'avais proposée
/// (0,05 kWh) aurait laissé passer ces 79 Wh. « 70 % sur 79 Wh produits » se lit juste
/// toute l'année ; « 70 % » ne se lit juste que les bons jours. **Un ratio sans son
/// dénominateur est une part sans son tout.**
class _KpiSection extends StatelessWidget {
/// `null` = pas encore lu, ou journée non mesurable. Les deux se disent « — ».
final DailyBalance? bilan;
const _KpiSection({required this.bilan});
@override
Widget build(BuildContext context) {
final b = bilan;
String kwh(double? v) => v == null ? '—' : '${v.toStringAsFixed(2)} kWh';
String pct(double? v) => v == null ? '—' : '${v.round()} %';
// « sur X produits » n'a de sens que s'il y a un X. Sans dénominateur, la tuile dit
// pourquoi elle n'a pas de taux — pas un zéro, pas un tiret muet.
final surProduction = b == null
? null
: b.productionKwh > 0
? 'sur ${_court(b.productionKwh)} produits'
: 'sans production';
final surConsommation = b == null
? null
: b.consumptionKwh > 0
? 'sur ${_court(b.consumptionKwh)} consommés'
: 'sans consommation';
return Column(children: [
Row(children: [
_KpiTile(
icon: Icons.wb_sunny_rounded,
color: EtmTokens.amber,
bgColor: EtmTokens.amberSoft,
label: 'Production',
value: kwh(b?.productionKwh),
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.home_rounded,
color: EtmTokens.blue,
bgColor: EtmTokens.blueSoft,
label: 'Consommation',
value: kwh(b?.consumptionKwh),
),
]),
const SizedBox(height: 14),
Row(children: [
_KpiTile(
icon: Icons.north_west_rounded,
color: EtmTokens.green,
bgColor: EtmTokens.greenSoft,
label: 'Vers réseau',
value: kwh(b?.exportKwh),
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.south_east_rounded,
color: EtmTokens.importColor,
bgColor: EtmTokens.amberSoft,
label: 'Depuis réseau',
value: kwh(b?.importKwh),
),
]),
const SizedBox(height: 14),
Row(children: [
_KpiTile(
icon: Icons.recycling_rounded,
color: EtmTokens.selfUse,
bgColor: EtmTokens.amberSoft,
label: 'Autoconsommation',
value: pct(b?.selfConsumptionRate),
detail: surProduction,
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.bolt_rounded,
color: EtmTokens.green,
bgColor: EtmTokens.greenSoft,
label: 'Autonomie',
value: pct(b?.autonomyRate),
detail: surConsommation,
),
]),
// L'identité des compteurs est le seul contrôle disponible sur cette fenêtre.
// Fausse, elle désigne un compteur cassé ou remis à zéro — et le bilan ne doit
// alors pas être présenté comme une mesure.
if (b != null && !b.identityHolds)
const _NoteEcran(
icon: Icons.rule_rounded,
texte: 'Les compteurs ne se recoupent pas sur la journée : production + soutiré '
'devrait égaler consommation + injecté. Un compteur a probablement été remis '
'à zéro.',
),
if (b != null && b.startsLate)
_NoteEcran(
icon: Icons.schedule_rounded,
texte: 'Journée partielle : la box ne publie rien avant '
'${b.from.hour.toString().padLeft(2, '0')}:'
'${b.from.minute.toString().padLeft(2, '0')}. Le bilan part de cette heure, '
'pas de minuit.',
),
]);
}
/// Une énergie courte : les watt-heures sous le kilowattheure, parce que « 0,08 kWh »
/// se lit comme un arrondi et « 79 Wh » comme une mesure.
static String _court(double kwh) =>
kwh < 1 ? '${(kwh * 1000).round()} Wh' : '${kwh.toStringAsFixed(1)} kWh';
}
class _KpiTile extends StatelessWidget {
final IconData icon;
final Color color;
final Color bgColor;
final String label;
final String value;
/// Ce dont la valeur est la fraction — `null` quand elle n'est fraction de rien.
final String? detail;
const _KpiTile({
required this.icon,
required this.color,
required this.bgColor,
this.detail,
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: [
// `scaleDown` dans une largeur BORNÉE (on est dans un `Expanded`) :
// il réduit seulement quand il le faut. Sans lui, « Autoconsommation »
// se coupait au milieu du mot — vu à l'écran le 2026-09-08.
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(label,
maxLines: 1,
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),
if (detail != null) ...[
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(detail!,
maxLines: 1,
style: EtmTokens.sans(
size: 10, color: EtmTokens.mutedOf(context))),
),
],
],
),
),
],
),
),
);
}
}
// ─────────────────────────── 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 ─────────────────────────────────────────
// ─────────────────────────── 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;
/// Ce qui s'écrit **entre la légende et le tracé** — la plage visible, par exemple.
///
/// Sa place n'est pas cosmétique : la date qualifie ce qu'on regarde, elle doit donc être
/// lue AVANT le graphe, pas trouvée après lui. Posée sous les deux cartes, elle arrivait
/// une fois la lecture faite — et sur trois jours, une courbe lue sans sa date est une
/// courbe lue de travers.
final Widget? sousLegende;
const _EtmChartCard({
required this.title,
required this.subtitle,
required this.legend,
required this.height,
required this.loading,
required this.noData,
required this.child,
this.sousLegende,
});
@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),
if (sousLegende != null) ...[
const SizedBox(height: 8),
sousLegende!,
],
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))),
],
),
),
],
),
),
],
),
);
}
}