etm-powersync-app/lib/widgets/energy_flow_widget.dart
Patrick Schurig 38475e0007 fix(dashboard+thème): compteurs réseau honnêtes, et le sélecteur clair/sombre s'applique partout
1) « Vers réseau » — TROIS défauts, dont un seul était visible.

   La valeur aberrante (9,10184409403335e+33 kWh) ne vient PAS de l'app : Energy.GetPowerBalance
   la publie telle quelle sur .75. Trois des quatre compteurs cumulés sont corrompus
   (totalReturn 9,1e+33, totalAcquisition 7,5e+33, totalConsumption -1,6e+33) alors que
   totalProduction reste sain, et que les états du Thing compteur sont tous plausibles
   (1429 kWh importés, 88 kWh exportés). C'est donc l'accumulation de l'experience-plugin
   énergie qui déraille, côté box — signalé, pas corrigé ici.

   Les deux autres défauts étaient bien dans l'app :
   - « Depuis réseau » lisait l'OPPOSÉ du compteur d'export au lieu de totalAcquisition,
     qui a son propre compteur. Dès que l'export était positif, la tuile affichait
     0,0 kWh en permanence, quelle que soit l'électricité réellement achetée ;
   - les deux tuiles étaient libellées « aujourd'hui » alors que ces compteurs sont
     CUMULÉS depuis l'origine, jamais remis à zéro.

   Corrigé : EnergyData porte gridExportKwh / gridImportKwh, transportés tels quels — l'app
   ne maquille pas une donnée que la box publie fausse. Un plafond de plausibilité
   (10 GWh, ~3000 ans de consommation) distingue « mesure » de « compteur cassé » : au-delà
   la tuile affiche « — · compteur box invalide ». Imprimer le nombre le présenterait comme
   une mesure ; l'effacer laisserait croire à zéro.

2) Thème : 126 sites basculés des constantes claires vers les variantes contextuelles
   (EtmTokens.bg → bgOf(context), card → surfaceOf, ink → inkOf, muted → mutedOf,
   faint → faintOf, line → lineOf) dans ac_screen, things_screen, energy_screen,
   favorites_screen, main_drawer, ev_charging_card, energy_flow_widget. Ces écrans
   restaient en palette claire quel que soit le sélecteur.

   9 sites RESTENT en constante, et c'est délibéré : ce sont des tables statiques et des
   helpers de haut niveau (categoryInfoMap, _options des favoris, _modeColor, peintres du
   flux) où la couleur est une identité d'icône et où aucun BuildContext n'est en portée.
   Les faire suivre le thème demande de threader le contexte jusqu'à ces tables — un
   refactor à part entière, pas un correctif de fin de soirée.

Tests 80/80, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:13:20 +02:00

439 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<EnergyFlowWidget> createState() => _EnergyFlowWidgetState();
}
class _EnergyFlowWidgetState extends State<EnergyFlowWidget>
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.surfaceOf(context),
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.mutedOf(context))),
const SizedBox(width: 4),
Icon(Icons.wb_cloudy_outlined, color: EtmTokens.faintOf(context), 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.lineOf(context),
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.mutedOf(context))),
],
),
),
const SizedBox(height: 4),
Text(
label,
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)),
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.mutedOf(context))),
],
),
),
Icon(Icons.chevron_right, color: EtmTokens.faintOf(context), size: 20),
],
),
);
}
}