import 'package:flutter/material.dart'; import '../../../models/energy_data.dart'; import '../../../theme/etm_tokens.dart'; import '../../../shared/widgets/hems_icon.dart'; import '../painters/flow_painter.dart'; enum _DrillView { overview, cons, prod } class EnergyFlowCard extends StatefulWidget { final EnergyData data; /// Puissance **mesurée** de la borne de recharge (`currentPower` du Thing), ou `null` /// quand aucune borne n'est déclarée — ou qu'elle ne publie pas cet état. /// /// Elle valait 3,6 kW en dur jusqu'au 2026-08-28 : `EnergyData.chargingPower` était une /// 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; /// **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(); } class _EnergyFlowCardState extends State with TickerProviderStateMixin { late final AnimationController _particleCtrl; late final AnimationController _pulseCtrl; late final Animation _pulseAnim; _DrillView _view = _DrillView.overview; @override void initState() { super.initState(); _particleCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 2800), )..repeat(); _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 3000), )..repeat(reverse: true); _pulseAnim = Tween(begin: 0.22, end: 0.55) .animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); } @override void dispose() { _particleCtrl.dispose(); _pulseCtrl.dispose(); super.dispose(); } void _switchView(_DrillView v) => setState(() => _view = v); String get _viewTitle => switch (_view) { _DrillView.overview => 'Flux énergétique', _DrillView.cons => 'Consommation — détail', _DrillView.prod => 'Production — détail', }; @override Widget build(BuildContext context) { final disableAnim = MediaQuery.of(context).disableAnimations; final eco = EtmTokens.ecoOf(context); final line = EtmTokens.lineOf(context); final surf = EtmTokens.surfaceOf(context); final ink = EtmTokens.inkOf(context); final muted = EtmTokens.mutedOf(context); return Container( decoration: BoxDecoration( color: surf, borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: EtmTokens.cardShadowOf(context), border: Border.all(color: line, width: 1), ), padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── Header ───────────────────────────────────────────────────────── Row( children: [ if (_view != _DrillView.overview) ...[ GestureDetector( onTap: () => _switchView(_DrillView.overview), child: Container( width: 32, height: 32, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(color: line, width: 1), ), child: Icon(Icons.chevron_left_rounded, size: 20, color: ink), ), ), const SizedBox(width: 10), ], Expanded( child: Text(_viewTitle, style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: ink)), ), AnimatedBuilder( animation: _pulseAnim, builder: (context, _) => Container( width: 7, height: 7, decoration: BoxDecoration( shape: BoxShape.circle, color: eco.withValues(alpha: disableAnim ? 0.6 : _pulseAnim.value), ), ), ), const SizedBox(width: 6), Text('en direct', style: EtmTokens.mono(size: 11, color: muted)), ], ), const SizedBox(height: 4), // ── Flow diagram ──────────────────────────────────────────────────── AspectRatio( aspectRatio: 1, child: LayoutBuilder( builder: (context, constraints) { final size = constraints.maxWidth; final segments = _buildSegments(widget.data, _view, widget.evChargerPowerW, widget.mesuresParDomaine); return AnimatedBuilder( animation: _particleCtrl, builder: (context, _) { final animVal = disableAnim ? 0.0 : _particleCtrl.value; return Stack( children: [ CustomPaint( size: Size(size, size), painter: FlowPainter( segments: segments, animValue: animVal, lineColor: EtmTokens.lineStrongOf(context), ), ), AnimatedSwitcher( duration: const Duration(milliseconds: 220), transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: ScaleTransition( scale: anim.drive(Tween(begin: 0.88, end: 1.0)), child: child, ), ), child: _NodeLayer( key: ValueKey(_view), view: _view, data: widget.data, evChargerPowerW: widget.evChargerPowerW, mesuresParDomaine: widget.mesuresParDomaine, pulseAnim: disableAnim ? null : _pulseAnim, onTapSolar: () => _switchView(_DrillView.prod), onTapMaison: () => _switchView(_DrillView.cons), ), ), ], ); }, ); }, ), ), // ── Ce qui est mesuré, et ce qui ne l'est pas ─────────────────────── // // Le trait pointillé porte l'information, mais il ne se lit pas tout seul : sans // cette ligne, quatre chiffres en kW se ressemblent tous et se lisent tous comme // des mesures. Une seule des quatre branches en est une. if (_view == _DrillView.cons) Padding( padding: const EdgeInsets.only(top: 8), child: Text( '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, ), ), // ── Hint (overview only) ──────────────────────────────────────────── if (_view == _DrillView.overview) Padding( padding: const EdgeInsets.only(top: 8), child: Text.rich( TextSpan( style: EtmTokens.sans(size: 11, color: muted), children: [ const TextSpan(text: 'Touchez '), TextSpan( text: 'Maison', style: EtmTokens.sans( size: 11, weight: FontWeight.w600, color: EtmTokens.brand), ), const TextSpan(text: ' ou '), TextSpan( text: 'Solaire', style: EtmTokens.sans( size: 11, weight: FontWeight.w600, color: EtmTokens.solar), ), const TextSpan(text: ' pour détailler'), ], ), textAlign: TextAlign.center, ), ), ], ), ); } // Edge-to-edge coordinates matching HTML EDGE dict ÷100 // nE = 0.22 (satellite inner edge), hE = 0.39 (hub outer edge) static const double _nE = 0.22; static const double _hE = 0.39; static List _buildSegments(EnergyData d, _DrillView view, double? evW, Map mesures) { const nE = _nE; const hE = _hE; switch (view) { case _DrillView.overview: // ⚠️ Ces deux lignes étaient INVERSÉES — elles lisaient « + = injection » là où la // box publie « + = soutirage ». Une installation exportant 2,5 kW affichait donc // « consommation réseau », flèche à l'envers (vu sur appareil le 2026-09-06). Le // sens vit désormais dans `EnergyData`, nommé une fois, plutôt que relu ici. final gridImport = d.gridImportW; final gridExport = d.gridExportW; final battCharge = d.batteryPower.clamp(0.0, double.infinity); final battDisch = (-d.batteryPower).clamp(0.0, double.infinity); return [ FlowSegment(from: Offset(0.5, nE), to: Offset(0.5, hE), color: EtmTokens.solar, powerW: d.pvPower), FlowSegment(from: Offset(nE, 0.5), to: Offset(hE, 0.5), color: EtmTokens.importColor, powerW: gridImport), FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.solToGrid, powerW: gridExport), FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.eco, powerW: battCharge), FlowSegment(from: Offset(1-nE, 0.5), to: Offset(1-hE, 0.5), color: EtmTokens.battToHouse, powerW: battDisch), FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.brand, powerW: d.homePower), ]; case _DrillView.cons: // ⚠️ TROIS BRANCHES SUR QUATRE SONT DES RATIOS EN DUR — 38 %, 18 %, 22 % de la // consommation maison. Ce ne sont pas des mesures, et rien dans la box ne les // produit : elles ont été posées à la maquette et jamais remplacées. // // Elles sont donc marquées `dashed` / `estimated`, comme « Autres » l'était déjà // seul, et la légende sous le schéma le dit en toutes lettres. Le marquage n'est // pas le correctif : la source existe (`GetLoadTelemetry` publie `measurement` par // 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. // 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: 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: // 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 []; } } } // ── Node layer ──────────────────────────────────────────────────────────────── class _NodeLayer extends StatefulWidget { final _DrillView view; final EnergyData data; /// 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; const _NodeLayer({ super.key, required this.view, required this.data, required this.evChargerPowerW, required this.mesuresParDomaine, required this.pulseAnim, required this.onTapSolar, required this.onTapMaison, }); @override State<_NodeLayer> createState() => _NodeLayerState(); } class _NodeLayerState extends State<_NodeLayer> with SingleTickerProviderStateMixin { late final AnimationController _enterCtrl; @override void initState() { super.initState(); _enterCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), )..forward(); } @override void dispose() { _enterCtrl.dispose(); super.dispose(); } Animation _stagger(int i) => CurvedAnimation( parent: _enterCtrl, curve: Interval( (i * 0.08).clamp(0.0, 0.6), ((i * 0.08) + 0.55).clamp(0.0, 1.0), curve: Curves.easeOutBack, ), ); @override Widget build(BuildContext context) { return switch (widget.view) { _DrillView.overview => _buildOverview(context), _DrillView.cons => _buildCons(context), _DrillView.prod => _buildProd(context), }; } Widget _buildOverview(BuildContext context) { final d = widget.data; final eco = EtmTokens.ecoOf(context); return Stack(children: [ // Hub — Héos with pulsing ring _at(Offset(0.5, 0.5), _buildHub(context, eco)), // Solar (top) — tappable → prod _at(Offset(0.5, 0.12), ScaleTransition(scale: _stagger(0), child: GestureDetector( onTap: widget.onTapSolar, child: _sat(context, icon: Icons.wb_sunny_rounded, color: EtmTokens.solarOf(context), label: 'Solaire', // ⚠️ **`abs()` est un choix, et il a un coût connu.** La production n'est // pas une grandeur signée : c'est le COMPTEUR qui l'est. Prendre la valeur // absolue redresse donc un signe de source côté affichage — et, ce faisant, // rendrait invisible un compteur câblé à l'envers, dont le signe serait la // seule trace. Le correctif appartient à la source ; il est demandé au // moteur. D'ici là on garde `abs()` en connaissance de cause : entre un // signe faux à l'écran et une anomalie de câblage masquée, le premier se // voit tous les jours, la seconde ne se voit jamais — et surtout, revenir // en arrière sans savoir ce que la box publiera ne serait pas un choix. // // `currentPowerProduction` est publié NÉGATIF par nymea (-8 980 W = // 8,98 kW produits), et le service garde les signes bruts par politique. // L'AFFICHAGE, lui, doit dire ce qu'un client comprend : une production est // une quantité positive. Vu sur appareil le 2026-09-05, « -8.9 kW Solaire » // sous un soleil de 9 kW — la carte normalisait déjà le réseau et la // batterie par `.abs()`, la branche solaire était la seule oubliée. value: '${(d.pvPower.abs() / 1000).toStringAsFixed(1)} kW', tappable: true, ), ), )), // Battery (right) _at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(1), child: _sat(context, icon: Icons.battery_charging_full_rounded, color: eco, label: 'Batterie', value: '${d.batterySOC.toStringAsFixed(0)} %', ), )), // Grid (left) — neutral border _at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(2), child: _sat(context, icon: Icons.home_outlined, color: EtmTokens.gridColor, label: 'Réseau', value: '${(d.gridPower.abs() / 1000).toStringAsFixed(1)} kW', borderColor: EtmTokens.lineOf(context), ), )), // Maison (bottom) — tappable → cons _at(Offset(0.5, 0.88), ScaleTransition(scale: _stagger(3), child: GestureDetector( onTap: widget.onTapMaison, child: _sat(context, icon: Icons.home_rounded, color: EtmTokens.brand, label: 'Maison', value: '${(d.homePower / 1000).toStringAsFixed(1)} kW', tappable: true, ), ), )), ]); } Widget _buildCons(BuildContext context) { final d = widget.data; return Stack(children: [ // Hub — Maison _at(Offset(0.5, 0.5), ScaleTransition(scale: _stagger(0), child: _satHub(context, icon: Icons.home_rounded, color: EtmTokens.brand, label: 'Maison', value: '${(d.homePower / 1000).toStringAsFixed(1)} kW', 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: _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: _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', // « non lu » plutôt qu'un zéro : sans borne déclarée, l'app ne sait pas // qu'il ne se charge rien — elle sait qu'elle ne mesure rien. value: widget.evChargerPowerW == null ? 'non lu' : '${(widget.evChargerPowerW! / 1000).toStringAsFixed(1)} kW'))), // « 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); return Stack(children: [ _at(Offset(0.5, 0.5), ScaleTransition(scale: _stagger(0), child: _satHub(context, icon: Icons.wb_sunny_rounded, color: solar, label: 'Solaire', // ⚠️ `.abs()` ICI AUSSI. La correction du 2026-09-05 n'avait redressé que le // nœud de la vue d'ensemble : le hub du détail production affichait toujours // « -1.3 kW », vu sur appareil le 2026-09-06. Une même grandeur rendue à deux // endroits, corrigée à un seul — le grep qui a servi cherchait `pvPower.abs` // et non `pvPower`, donc il a montré la ligne déjà corrigée et caché l'autre. value: '${(d.pvPower.abs() / 1000).toStringAsFixed(1)} kW', borderColor: EtmTokens.lineOf(context), ), )), // « 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))))), ]); } Color eco(BuildContext context) => EtmTokens.ecoOf(context); // Position any widget by normalized (0..1) coordinates Widget _at(Offset pos, Widget child) => Align( alignment: Alignment(pos.dx * 2 - 1, pos.dy * 2 - 1), child: child, ); // Hub node — 92px circle, surface bg, line border Widget _satHub(BuildContext context, { required IconData icon, required Color color, required String label, required String value, Color? borderColor, }) { final surf = EtmTokens.surfaceOf(context); final muted = EtmTokens.mutedOf(context); final bdr = borderColor ?? color.withValues(alpha: 0.4); return SizedBox( width: 92, height: 92, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: surf, border: Border.all(color: bdr, width: 2), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: color, size: 22), const SizedBox(height: 2), Text(value, style: EtmTokens.mono(size: 13, weight: FontWeight.w600, color: color)), Text(label, style: EtmTokens.sans(size: 9, color: muted)), ], ), ), ); } // Hub with Héos pulsing ring Widget _buildHub(BuildContext context, Color eco) { final surf = EtmTokens.surfaceOf(context); final muted = EtmTokens.mutedOf(context); final line = EtmTokens.lineOf(context); return SizedBox( width: 102, height: 102, child: Stack( alignment: Alignment.center, children: [ // Pulsing Héos ring if (widget.pulseAnim != null) AnimatedBuilder( animation: widget.pulseAnim!, builder: (context, _) => Container( width: 102, height: 102, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: eco.withValues(alpha: widget.pulseAnim!.value), width: 1.5, ), ), ), ), // Hub circle — Héos Container( width: 92, height: 92, decoration: BoxDecoration( shape: BoxShape.circle, color: surf, border: Border.all(color: line, width: 2), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ HemsIcon(size: 24, color: eco), const SizedBox(height: 3), Text('Héos', style: EtmTokens.sans(size: 9, color: muted)), ], ), ), ], ), ); } // Satellite node — 74px circle, content inside Widget _sat(BuildContext context, { required IconData icon, required Color color, required String label, required String value, Color? borderColor, bool tappable = false, bool estimated = false, _NodeState? state, }) { const size = 74.0; final surf = EtmTokens.surfaceOf(context); final muted = EtmTokens.mutedOf(context); final bdr = borderColor ?? color.withValues(alpha: 0.4); Widget circle = Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: surf, border: Border.all( color: estimated ? Colors.transparent : bdr, width: 1.5, ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: color, size: 21), const SizedBox(height: 1), // 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)), ], ), ); if (estimated) { circle = CustomPaint( painter: _DashedBorderPainter(color: color, radius: size / 2), child: circle, ); } return SizedBox( width: size, height: size, child: Stack( clipBehavior: Clip.none, children: [ circle, if (tappable) Positioned( right: 2, top: 2, child: Container( width: 15, height: 15, decoration: BoxDecoration( color: EtmTokens.brand, shape: BoxShape.circle, border: Border.all(color: surf, width: 1), ), child: const Center( child: Icon(Icons.add, size: 9, color: Colors.white), ), ), ), if (state != null) Positioned( right: 4, bottom: 4, child: Container( width: 7, height: 7, decoration: BoxDecoration( shape: BoxShape.circle, color: state == _NodeState.ok ? EtmTokens.eco : EtmTokens.heat, border: Border.all(color: surf, width: 1), ), ), ), ], ), ); } } enum _NodeState { ok } // ── Dashed border painter ───────────────────────────────────────────────────── class _DashedBorderPainter extends CustomPainter { final Color color; final double radius; const _DashedBorderPainter({required this.color, required this.radius}); @override void paint(Canvas canvas, Size size) { const dashCount = 16; final paint = Paint() ..color = color.withValues(alpha: 0.4) ..strokeWidth = 1.5 ..style = PaintingStyle.stroke; final center = Offset(size.width / 2, size.height / 2); const step = 3.14159 * 2 / dashCount; for (int i = 0; i < dashCount; i += 2) { final start = i * step; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), start, step * 0.6, false, paint, ); } } @override bool shouldRepaint(_DashedBorderPainter old) => old.color != color; }