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; const EnergyFlowCard( {super.key, required this.data, this.evChargerPowerW}); @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); 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, pulseAnim: disableAnim ? null : _pulseAnim, onTapSolar: () => _switchView(_DrillView.prod), onTapMaison: () => _switchView(_DrillView.cons), ), ), ], ); }, ); }, ), ), // ── 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) { const nE = _nE; const hE = _hE; switch (view) { case _DrillView.overview: final gridImport = (-d.gridPower).clamp(0.0, double.infinity); final gridExport = d.gridPower.clamp(0.0, double.infinity); 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: final total = d.homePower.clamp(1.0, double.infinity); return [ FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: total * 0.38), FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18), 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), ]; 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), ]; } } } // ── 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; final Animation? pulseAnim; final VoidCallback onTapSolar; final VoidCallback onTapMaison; const _NodeLayer({ super.key, required this.view, required this.data, required this.evChargerPowerW, 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', value: '${(d.pvPower / 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; final total = d.homePower.clamp(1.0, double.infinity); 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), ), )), _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'))), _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'))), _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'))), _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))), ]); } 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), child: _satHub(context, icon: Icons.wb_sunny_rounded, color: solar, label: 'Solaire', value: '${(d.pvPower / 1000).toStringAsFixed(1)} kW', 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))), ]); } 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), Text(value, 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; }