Marquage, pas refonte. PAC = 38 % de la consommation maison, eau chaude = 18 %, autres
= 22 % : trois ratios posés à la maquette, jamais remplacés, et un seul (« Autres »)
portait le cercle pointillé. Les trois le portent maintenant, leurs flux passent en
pointillé, et une légende sous le schéma nomme ce qui est quoi :
« 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. »
Le trait pointillé seul ne suffisait pas : quatre chiffres en kW se ressemblent tous et se
lisent tous comme des mesures. Nommer les branches évite de faire chercher lesquelles.
Brancher les trois sur GetLoadTelemetry.measuredW reste le vrai lot — la source existe
(chauffe-eau 1500 W, PAC 800 W au relevé du jour), mais c'est une refonte de l'écran
d'accueil et le banc est pris par la campagne V2C.
Quatre tests montés sur la vue, dont deux qui figent ce que la voiture affiche : « non lu »
sans borne — jamais 0,0 kW, qui échangerait une fiction contre une autre — et la mesure
quand il y en a une. Deux pièges de harnais notés dans le fichier : pumpAndSettle est
inutilisable (les particules tournent en boucle, disableAnimations ne change que la valeur
lue), et toucher un nœud à la première trame le rate en silence — les nœuds entrent en
ScaleTransition depuis une échelle nulle.
PREPA_BANC_20260828.md corrigé en parallèle (dépôt plugin) : trois de ses affirmations sont
tombées avec le déploiement du matin.
158 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 `measuredW` 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;
|
|
}
|