import 'dart:math' as math; import 'package:flutter/material.dart'; import '../models/energy_data.dart'; import '../theme/etm_tokens.dart'; /// Diagramme de flux d'énergie animé — réutilisable Dashboard + Énergie. /// /// Layout 4 nœuds : Solaire (haut centre), Maison (centre), Batterie (bas gauche), /// Réseau (bas droite). Les lignes de flux sont animées via CustomPainter. class EnergyFlowWidget extends StatefulWidget { final EnergyData data; const EnergyFlowWidget({super.key, required this.data}); @override State createState() => _EnergyFlowWidgetState(); } class _EnergyFlowWidgetState extends State with SingleTickerProviderStateMixin { late AnimationController _ctrl; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final data = widget.data; return Container( decoration: BoxDecoration( color: EtmTokens.card, borderRadius: BorderRadius.circular(EtmTokens.radiusLg), boxShadow: EtmTokens.cardShadow, ), padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Flux d\'énergie en temps réel', style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), Row( children: [ Text('${data.temperature.toStringAsFixed(0)}°C', style: EtmTokens.sans(size: 14, color: EtmTokens.muted)), const SizedBox(width: 4), const Icon(Icons.wb_cloudy_outlined, color: EtmTokens.faint, size: 20), ], ), ], ), const SizedBox(height: 16), // Flow diagram SizedBox( height: 240, child: LayoutBuilder( builder: (context, constraints) { final w = constraints.maxWidth; final h = constraints.maxHeight; final nodeR = w * 0.13; // rayon nœud ≈52px sur 400px final nodes = _NodePositions(w: w, h: h, r: nodeR); return AnimatedBuilder( animation: _ctrl, builder: (_, __) => Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: CustomPaint( painter: _FlowPainter( nodes: nodes, data: data, t: _ctrl.value, ), ), ), _sunNode(center: nodes.sun, r: nodeR, data: data), _homeNode(center: nodes.home, r: nodeR * 1.08, data: data), _battNode(center: nodes.batt, r: nodeR, data: data), _gridNode(center: nodes.grid, r: nodeR, data: data), ], ), ); }, ), ), // Footer mode optimal const SizedBox(height: 6), _OptimalBanner(), ], ), ); } } // ─────────────────────────── Positions ───────────────────────────────────────── class _NodePositions { final double w, h, r; late final Offset sun, home, batt, grid; _NodePositions({required this.w, required this.h, required this.r}) { sun = Offset(w * 0.5, h * 0.19); home = Offset(w * 0.5, h * 0.73); batt = Offset(w * 0.14, h * 0.73); grid = Offset(w * 0.86, h * 0.73); } } // ─────────────────────────── Painter ─────────────────────────────────────────── class _FlowPainter extends CustomPainter { final _NodePositions nodes; final EnergyData data; final double t; const _FlowPainter({required this.nodes, required this.data, required this.t}); @override void paint(Canvas canvas, Size size) { final r = nodes.r; // Sun → Home _line( canvas, from: Offset(nodes.sun.dx, nodes.sun.dy + r), to: Offset(nodes.home.dx, nodes.home.dy - r * 1.08), color: data.pvPower > 0 ? EtmTokens.amber : EtmTokens.line, animated: data.pvPower > 0, arrowDown: true, ); // Home ↔ Battery final battFlow = data.batteryPower; if (battFlow > 0) { // Charging: Home → Battery _line(canvas, from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), to: Offset(nodes.batt.dx + r, nodes.batt.dy), color: EtmTokens.green, animated: true, arrowLeft: true); } else if (battFlow < 0) { // Discharging: Battery → Home _line(canvas, from: Offset(nodes.batt.dx + r, nodes.batt.dy), to: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), color: EtmTokens.amber, animated: true, arrowLeft: false); } else { _line(canvas, from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), to: Offset(nodes.batt.dx + r, nodes.batt.dy), color: EtmTokens.line, animated: false); } // Réseau ↔ Maison (gridPower > 0 = soutirage = Grid → Home) final gridFlow = data.gridPower; final homeRight = Offset(nodes.home.dx + r * 1.08, nodes.home.dy); final gridLeft = Offset(nodes.grid.dx - r, nodes.grid.dy); if (gridFlow > 20) { // Soutirage : Grid → Home (amber) _line(canvas, from: gridLeft, to: homeRight, color: EtmTokens.amber, animated: true); } else if (gridFlow < -20) { // Injection : Home → Grid (blue) _line(canvas, from: homeRight, to: gridLeft, color: EtmTokens.blue, animated: true); } else { _line(canvas, from: homeRight, to: gridLeft, color: EtmTokens.line, animated: false); } } void _line( Canvas canvas, { required Offset from, required Offset to, required Color color, required bool animated, bool arrowDown = false, bool arrowLeft = false, }) { final dx = to.dx - from.dx; final dy = to.dy - from.dy; final len = math.sqrt(dx * dx + dy * dy); if (len < 1) return; final paint = Paint() ..color = color ..strokeWidth = 2.5 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; const dash = 4.0; const gap = 7.0; const period = dash + gap; final offset = animated ? (t * period) % period : 0.0; double dist = -offset; while (dist < len) { final d0 = dist.clamp(0.0, len); final d1 = (dist + dash).clamp(0.0, len); if (d1 > d0) { canvas.drawLine( Offset(from.dx + dx / len * d0, from.dy + dy / len * d0), Offset(from.dx + dx / len * d1, from.dy + dy / len * d1), paint, ); } dist += period; } // Arrow head at destination if (animated) { _arrow(canvas, from, to, color); } } void _arrow(Canvas canvas, Offset from, Offset to, Color color) { final dx = to.dx - from.dx; final dy = to.dy - from.dy; final len = math.sqrt(dx * dx + dy * dy); if (len < 1) return; final nx = dx / len; final ny = dy / len; const size = 7.0; final tip = to; final p1 = Offset(tip.dx - nx * size + ny * size * 0.5, tip.dy - ny * size - nx * size * 0.5); final p2 = Offset(tip.dx - nx * size - ny * size * 0.5, tip.dy - ny * size + nx * size * 0.5); final path = Path()..moveTo(tip.dx, tip.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close(); canvas.drawPath(path, Paint()..color = color..style = PaintingStyle.fill); } @override bool shouldRepaint(_FlowPainter old) => old.t != t || old.data.pvPower != data.pvPower || old.data.batteryPower != data.batteryPower || old.data.gridPower != data.gridPower; } // ─────────────────────────── Nœuds ───────────────────────────────────────────── Widget _sunNode({required Offset center, required double r, required EnergyData data}) { final kw = (data.pvPower / 1000); return _Node( center: center, r: r, borderColor: const Color(0xFFFDE9A8), icon: Icons.wb_sunny_rounded, iconColor: EtmTokens.amber, label: 'Solaire', value: '${kw.toStringAsFixed(2)} kW', valueColor: EtmTokens.amber, ); } Widget _homeNode({required Offset center, required double r, required EnergyData data}) { return _Node( center: center, r: r, borderColor: const Color(0xFFBFE2F3), ringColor: const Color(0xFFEAF6FC), icon: Icons.home_rounded, iconColor: EtmTokens.blue, label: 'Maison', value: '${data.homePower.toStringAsFixed(0)} W', valueColor: EtmTokens.blue, ); } Widget _battNode({required Offset center, required double r, required EnergyData data}) { final sign = data.batteryPower > 0 ? '+ ' : data.batteryPower < 0 ? '− ' : ''; return _Node( center: center, r: r, borderColor: const Color(0xFFC4EED7), icon: Icons.battery_charging_full_rounded, iconColor: EtmTokens.green, label: 'Batterie', value: '${data.batterySOC.toStringAsFixed(0)}%', valueColor: EtmTokens.green, sub: '$sign${data.batteryPower.abs().toStringAsFixed(0)} W', subColor: EtmTokens.green, ); } Widget _gridNode({required Offset center, required double r, required EnergyData data}) { return _Node( center: center, r: r, icon: Icons.electrical_services_rounded, iconColor: EtmTokens.faint, label: 'Réseau', value: '${data.gridPower.abs().toStringAsFixed(0)} W', valueColor: EtmTokens.muted, ); } class _Node extends StatelessWidget { final Offset center; final double r; final Color? borderColor; final Color? ringColor; final IconData icon; final Color iconColor; final String label; final String value; final Color valueColor; final String? sub; final Color? subColor; const _Node({ required this.center, required this.r, this.borderColor, this.ringColor, required this.icon, required this.iconColor, required this.label, required this.value, required this.valueColor, this.sub, this.subColor, }); @override Widget build(BuildContext context) { final diameter = r * 2; return Positioned( left: center.dx - r, top: center.dy - r, width: diameter, height: diameter + 32, // extra for label below child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: diameter, height: diameter, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, border: Border.all( color: borderColor ?? EtmTokens.line, width: 1.5, ), boxShadow: ringColor != null ? [ BoxShadow(color: ringColor!, blurRadius: 0, spreadRadius: 5), BoxShadow( color: EtmTokens.navy.withValues(alpha: 0.08), blurRadius: 16, offset: const Offset(0, 4)), ] : [ BoxShadow( color: EtmTokens.navy.withValues(alpha: 0.08), blurRadius: 16, offset: const Offset(0, 4)), ], ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: iconColor, size: r * 0.56), const SizedBox(height: 2), Text(value, style: EtmTokens.mono(size: r * 0.32, color: valueColor)), if (sub != null) Text(sub!, style: EtmTokens.mono(size: r * 0.26, color: subColor ?? EtmTokens.muted)), ], ), ), const SizedBox(height: 4), Text( label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted), textAlign: TextAlign.center, ), ], ), ); } } // ─────────────────────────── Bannière ────────────────────────────────────────── class _OptimalBanner extends StatelessWidget { const _OptimalBanner(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: EtmTokens.greenSoft, borderRadius: BorderRadius.circular(14), ), child: Row( children: [ Container( width: 32, height: 32, decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle), child: const Icon(Icons.check, color: Colors.white, size: 18), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Mode optimal', style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.greenDark)), Text('Héos optimise votre consommation', style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), ], ), ), const Icon(Icons.chevron_right, color: EtmTokens.faint, size: 20), ], ), ); } }