From d6240984ee8c8de91c47bb9b11f7b7e51a5e744a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 10:44:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C3=A9nergie):=20naviguer=20de=20p=C3=A9ri?= =?UTF-8?q?ode=20en=20p=C3=A9riode,=20et=20un=20sous-titre=20en=20moins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/screens/energy_screen.dart | 194 ++++++++++++++++++++++++--------- 1 file changed, 141 insertions(+), 53 deletions(-) diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 1aca96f..f009b76 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -19,6 +19,79 @@ import '../main.dart' show DrawerMenuButton; // 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; @@ -332,6 +405,49 @@ class _EnergyScreenState extends State { ? 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 _pickDate() async { final picked = await showDatePicker( context: context, @@ -411,17 +527,25 @@ class _EnergyScreenState extends State { }, ), - // Chip date sélectionnée - if (_selectedDate != null) ...[ - const SizedBox(height: 10), - _DateChip( - date: _selectedDate!, - onClear: () { - setState(() => _selectedDate = null); - _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 ────────────── @@ -433,8 +557,7 @@ class _EnergyScreenState extends State { 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é', + subtitle: 'kWh par période', legend: [ _LegendDot(EtmTokens.selfUse, 'Autoconsommé'), _LegendDot(EtmTokens.amber, 'Exporté'), @@ -945,7 +1068,11 @@ class _EnergyScreenState extends State { 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); + // 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. @@ -1711,45 +1838,6 @@ class _PeriodSelector extends StatelessWidget { // ─────────────────────────── 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 {