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 // ───────────────────────────────────────────────────────────────────────────── /// 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 createState() => _EnergyScreenState(); } class _EnergyScreenState extends State { /// 🔴 **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 _data = []; List _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(); // 🔴 **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 _fetchSocOnly() async { if (!mounted) return; final tab = _tabs[_tabIdx]; final to = _anchorDate(); final service = context.read(); 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 _fetch() async { if (!mounted) return; setState(() { _loading = true; _noData = false; }); final tab = _tabs[_tabIdx]; final (from, to) = _fenetreRequete(tab); final service = context.read(); 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(); 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([ 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>.value([]), service.fetchThingPowerLogs( appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate), service.fetchDailyBalance(), ]); if (!mounted) return; setState(() { _data = results[0] as List; _socData = results[1] as List; _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(); Future _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( 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(); } }, ), // Chip date sélectionnée if (_selectedDate != null) ...[ const SizedBox(height: 10), _DateChip( date: _selectedDate!, onClear: () { 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 — le bloc orange est partagé : ' 'ce qui a été produit ET gardé', 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 = []; final consoSpots = []; final autoSpots = []; final socSpots = []; 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 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 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 = {}; 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 = []; 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); final imp = delta(a.totalAcquisitionKwh, b.totalAcquisitionKwh); // 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 = []; 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 = []; 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 _couleursSeries(BuildContext context) { final rangs = {}; final out = {}; 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 = []; for (int i = 0; i < n; i++) { final somme = series.fold(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 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 = >[]; for (int b = 0; b < bandes.length; b++) { final prec = b == 0 ? List.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(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 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 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))), ], ), ), ], ), ), ], ), ); } }