diff --git a/lib/features/dashboard/widgets/energy_flow_card.dart b/lib/features/dashboard/widgets/energy_flow_card.dart index d14b738..afd1766 100644 --- a/lib/features/dashboard/widgets/energy_flow_card.dart +++ b/lib/features/dashboard/widgets/energy_flow_card.dart @@ -16,8 +16,23 @@ class EnergyFlowCard extends StatefulWidget { /// valeur par défaut que rien ne remplaçait, et le Sankey dessinait donc en permanence /// un flux vers une voiture qui n'était pas branchée. final double? evChargerPowerW; - const EnergyFlowCard( - {super.key, required this.data, this.evChargerPowerW}); + + /// **Puissance MESURÉE par domaine de charge** — `'ecs'`, `'heating'` — issue de + /// `GetLoadTelemetry.measurement`. Clé absente ou valeur `null` = **non publié**. + /// + /// ⚠️ Ce paramètre remplace trois ratios en dur (38 %, 18 %, 22 % de la consommation + /// maison) qui étaient posés à la maquette et jamais remplacés. Le 2026-09-06, avec de + /// vrais compteurs ECS et PAC **à 0 W**, l'écran affichait toujours 38 % et 18 % de la + /// maison : des chiffres crédibles, marqués « estimé », et faux — exactement ce que + /// `source: "none"` existe pour empêcher, à l'envers. + final Map mesuresParDomaine; + + const EnergyFlowCard({ + super.key, + required this.data, + this.evChargerPowerW, + this.mesuresParDomaine = const {}, + }); @override State createState() => _EnergyFlowCardState(); @@ -125,7 +140,8 @@ class _EnergyFlowCardState extends State builder: (context, constraints) { final size = constraints.maxWidth; final segments = - _buildSegments(widget.data, _view, widget.evChargerPowerW); + _buildSegments(widget.data, _view, widget.evChargerPowerW, + widget.mesuresParDomaine); return AnimatedBuilder( animation: _particleCtrl, builder: (context, _) { @@ -154,6 +170,7 @@ class _EnergyFlowCardState extends State view: _view, data: widget.data, evChargerPowerW: widget.evChargerPowerW, + mesuresParDomaine: widget.mesuresParDomaine, pulseAnim: disableAnim ? null : _pulseAnim, onTapSolar: () => _switchView(_DrillView.prod), onTapMaison: () => _switchView(_DrillView.cons), @@ -176,8 +193,8 @@ class _EnergyFlowCardState extends State Padding( padding: const EdgeInsets.only(top: 8), child: Text( - 'Répartition estimée : PAC, eau chaude et autres usages sont des parts ' - 'types, pas des mesures. Seule la voiture est mesurée à la borne.', + 'Chaque branche est une mesure de la charge, ou « non publié ». ' + 'Le total de la maison peut donc dépasser la somme affichée.', style: EtmTokens.sans(size: 11, color: muted), textAlign: TextAlign.center, ), @@ -219,8 +236,8 @@ class _EnergyFlowCardState extends State static const double _nE = 0.22; static const double _hE = 0.39; - static List _buildSegments( - EnergyData d, _DrillView view, double? evW) { + static List _buildSegments(EnergyData d, _DrillView view, + double? evW, Map mesures) { const nE = _nE; const hE = _hE; @@ -254,20 +271,23 @@ class _EnergyFlowCardState extends State // charge), et l'y brancher est un lot à part — cf. `TODO.md`. En attendant, un // écran qui annonce son estimation ne trompe personne ; un écran qui affiche // « PAC 1,3 kW » en dur, si. - final total = d.homePower.clamp(1.0, double.infinity); + // Plus aucun ratio : une branche ne se dessine que si sa puissance est MESURÉE. + // Une branche absente dit « non publié » ; une branche à 38 % de la maison disait + // « voilà ce que consomme votre PAC », ce que personne n'avait mesuré. return [ - FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: total * 0.38, dashed: true), - FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18, dashed: true), - FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0), - FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.gridColor, powerW: total * 0.22, dashed: true), + FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: mesures['heating'] ?? 0), + FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: mesures['ecs'] ?? 0), + FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0), ]; case _DrillView.prod: - final half = d.pvPower / 2; - return [ - FlowSegment(from: Offset(nE, 0.5), to: Offset(hE, 0.5), color: EtmTokens.solar, powerW: half), - FlowSegment(from: Offset(1-nE, 0.5), to: Offset(1-hE, 0.5), color: EtmTokens.solar, powerW: half), - ]; + // La production n'était PAS décomposée : elle était COUPÉE EN DEUX, et les deux + // moitiés présentées comme « Onduleur 1 » et « Onduleur 2 » — avec l'état « ok », + // donc sans même la marque « estimé » que portaient les branches de consommation. + // Le banc n'a qu'un seul compteur de production : les deux onduleurs n'existaient + // nulle part. Tant que la box ne publie pas de détail par source, il n'y a rien à + // dessiner ici. + return const []; } } } @@ -280,6 +300,9 @@ class _NodeLayer extends StatefulWidget { /// Puissance mesurée de la borne — `null` = non lue. Voir [EnergyFlowCard]. final double? evChargerPowerW; + + /// Mesures par domaine — voir [EnergyFlowCard.mesuresParDomaine]. + final Map mesuresParDomaine; final Animation? pulseAnim; final VoidCallback onTapSolar; final VoidCallback onTapMaison; @@ -289,6 +312,7 @@ class _NodeLayer extends StatefulWidget { required this.view, required this.data, required this.evChargerPowerW, + required this.mesuresParDomaine, required this.pulseAnim, required this.onTapSolar, required this.onTapMaison, @@ -411,7 +435,6 @@ class _NodeLayerState extends State<_NodeLayer> Widget _buildCons(BuildContext context) { final d = widget.data; - final total = d.homePower.clamp(1.0, double.infinity); return Stack(children: [ // Hub — Maison _at(Offset(0.5, 0.5), @@ -424,14 +447,15 @@ class _NodeLayerState extends State<_NodeLayer> borderColor: EtmTokens.lineOf(context), ), )), + // « non publié » plutôt qu'un pourcentage de la maison. Un compteur ECS à 0 W est + // une MESURE — « rien ne chauffe » — et elle ne doit pas être écrasée par 18 % de la + // consommation du foyer. _at(Offset(0.5, 0.12), ScaleTransition(scale: _stagger(1), child: _sat(context, icon: Icons.heat_pump_outlined, color: EtmTokens.heat, - label: 'PAC', value: '${(total * 0.38 / 1000).toStringAsFixed(1)} kW', - estimated: true))), + label: 'PAC', value: _kwOuNonPublie(widget.mesuresParDomaine['heating'])))), _at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(2), child: _sat(context, icon: Icons.water_drop_outlined, color: EtmTokens.water, - label: 'Eau chaude', value: '${(total * 0.18 / 1000).toStringAsFixed(1)} kW', - estimated: true))), + label: 'Eau chaude', value: _kwOuNonPublie(widget.mesuresParDomaine['ecs'])))), _at(Offset(0.5, 0.88), ScaleTransition(scale: _stagger(3), child: _sat(context, icon: Icons.ev_station_rounded, color: eco(context), label: 'Voiture', @@ -440,17 +464,19 @@ class _NodeLayerState extends State<_NodeLayer> value: widget.evChargerPowerW == null ? 'non lu' : '${(widget.evChargerPowerW! / 1000).toStringAsFixed(1)} kW'))), - _at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(4), - child: _sat(context, icon: Icons.devices_other_rounded, color: EtmTokens.gridColor, - label: 'Autres', value: '${(total * 0.22 / 1000).toStringAsFixed(1)} kW', - estimated: true))), + // « Autres » était 22 % de la maison — un reste calculé sur des parts inventées, + // donc inventé lui aussi. Sans mesure par charge exhaustive, il n'y a pas de reste + // à afficher : il faudrait connaître la somme des parts, et on ne la connaît pas. ]); } + /// Une puissance mesurée, ou **« non publié »** — jamais un zéro ni un pourcentage. + static String _kwOuNonPublie(double? w) => + w == null ? 'non publié' : '${(w / 1000).toStringAsFixed(1)} kW'; + Widget _buildProd(BuildContext context) { final d = widget.data; final solar = EtmTokens.solarOf(context); - final half = d.pvPower / 2000; return Stack(children: [ _at(Offset(0.5, 0.5), ScaleTransition(scale: _stagger(0), @@ -462,12 +488,16 @@ class _NodeLayerState extends State<_NodeLayer> borderColor: EtmTokens.lineOf(context), ), )), - _at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(1), - child: _sat(context, icon: Icons.solar_power_rounded, color: solar, - label: 'Onduleur 1', value: '${half.toStringAsFixed(1)} kW', state: _NodeState.ok))), - _at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(2), - child: _sat(context, icon: Icons.solar_power_rounded, color: solar, - label: 'Onduleur 2', value: '${half.toStringAsFixed(1)} kW', state: _NodeState.ok))), + // « Onduleur 1 » et « Onduleur 2 » portaient chacun la MOITIÉ de la production, avec + // l'état « ok ». Deux appareils qui n'existent nulle part — le banc n'a qu'un seul + // compteur de production. Une décomposition par source demande que la box la + // publie ; tant qu'elle ne la publie pas, le détail se résume au total, et le dire + // vaut mieux que de le fabriquer. + _at(Offset(0.5, 0.86), ScaleTransition(scale: _stagger(1), + child: Text('Détail par source non publié par cette box', + textAlign: TextAlign.center, + style: EtmTokens.sans( + size: 11.5, color: EtmTokens.mutedOf(context))))), ]); } @@ -589,7 +619,20 @@ class _NodeLayerState extends State<_NodeLayer> children: [ Icon(icon, color: color, size: 21), const SizedBox(height: 1), - Text(value, style: EtmTokens.mono(size: 11, weight: FontWeight.w600, color: color)), + // Le nœud est un cercle de taille FIXE, et la valeur n'est pas toujours un + // nombre : « non lu », « non publié » débordent là où « 1.2 kW » tenait. Un + // débordement de 7 px, mesuré en test — donc du texte coupé sur l'appareil. + // `scaleDown` réduit ce qu'il faut et ne touche à rien quand la place suffit. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text(value, + maxLines: 1, + style: EtmTokens.mono( + size: 11, weight: FontWeight.w600, color: color)), + ), + ), Text(label, style: EtmTokens.sans(size: 8.5, color: muted)), ], ), diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 448c17d..82ff373 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -6,6 +6,8 @@ import '../models/daily_balance.dart'; import '../models/energy_ratios_snapshot.dart'; import '../main.dart' show DrawerMenuButton; import '../providers/app_settings_provider.dart'; +import '../providers/load_config_provider.dart'; +import '../providers/load_telemetry_provider.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; import '../features/dashboard/widgets/energy_flow_card.dart'; @@ -71,6 +73,36 @@ class _DashboardScreenState extends State { } } + /// Puissance mesurée par domaine (`ecs`, `heating`), lue dans `GetLoadTelemetry`. + /// + /// Une charge dont la mesure n'est pas publiée — `source: "none"`, ou charge absente de + /// `loads[]` — n'entre pas dans la carte : son domaine reste **sans valeur**, et le + /// détail affichera « non publié ». C'est la seule lecture honnête : un compteur ECS à + /// 0 W dit « rien ne chauffe », une absence dit « on ne sait pas », et un pourcentage de + /// la consommation du foyer ne dit ni l'un ni l'autre. + Map _mesuresParDomaine(BuildContext context) { + final tel = context.watch(); + final cfg = context.watch(); + final out = {}; + for (final e in tel.telemetry?.loads ?? const []) { + if (!e.measurement.mesuree) continue; + // Les identifiants voyagent avec ou sans accolades selon l'appel (UI_data_contract + // §9) : on compare sur la forme nue, jamais sur la chaîne brute. + String nu(String x) => x.replaceAll('{', '').replaceAll('}', '').toLowerCase(); + final cible = nu(e.loadId); + String? domaine; + for (final c in cfg.entries) { + if (nu(c.id) == cible) { + domaine = c.domain; + break; + } + } + if (domaine == null || domaine.isEmpty) continue; + out[domaine] = (out[domaine] ?? 0) + e.measurement.powerW!; + } + return out; + } + @override Widget build(BuildContext context) { return Consumer( @@ -97,7 +129,11 @@ class _DashboardScreenState extends State { EnergyFlowCard( data: data, evChargerPowerW: - service.evCharger?.currentPowerW), + service.evCharger?.currentPowerW, + // Les mesures RÉELLES par domaine, prises dans la télémétrie + // d'arbitrage. Un domaine absent de la carte = la box ne le + // publie pas, et le détail le dira au lieu d'inventer une part. + mesuresParDomaine: _mesuresParDomaine(context)), const SizedBox(height: 14), KpiRow(data: data, balance: _bilan, ratios: _ratios), const SizedBox(height: 14), diff --git a/test/energy_flow_cons_test.dart b/test/energy_flow_cons_test.dart index 085ff8e..8c20d15 100644 --- a/test/energy_flow_cons_test.dart +++ b/test/energy_flow_cons_test.dart @@ -18,7 +18,8 @@ import 'package:flutter_test/flutter_test.dart'; /// Largeur contrainte : le schéma est un carré, et en pleine largeur de la fenêtre de test /// (800 px) il déborde des 600 px de haut — le nœud « Maison » se retrouve hors écran et /// aucune touche ne l'atteint. -Widget _harness({double? evChargerPowerW}) => MediaQuery( +Widget _harness({double? evChargerPowerW, Map? mesures}) => + MediaQuery( data: const MediaQueryData(disableAnimations: true), child: MaterialApp( home: Scaffold( @@ -28,6 +29,7 @@ Widget _harness({double? evChargerPowerW}) => MediaQuery( child: EnergyFlowCard( data: const EnergyData(homePower: 4000, pvPower: 5000), evChargerPowerW: evChargerPowerW, + mesuresParDomaine: mesures ?? const {}, ), ), ), @@ -63,20 +65,44 @@ void main() { expect(find.textContaining('Répartition estimée'), findsNothing); }); - testWidgets('la vue consommation ANNONCE que trois branches sont estimées', + testWidgets('un compteur a 0 W affiche 0 W, pas 18 % de la maison', (tester) async { - await tester.pumpWidget(_harness(evChargerPowerW: 0)); + // Le cas exact du 2026-09-06 : ECS et PAC publiaient {powerW: 0, source: meter} + // pendant que l'ecran montrait des parts de la consommation du foyer. + await tester.pumpWidget(_harness(mesures: {'ecs': 0.0, 'heating': 0.0})); await _ouvrirConso(tester); - final note = find.textContaining('Répartition estimée'); - expect(note, findsOneWidget); - final texte = tester.widget(note).data!; - // Les trois branches sont NOMMÉES : « certaines valeurs sont estimées » laisserait - // chercher lesquelles, et quatre chiffres en kW se ressemblent tous. - expect(texte, contains('PAC')); - expect(texte, contains('eau chaude')); - expect(texte, contains('autres usages')); - expect(texte, contains('Seule la voiture est mesurée')); + expect(find.text('0.0 kW'), findsNWidgets(2)); + // 18 % et 38 % de 4 000 W : les deux nombres qui ne doivent plus exister. + expect(find.textContaining('0.7'), findsNothing); + expect(find.textContaining('1.5'), findsNothing); + }); + + testWidgets('un domaine sans mesure publiee dit non publie', (tester) async { + await tester.pumpWidget(_harness(mesures: {'ecs': 1200.0})); + await _ouvrirConso(tester); + + expect(find.text('1.2 kW'), findsOneWidget); + expect(find.text('non publié'), findsOneWidget); + }); + + testWidgets('la note ne parle plus d estimation, il n y a plus rien d estime', + (tester) async { + // La mention « estimé » etait le probleme autant que le remede : sous un nombre + // invente, elle le rend acceptable. L'enlever oblige a ne montrer que du vrai. + await tester.pumpWidget(_harness(mesures: {'ecs': 1200.0})); + await _ouvrirConso(tester); + + expect(find.textContaining('Répartition estimée'), findsNothing); + expect(find.textContaining('parts types'), findsNothing); + expect(find.textContaining('non publié'), findsWidgets); + }); + + testWidgets('Autres a disparu : un reste calcule sur des parts inventees', + (tester) async { + await tester.pumpWidget(_harness(mesures: {'ecs': 1200.0, 'heating': 800.0})); + await _ouvrirConso(tester); + expect(find.text('Autres'), findsNothing); }); testWidgets('sans borne, la voiture affiche « non lu » — jamais 0,0 kW',