From 706c390f1ff1e44ce66b9bdd3130887ae4f3f975 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 21 Jun 2026 07:55:20 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20production=20d=C3=A9tail=20=E2=80=94=20o?= =?UTF-8?q?nduleurs=20positionn=C3=A9s=20gauche/droite=20comme=20les=20con?= =?UTF-8?q?sommateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/widgets/energy_flow_card.dart | 607 ++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 lib/features/dashboard/widgets/energy_flow_card.dart diff --git a/lib/features/dashboard/widgets/energy_flow_card.dart b/lib/features/dashboard/widgets/energy_flow_card.dart new file mode 100644 index 0000000..9cfd0f5 --- /dev/null +++ b/lib/features/dashboard/widgets/energy_flow_card.dart @@ -0,0 +1,607 @@ +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; + const EnergyFlowCard({super.key, required this.data}); + + @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); + 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, + 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) { + 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: d.chargingPower * 1000), + 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; + final Animation? pulseAnim; + final VoidCallback onTapSolar; + final VoidCallback onTapMaison; + + const _NodeLayer({ + super.key, + required this.view, + required this.data, + 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', value: '${d.chargingPower.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; +}