From 09556039e40c98d41a6c1c199c5b866ed8fb84b9 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 08:48:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C3=A9nergie):=20infobulles=20avec=20l'heu?= =?UTF-8?q?re=20sur=20tous=20les=20graphes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puissances, Qui consomme et Prévision PV affichent au toucher l'heure puis une ligne par série (nommée comme la légende) ; Qui consomme donne la part de chaque charge, pas le cumul empilé. Les barres Jour / Semaine / Mois / Année donnent leur période et le détail autoconsommé / exporté / soutiré. Les bandes invisibles de la fourchette se taisent ; -0,00 kW se lit 0,00. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fNqASdws99s94UqQqBYoB --- lib/screens/energy_screen.dart | 146 +++++++++++++++++++++++++----- lib/widgets/chart_tooltip.dart | 65 +++++++++++++ lib/widgets/pv_forecast_card.dart | 56 +++++++++++- test/chart_tooltip_test.dart | 36 ++++++++ 4 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 lib/widgets/chart_tooltip.dart create mode 100644 test/chart_tooltip_test.dart diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index e7265fb..a0da2d1 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -11,6 +11,7 @@ import '../providers/load_config_provider.dart'; import '../services/energy_ratios.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; +import '../widgets/chart_tooltip.dart'; import '../widgets/pv_forecast_card.dart'; import '../models/axe_echantillons.dart'; import '../models/pv_forecast.dart'; @@ -845,6 +846,7 @@ class _EnergyScreenState extends State { minX: _fenetreDebut, maxX: _fenetreFin, etiquette: (v) => _fmtTime(_tDe(v.roundToDouble())), + infobulle: (v) => _fmtInfobulle(_tDe(v.roundToDouble())), xInterval: _xInterval(), xMaintenant: _selectedDate == null ? _xDe(DateTime.now()) : null, bande: bande, @@ -1106,17 +1108,26 @@ class _EnergyScreenState extends State { ], 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(), + getTooltipColor: fondInfobulle, + maxContentWidth: 260, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipItems: (spots) => itemsInfobulle( + spots.isEmpty ? null : _fmtInfobulle(_tDe(spots.first.x.roundToDouble())), + [ + for (final s in spots) + switch (s.barIndex) { + 0 => (couleur: EtmTokens.exportSolaire, nom: 'Production', valeur: _kw(s.y)), + 1 => (couleur: EtmTokens.selfUse, nom: 'Autoconsommé', valeur: _kw(s.y)), + 2 => (couleur: EtmTokens.blue, nom: 'Consommation', valeur: _kw(s.y)), + _ => ( + couleur: EtmTokens.soc, + nom: 'Batterie', + valeur: '${(s.y / yMax * 100).toStringAsFixed(0)} %' + ), + } + ], + ), ), ), ), @@ -1313,11 +1324,31 @@ class _EnergyScreenState extends State { 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), - ), + getTooltipColor: fondInfobulle, + maxContentWidth: 260, + fitInsideHorizontally: true, + fitInsideVertically: true, + // La barre touchée, détaillée : production = autoconsommé + exporté, + // consommation = autoconsommé + soutiré. + getTooltipItem: (g, _, rod, rodIndex) { + final pile = rod.rodStackItems; + final auto = pile.isEmpty ? 0.0 : pile.first.toY - pile.first.fromY; + final reste = pile.length < 2 ? 0.0 : pile[1].toY - pile[1].fromY; + final prod = rodIndex == 0; + return itemBarre(_enteteBarre(g.x), [ + ( + couleur: prod ? EtmTokens.exportSolaire : EtmTokens.blue, + nom: prod ? 'Production' : 'Consommation', + valeur: _kwh(rod.toY) + ), + (couleur: EtmTokens.selfUse, nom: 'dont autoconsommé', valeur: _kwh(auto)), + ( + couleur: prod ? EtmTokens.exportSolaire : EtmTokens.blue, + nom: prod ? 'dont injecté' : 'dont soutiré', + valeur: _kwh(reste) + ), + ]); + }, ), ), titlesData: FlTitlesData( @@ -1406,6 +1437,7 @@ class _EnergyScreenState extends State { final couleurs = _couleursSeries(context); final groupes = []; var maxKwh = 0.5; + final detail = >{}; final periodes = _periodes(); for (int i = 0; i < periodes.length; i++) { @@ -1416,13 +1448,17 @@ class _EnergyScreenState extends State { final parts = <(Color, double)>[]; var somme = 0.0; + final lignes = <(String, Color, double)>[]; 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 c = couleurs[serie.thingId] ?? EtmTokens.mutedOf(context); + parts.add((c, e)); + lignes.add((serie.label, c, e)); } final reste = total - somme; + detail[i] = [...lignes, ('Reste du foyer', EtmTokens.brand, reste)]; final piles = []; var bas = 0.0; @@ -1460,11 +1496,15 @@ class _EnergyScreenState extends State { 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), - ), + getTooltipColor: fondInfobulle, + maxContentWidth: 260, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipItem: (g, _, rod, _) => itemBarre(_enteteBarre(g.x), [ + (couleur: EtmTokens.blue, nom: 'Consommation', valeur: _kwh(rod.toY)), + for (final (nom, couleur, v) in detail[g.x] ?? const <(String, Color, double)>[]) + if (v.abs() >= 0.005) (couleur: couleur, nom: nom, valeur: _kwh(v)), + ]), ), ), titlesData: FlTitlesData( @@ -1690,7 +1730,33 @@ class _EnergyScreenState extends State { color: bandes[b].couleur.withValues(alpha: 0.55)), ), ], - lineTouchData: const LineTouchData(enabled: false), + // L'infobulle donne la part de CHAQUE bande, pas son cumul : le tracé empile, + // mais « PAC 2,3 kW » doit dire ce que tire la PAC, pas le haut de sa bande. + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: fondInfobulle, + maxContentWidth: 260, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipItems: (spots) { + if (spots.isEmpty) return const []; + final i = spots.first.x.round().clamp(0, n - 1); + return itemsInfobulle(_fmtInfobulle(_data[i].timestamp), [ + for (final s in spots) + () { + // Tracées du haut vers le bas : la barre k est la bande L−1−k. + final b = bandes[bandes.length - 1 - s.barIndex]; + final v = b.valeurs[i] / 1000; + // Une charge à l'arrêt n'encombre pas la bulle ; le reste du foyer, + // même négatif, se montre toujours — c'est lui qui trahit un + // périmètre faux. + if (v.abs() < 0.005 && b.label != 'Reste du foyer') return null; + return (couleur: b.couleur, nom: b.label, valeur: _kw(v)); + }(), + ]); + }, + ), + ), ), ), ]); @@ -1713,6 +1779,40 @@ class _EnergyScreenState extends State { /// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une /// journée entière. + // Sous le centième, « -0,00 » : quelques watts négatifs la nuit (l'onduleur) se lisent 0. + String _kw(double kw) => + '${(kw.abs() < 0.005 ? 0.0 : kw).toStringAsFixed(2).replaceAll('.', ',')} kW'; + String _kwh(double kwh) => '${kwh.toStringAsFixed(2).replaceAll('.', ',')} kWh'; + + /// L'en-tête d'une bulle de courbe : l'heure, précédée du jour s'il n'est pas aujourd'hui + /// — la vue Heures couvre trois jours, et « 14:30 » seul y désigne trois instants. + String _fmtInfobulle(DateTime t) { + final l = t.toLocal(); + final h = '${l.hour.toString().padLeft(2, '0')}:${l.minute.toString().padLeft(2, '0')}'; + final n = DateTime.now(); + return l.year == n.year && l.month == n.month && l.day == n.day + ? h + : '${l.day}/${l.month} $h'; + } + + /// L'en-tête d'une bulle de barre : la période, désignée par son DÉBUT comme l'axe. + String? _enteteBarre(int idx) { + final p = _periodes(); + if (idx < 0 || idx >= p.length) return null; + final a = p[idx].a.timestamp.toLocal(), b = p[idx].b.timestamp.toLocal(); + String hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + final tab = _tabs[_tabIdx]; + if (tab.showTime) return '${hm(a)} → ${hm(b)}'; + if (tab.sampleRate == 'SampleRate1Month') { + const mois = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', + 'sept.', 'oct.', 'nov.', 'déc.']; + return '${mois[a.month - 1]} ${a.year}'; + } + const jours = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.']; + return '${jours[a.weekday - 1]} ${a.day}/${a.month}'; + } + String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime ? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}' : '${t.day}/${t.month}'; diff --git a/lib/widgets/chart_tooltip.dart b/lib/widgets/chart_tooltip.dart new file mode 100644 index 0000000..1c247e2 --- /dev/null +++ b/lib/widgets/chart_tooltip.dart @@ -0,0 +1,65 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../theme/etm_tokens.dart'; + +/// Une ligne d'infobulle : la pastille de la série, son nom, sa valeur. +typedef LigneInfobulle = ({Color couleur, String nom, String valeur}); + +/// Fond commun des infobulles de graphe. +Color fondInfobulle(_) => EtmTokens.navy.withValues(alpha: 0.9); + +/// Les infobulles d'un graphe en lignes, **alignées sur `lignes`** (une par série touchée, +/// `null` = série tue — une bande invisible, une valeur nulle). +/// +/// L'[entete] (l'heure, le jour) ne s'écrit qu'une fois, en tête de la première ligne +/// affichée : fl_chart empile les items dans une seule bulle, et répéter l'heure à chaque +/// série la ferait lire comme quatre instants différents. +List itemsInfobulle(String? entete, List lignes) { + final premiere = lignes.indexWhere((l) => l != null); + return [ + for (final (i, l) in lignes.indexed) + if (l == null) + null + else + LineTooltipItem( + '', + EtmTokens.mono(size: 11, color: Colors.white), + textAlign: TextAlign.left, + children: [ + if (i == premiere && entete != null) + TextSpan( + text: '$entete\n', + style: EtmTokens.sans( + size: 11, weight: FontWeight.w600, color: Colors.white)), + TextSpan( + text: '● ', style: EtmTokens.mono(size: 11, color: l.couleur)), + TextSpan( + text: '${l.nom} ${l.valeur}', + style: EtmTokens.mono(size: 11, color: Colors.white)), + ], + ), + ]; +} + +/// Une bulle de barre : l'en-tête puis une ligne par part. +BarTooltipItem itemBarre(String? entete, List lignes) => + BarTooltipItem( + '', + EtmTokens.mono(size: 11, color: Colors.white), + textAlign: TextAlign.left, + children: [ + if (entete != null) + TextSpan( + text: entete, + style: EtmTokens.sans( + size: 11, weight: FontWeight.w600, color: Colors.white)), + for (final l in lignes) ...[ + const TextSpan(text: '\n'), + TextSpan(text: '● ', style: EtmTokens.mono(size: 11, color: l.couleur)), + TextSpan( + text: '${l.nom} ${l.valeur}', + style: EtmTokens.mono(size: 11, color: Colors.white)), + ], + ], + ); diff --git a/lib/widgets/pv_forecast_card.dart b/lib/widgets/pv_forecast_card.dart index 73e4e8c..7cd9485 100644 --- a/lib/widgets/pv_forecast_card.dart +++ b/lib/widgets/pv_forecast_card.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../models/pv_forecast.dart'; import '../theme/etm_tokens.dart'; +import 'chart_tooltip.dart'; /// Rendu pur de la prévision — tout ce qu'il affiche lui est donné. class PvForecastView extends StatelessWidget { @@ -353,6 +354,9 @@ class PvForecastLineChart extends StatelessWidget { /// Abscisse de « maintenant », pour le trait vertical. `null` = hors cadre. final double? xMaintenant; + /// Abscisse → en-tête de l'infobulle (l'heure touchée). `null` = [etiquette]. + final String? Function(double x)? infobulle; + /// Écart d'abscisse au-delà duquel deux mesures consécutives ne se relient pas. final double trouMesure; @@ -369,6 +373,7 @@ class PvForecastLineChart extends StatelessWidget { required this.etiquette, required this.xInterval, this.xMaintenant, + this.infobulle, this.trouMesure = 2.1, this.bande, }); @@ -515,7 +520,40 @@ class PvForecastLineChart extends StatelessWidget { ), ), ), - lineTouchData: const LineTouchData(enabled: false), + // Les bandes (deux lignes invisibles par segment) se taisent : la bulle ne parle + // que de la mesure et de la prévision. + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: fondInfobulle, + maxContentWidth: 260, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipItems: (spots) { + if (spots.isEmpty) return const []; + final nBandes = 2 * bandes.length; + final iMesure = mes.isNotEmpty ? nBandes : -1; + // L'abscisse de référence est celle de la MESURE quand il y en a une : la + // prévision est en marches d'une heure, et son point le plus proche peut + // appartenir au créneau voisin. + final ref = spots + .firstWhere((s) => s.barIndex == iMesure, orElse: () => spots.first) + .x; + final enTete = (infobulle ?? etiquette)(ref); + return itemsInfobulle(enTete, [ + for (final s in spots) + if (s.barIndex < nBandes) + null + else if (s.barIndex == iMesure) + (couleur: mesure_, nom: 'Mesuré', valeur: _kw(s.y)) + else + switch (_prevuEn(ref)) { + final kw? => (couleur: solaire, nom: 'Prévu', valeur: _kw(kw)), + null => null, + }, + ]); + }, + ), + ), betweenBarsData: [ for (var i = 0; i < bandes.length; i++) BetweenBarsData( @@ -550,5 +588,21 @@ class PvForecastLineChart extends StatelessWidget { ), ); } + + /// La prévision du créneau qui CONTIENT l'abscisse, en puissance moyenne — `null` hors + /// créneau (un trou reste un trou, même dans une bulle). + double? _prevuEn(double ref) { + final pas = snapshot.pas; + if (pas == null) return null; + final h = pas.inSeconds / 3600.0; + for (final p in snapshot.points) { + if (x(p.debutUtc) <= ref && ref < x(p.debutUtc.add(pas))) return p.wh / h / 1000; + } + return null; + } + + // Sous le centième, « -0,00 » : quelques watts négatifs la nuit (l'onduleur) se lisent 0. + static String _kw(double kw) => + '${(kw.abs() < 0.005 ? 0.0 : kw).toStringAsFixed(2).replaceAll('.', ',')} kW'; } diff --git a/test/chart_tooltip_test.dart b/test/chart_tooltip_test.dart new file mode 100644 index 0000000..c4fc55f --- /dev/null +++ b/test/chart_tooltip_test.dart @@ -0,0 +1,36 @@ +/// Infobulles des graphes (Patrick, 2026-10-17 : « il manque l'heure »). +library; + +import 'package:etm_powersync_app/widgets/chart_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +String _texte(InlineSpan? s) => s is TextSpan + ? '${s.text ?? ''}${(s.children ?? []).map(_texte).join()}' + : ''; + +void main() { + testWidgets('l\'heure s\'écrit UNE fois, sur la première ligne affichée — pas sur une tue', (t) async { + final items = itemsInfobulle('14:30', [ + null, // bande invisible + (couleur: Colors.amber, nom: 'Mesuré', valeur: '1,20 kW'), + (couleur: Colors.yellow, nom: 'Prévu', valeur: '1,50 kW'), + ]); + // Alignés sur les séries touchées : fl_chart l'exige. + expect(items, hasLength(3)); + expect(items[0], isNull); + final t1 = (items[1]!.children ?? []).map(_texte).join(); + final t2 = (items[2]!.children ?? []).map(_texte).join(); + expect(t1, startsWith('14:30\n')); + expect(t1, contains('Mesuré 1,20 kW')); + expect(t2, isNot(contains('14:30'))); + }); + + testWidgets('une bulle de barre porte sa période en tête', (t) async { + final b = itemBarre('ven. 17/10', [ + (couleur: Colors.blue, nom: 'Consommation', valeur: '12,40 kWh'), + ]); + final t = (b.children ?? []).map(_texte).join(); + expect(t, 'ven. 17/10\n● Consommation 12,40 kWh'); + }); +}