Patrick Schurig a14adcfcc3 fix(bilan): un cumul ABSENT n'est pas un cumul nul
`double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0` : un champ manquant
devenait 0,0 au décodage, deux bornes à zéro donnaient une différence à zéro, et
l'écran affichait « 0,0 kWh · Vers réseau » comme une mesure. C'est le zéro forgé
que nous traquons depuis une semaine, cette fois de NOTRE côté.

Le cas est réel et n'attend pas sa cause : un compteur racine parfaitement en
place, la puissance instantanée qui arrive, et les cumuls qui n'arrivent pas.

`PowerBalanceEntry.totalsPresent` porte la distinction, et `fetchDailyBalance`
refuse de présenter comme mesure un bilan bâti là-dessus — au même titre qu'un
compteur corrompu. « Rien échangé aujourd'hui » et « la box ne publie pas ses
cumuls » sont deux états, et le second n'a pas le droit de porter le premier.
Le repli à 0 reste, pour ne pas propager des nullables dans tout l'écran Énergie,
mais il est désormais ACCOMPAGNÉ du fait qu'il est un repli.

Et `abs()` sur la production devient un choix DOCUMENTÉ plutôt qu'un correctif
tacite : la production n'est pas signée, c'est le compteur qui l'est, donc la
valeur absolue masquerait un compteur câblé à l'envers dont le signe serait la
seule trace. Le correctif appartient à la source. On la garde en connaissance de
cause — un signe faux se voit tous les jours, un câblage inversé ne se voit
jamais — et on ne revient pas en arrière sans savoir ce que la box publiera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 12:41:10 +02:00

677 lines
26 KiB
Dart

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<EnergyFlowCard> createState() => _EnergyFlowCardState();
}
class _EnergyFlowCardState extends State<EnergyFlowCard>
with TickerProviderStateMixin {
late final AnimationController _particleCtrl;
late final AnimationController _pulseCtrl;
late final Animation<double> _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<double>(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),
),
),
],
);
},
);
},
),
),
// ── Ce qui est mesuré, et ce qui ne l'est pas ───────────────────────
//
// Le trait pointillé porte l'information, mais il ne se lit pas tout seul : sans
// cette ligne, quatre chiffres en kW se ressemblent tous et se lisent tous comme
// des mesures. Une seule des quatre branches en est une.
if (_view == _DrillView.cons)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Répartition estimée : PAC, eau chaude et autres usages sont des parts '
'types, pas des mesures. Seule la voiture est mesurée à la borne.',
style: EtmTokens.sans(size: 11, color: muted),
textAlign: TextAlign.center,
),
),
// ── 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<FlowSegment> _buildSegments(
EnergyData d, _DrillView view, double? evW) {
const nE = _nE;
const hE = _hE;
switch (view) {
case _DrillView.overview:
// ⚠️ Ces deux lignes étaient INVERSÉES — elles lisaient « + = injection » là où la
// box publie « + = soutirage ». Une installation exportant 2,5 kW affichait donc
// « consommation réseau », flèche à l'envers (vu sur appareil le 2026-09-06). Le
// sens vit désormais dans `EnergyData`, nommé une fois, plutôt que relu ici.
final gridImport = d.gridImportW;
final gridExport = d.gridExportW;
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:
// ⚠️ TROIS BRANCHES SUR QUATRE SONT DES RATIOS EN DUR — 38 %, 18 %, 22 % de la
// consommation maison. Ce ne sont pas des mesures, et rien dans la box ne les
// produit : elles ont été posées à la maquette et jamais remplacées.
//
// Elles sont donc marquées `dashed` / `estimated`, comme « Autres » l'était déjà
// seul, et la légende sous le schéma le dit en toutes lettres. Le marquage n'est
// pas le correctif : la source existe (`GetLoadTelemetry` publie `measurement` par
// charge), et l'y brancher est un lot à part — cf. `TODO.md`. En attendant, un
// écran qui annonce son estimation ne trompe personne ; un écran qui affiche
// « PAC 1,3 kW » en dur, si.
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, dashed: true),
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18, dashed: true),
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<double>? 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<double> _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',
// ⚠️ **`abs()` est un choix, et il a un coût connu.** La production n'est
// pas une grandeur signée : c'est le COMPTEUR qui l'est. Prendre la valeur
// absolue redresse donc un signe de source côté affichage — et, ce faisant,
// rendrait invisible un compteur câblé à l'envers, dont le signe serait la
// seule trace. Le correctif appartient à la source ; il est demandé au
// moteur. D'ici là on garde `abs()` en connaissance de cause : entre un
// signe faux à l'écran et une anomalie de câblage masquée, le premier se
// voit tous les jours, la seconde ne se voit jamais — et surtout, revenir
// en arrière sans savoir ce que la box publiera ne serait pas un choix.
//
// `currentPowerProduction` est publié NÉGATIF par nymea (-8 980 W =
// 8,98 kW produits), et le service garde les signes bruts par politique.
// L'AFFICHAGE, lui, doit dire ce qu'un client comprend : une production est
// une quantité positive. Vu sur appareil le 2026-09-05, « -8.9 kW Solaire »
// sous un soleil de 9 kW — la carte normalisait déjà le réseau et la
// batterie par `.abs()`, la branche solaire était la seule oubliée.
value: '${(d.pvPower.abs() / 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',
estimated: true))),
_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',
estimated: true))),
_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;
}