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:
parent
4da904fc7a
commit
09556039e4
@ -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}';
|
||||
|
||||
65
lib/widgets/chart_tooltip.dart
Normal file
65
lib/widgets/chart_tooltip.dart
Normal 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)),
|
||||
],
|
||||
],
|
||||
);
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
36
test/chart_tooltip_test.dart
Normal file
36
test/chart_tooltip_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user