Patrick Schurig 95769938fc fix(ev): la tuile des favoris devient muette — elle lit la borne, ou le dit
Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée
lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire,
elle le dit.

Ce qui est lu, et sa source
  · branché / en charge   ← états pluggedIn, charging du Thing
  · puissance             ← état currentPower du Thing
  · mode                  ← GetChargingInfos + notification ChargingInfoChanged

Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non
reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a
pas dit ce qu'elle porte.

L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui
avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et
affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local :
après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle
avait demandé, ce qui n'est pas ce que la borne porte.

La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et
solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que
_updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais
dans EvChargerLive, champ par champ, nullable.

⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW,
voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en
a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %,
Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle
(GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le
Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider.

Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi)
  · currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu
  · phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard
  · sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on
    attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste
    entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un
    sessionEnergy qui continue.

Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit
pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son
Thing, et le banc n'a pas eu ce cas.

154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus),
flutter analyze à 27 remarques, aucune nouvelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 12:49:28 +02:00

629 lines
23 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),
),
),
],
);
},
);
},
),
),
// ── 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:
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: 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',
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',
// « 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;
}