feat(énergie): infobulles avec l'heure sur tous les graphes

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fNqASdws99s94UqQqBYoB
This commit is contained in:
Patrick Schurig 2026-10-17 08:48:15 +02:00
parent 4da904fc7a
commit 09556039e4
4 changed files with 279 additions and 24 deletions

View File

@ -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<EnergyScreen> {
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<EnergyScreen> {
],
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<EnergyScreen> {
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<EnergyScreen> {
final couleurs = _couleursSeries(context);
final groupes = <BarChartGroupData>[];
var maxKwh = 0.5;
final detail = <int, List<(String, Color, double)>>{};
final periodes = _periodes();
for (int i = 0; i < periodes.length; i++) {
@ -1416,13 +1448,17 @@ class _EnergyScreenState extends State<EnergyScreen> {
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 = <BarChartRodStackItem>[];
var bas = 0.0;
@ -1460,11 +1496,15 @@ class _EnergyScreenState extends State<EnergyScreen> {
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<EnergyScreen> {
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<EnergyScreen> {
/// 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}';

View File

@ -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<LineTooltipItem?> itemsInfobulle(String? entete, List<LigneInfobulle?> 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<LigneInfobulle> 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)),
],
],
);

View File

@ -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';
}

View File

@ -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');
});
}