Vérifié par sonde avant de couper : `measuredW` a disparu de la charge utile, `measurement` est en ligne. L'alias n'aura vécu que le temps de l'intervalle qu'il devait couvrir, et `MeasurementSource.legacy` part avec lui. Ce qui reste, `MeasurementSource.inconnue`, ne le remplace pas : il couvre une box PLUS RÉCENTE que l'app — la même règle que partout ailleurs dans ce fichier, un code inconnu tombe sur un repli lisible plutôt que sur une phrase inventée. On affiche la valeur, on ne prétend rien sur ce qu'un écart prouverait. ── La garantie qui manquait à ce que j'avais écrit ────────────────────────── `measurement` est présent pour toute charge de `loads[]`, et pour elle seule. C'est elle qui rend la lecture sûre, et elle est maintenant au contrat : son absence ne peut PAS vouloir dire « pas mesurable » — ça, c'est `none`, un état publié — elle ne peut vouloir dire que « charge hors de loads[] », cas où l'on ne conclut rien. J'avais fait la distinction par prudence sur l'écran ; elle est désormais fondée sur une garantie, pas sur une intuition. ── Les sources du banc ne sont pas celles annoncées, et c'est instructif ──── chauffe-eau meter, 1 500 W porte ECS-Meter pac-terrain meter, 800 W porte PAC-Meter — pas `none` V2C Trydan absente de loads[] (pluggedIn: false) `pac-terrain` en `meter` ne contredit pas LM-1105-b : cette règle décrit ce qu'une PAC publie SANS compteur dédié. On lui en a posé un — ce que le brief recommandait — et le compteur explicite l'emporte toujours. Et la V2C illustre la distinction sur machine : absente, pas `none`. Elle porte `currentPower` et publiera sa mesure au cycle où une voiture y sera branchée. ⚠️ Le test sur appareil exigeait `measuredW == null` après détachement du compteur. C'était vrai avant `+etm26`, c'est faux depuis : le moteur retombe sur l'appareil quand il publie lui-même sa puissance. Ce qui doit cesser, c'est que la source vaille `meter` sans compteur désigné — pas que la mesure disparaisse. CLAUDE.md porte le contrat complet et les deux pièges, avec l'illustration du banc. 205 tests, flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
657 lines
24 KiB
Dart
657 lines
24 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:
|
|
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:
|
|
// ⚠️ 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',
|
|
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',
|
|
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;
|
|
}
|