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
439 lines
14 KiB
Dart
439 lines
14 KiB
Dart
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|