Deux défauts trouvés en vérifiant la correction précédente à l'écran.
1. « -1.3 kW · Solaire » dans « Production — détail ». La correction du
2026-09-05 n'avait redressé QUE le nœud de la vue d'ensemble : la même
grandeur est rendue à deux endroits, et le grep qui avait servi cherchait
`pvPower.abs` — donc il montrait la ligne déjà corrigée et cachait l'autre.
Un test couvre désormais le détail, en éprouvant l'ABSENCE du signe.
2. PAC et « Eau chaude » affichaient « non publié » alors que la box publie
`{powerW: 0, source: "meter"}` pour les deux. `LoadConfigProvider.load()`
n'était appelé que par l'écran des rôles : sur le tableau de bord la
configuration est vide, aucun `domain` ne se rattache, et la carte reste sans
valeur. Une absence CÔTÉ APP présentée comme une absence côté box — le défaut
qu'on venait de corriger, reproduit un cran plus loin, et trompeur de la même
façon : on en conclurait que la box ne publie rien.
Vérifié à l'écran entre les deux : « non publié » est lisible dans les cercles,
« Autres » a disparu, et le détail production ne montre plus d'onduleurs
fabriqués mais « Détail par source non publié par cette box ».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
725 lines
29 KiB
Dart
725 lines
29 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;
|
|
|
|
/// **Puissance MESURÉE par domaine de charge** — `'ecs'`, `'heating'` — issue de
|
|
/// `GetLoadTelemetry.measurement`. Clé absente ou valeur `null` = **non publié**.
|
|
///
|
|
/// ⚠️ Ce paramètre remplace trois ratios en dur (38 %, 18 %, 22 % de la consommation
|
|
/// maison) qui étaient posés à la maquette et jamais remplacés. Le 2026-09-06, avec de
|
|
/// vrais compteurs ECS et PAC **à 0 W**, l'écran affichait toujours 38 % et 18 % de la
|
|
/// maison : des chiffres crédibles, marqués « estimé », et faux — exactement ce que
|
|
/// `source: "none"` existe pour empêcher, à l'envers.
|
|
final Map<String, double?> mesuresParDomaine;
|
|
|
|
const EnergyFlowCard({
|
|
super.key,
|
|
required this.data,
|
|
this.evChargerPowerW,
|
|
this.mesuresParDomaine = const {},
|
|
});
|
|
|
|
@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,
|
|
widget.mesuresParDomaine);
|
|
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,
|
|
mesuresParDomaine: widget.mesuresParDomaine,
|
|
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(
|
|
'Chaque branche est une mesure de la charge, ou « non publié ». '
|
|
'Le total de la maison peut donc dépasser la somme affichée.',
|
|
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, Map<String, double?> mesures) {
|
|
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.
|
|
// Plus aucun ratio : une branche ne se dessine que si sa puissance est MESURÉE.
|
|
// Une branche absente dit « non publié » ; une branche à 38 % de la maison disait
|
|
// « voilà ce que consomme votre PAC », ce que personne n'avait mesuré.
|
|
return [
|
|
FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: mesures['heating'] ?? 0),
|
|
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: mesures['ecs'] ?? 0),
|
|
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0),
|
|
];
|
|
|
|
case _DrillView.prod:
|
|
// La production n'était PAS décomposée : elle était COUPÉE EN DEUX, et les deux
|
|
// moitiés présentées comme « Onduleur 1 » et « Onduleur 2 » — avec l'état « ok »,
|
|
// donc sans même la marque « estimé » que portaient les branches de consommation.
|
|
// Le banc n'a qu'un seul compteur de production : les deux onduleurs n'existaient
|
|
// nulle part. Tant que la box ne publie pas de détail par source, il n'y a rien à
|
|
// dessiner ici.
|
|
return const [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
/// Mesures par domaine — voir [EnergyFlowCard.mesuresParDomaine].
|
|
final Map<String, double?> mesuresParDomaine;
|
|
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.mesuresParDomaine,
|
|
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;
|
|
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),
|
|
),
|
|
)),
|
|
// « non publié » plutôt qu'un pourcentage de la maison. Un compteur ECS à 0 W est
|
|
// une MESURE — « rien ne chauffe » — et elle ne doit pas être écrasée par 18 % de la
|
|
// consommation du foyer.
|
|
_at(Offset(0.5, 0.12), ScaleTransition(scale: _stagger(1),
|
|
child: _sat(context, icon: Icons.heat_pump_outlined, color: EtmTokens.heat,
|
|
label: 'PAC', value: _kwOuNonPublie(widget.mesuresParDomaine['heating'])))),
|
|
_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: _kwOuNonPublie(widget.mesuresParDomaine['ecs'])))),
|
|
_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'))),
|
|
// « Autres » était 22 % de la maison — un reste calculé sur des parts inventées,
|
|
// donc inventé lui aussi. Sans mesure par charge exhaustive, il n'y a pas de reste
|
|
// à afficher : il faudrait connaître la somme des parts, et on ne la connaît pas.
|
|
]);
|
|
}
|
|
|
|
/// Une puissance mesurée, ou **« non publié »** — jamais un zéro ni un pourcentage.
|
|
static String _kwOuNonPublie(double? w) =>
|
|
w == null ? 'non publié' : '${(w / 1000).toStringAsFixed(1)} kW';
|
|
|
|
Widget _buildProd(BuildContext context) {
|
|
final d = widget.data;
|
|
final solar = EtmTokens.solarOf(context);
|
|
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',
|
|
// ⚠️ `.abs()` ICI AUSSI. La correction du 2026-09-05 n'avait redressé que le
|
|
// nœud de la vue d'ensemble : le hub du détail production affichait toujours
|
|
// « -1.3 kW », vu sur appareil le 2026-09-06. Une même grandeur rendue à deux
|
|
// endroits, corrigée à un seul — le grep qui a servi cherchait `pvPower.abs`
|
|
// et non `pvPower`, donc il a montré la ligne déjà corrigée et caché l'autre.
|
|
value: '${(d.pvPower.abs() / 1000).toStringAsFixed(1)} kW',
|
|
borderColor: EtmTokens.lineOf(context),
|
|
),
|
|
)),
|
|
// « Onduleur 1 » et « Onduleur 2 » portaient chacun la MOITIÉ de la production, avec
|
|
// l'état « ok ». Deux appareils qui n'existent nulle part — le banc n'a qu'un seul
|
|
// compteur de production. Une décomposition par source demande que la box la
|
|
// publie ; tant qu'elle ne la publie pas, le détail se résume au total, et le dire
|
|
// vaut mieux que de le fabriquer.
|
|
_at(Offset(0.5, 0.86), ScaleTransition(scale: _stagger(1),
|
|
child: Text('Détail par source non publié par cette box',
|
|
textAlign: TextAlign.center,
|
|
style: EtmTokens.sans(
|
|
size: 11.5, color: EtmTokens.mutedOf(context))))),
|
|
]);
|
|
}
|
|
|
|
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),
|
|
// Le nœud est un cercle de taille FIXE, et la valeur n'est pas toujours un
|
|
// nombre : « non lu », « non publié » débordent là où « 1.2 kW » tenait. Un
|
|
// débordement de 7 px, mesuré en test — donc du texte coupé sur l'appareil.
|
|
// `scaleDown` réduit ce qu'il faut et ne touche à rien quand la place suffit.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(value,
|
|
maxLines: 1,
|
|
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;
|
|
}
|