etm-powersync-app/lib/widgets/load_cards.dart
Patrick Schurig 2792e07265 fix(télémétrie): funding a quitté la charge en +etm30 — le lire dans levels[]
R2 est livrée côté moteur (1.15.2+etm30 sur .75) : `funding` ne figure plus à la
racine d'une entrée de `loads[]`, il vit dans `levels[]`, un par passe. La rupture
est effective, pas seulement contractuelle — `entry.funding` est désormais `null`
partout, donc la mention « servie au réseau » avait disparu de la carte et une
charge achetée au réseau aurait été comptée comme surplus dans l'identité. Une
panne muette : rien n'échoue, tout se lit de travers.

- `LoadLevel` — une passe : `level`, `targetW` (du DÉCIDÉ, comme `allocatedW`),
  `funding` ;
- `resolvedFunding` / `isGridFunded` résolvent les DEUX formes ; `levels[]` vide
  = box antérieure, pas « aucune passe » ;
- `'mixed'` est NOMMÉ plutôt que replié : impossible aujourd'hui (une seule passe
  publiée), courant au §10, et l'affirmer d'un côté serait une demi-vérité ;
- `surplusShareW` somme les `targetW` financés au surplus, l'exception écrite
  `EV_GRID_START` (part de surplus = `params.budgetW`) passant avant.

Vérifié à la sonde contre `.75`, pas en test : 3 500 + 3 000 == 6 500 ==
`budget.allocatedW` au cycle 06:10, puis 1 500 == 1 500 à 06:14. Le niveau publié
est « comfort », et c'est déjà le bon nom.

Aucun écran à deux niveaux n'est commencé : le reste de la maquette attend R1 et
R3 à R8, et le §10 n'est pas implémenté.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:16:29 +02:00

728 lines
30 KiB
Dart

/// Briques d'affichage d'une charge pilotable, **partagées** par les écrans de
/// configuration.
///
/// Extraites de l'ancien écran « Ordre de service » au moment de sa fusion dans
/// « Rôles & appareils ». C'est la moitié de l'écran clair qu'il fallait sauver : le
/// chemin de données correct et l'affichage fidèle de la télémétrie. Les dupliquer
/// aurait laissé deux rendus du même contrat diverger.
///
/// Règle qui traverse tout ce fichier : **des codes, jamais des libellés en dur.**
/// `domain`, `adapter`, `decision.code`, `faultCode` sont des clés ARB ; un domaine
/// inconnu de cette version d'app (box plus récente) tombe sur un repli lisible qui
/// montre le code brut, jamais une phrase vide.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n_ext.dart';
import '../models/load_config_entry.dart';
import '../models/load_telemetry.dart';
import '../providers/load_config_provider.dart';
import '../providers/load_telemetry_provider.dart';
import '../services/load_priority.dart';
import '../services/telemetry_text.dart';
import '../theme/etm_tokens.dart';
/// Libellé d'un domaine. Un domaine inconnu de cette version d'app (box plus récente)
/// a un repli lisible : le code brut, jamais une phrase vide.
String domainLabel(L10n t, String domain) => switch (domain) {
'ev' => t.domainEv,
'ecs' => t.domainEcs,
'hvac' => t.domainHvac,
'heating' => t.domainHeating,
'battery' => t.domainBattery,
kUnclassifiedDomain => t.sectionUnclassified,
_ => t.domainUnknown(domain),
};
// ── Choix du domaine ─────────────────────────────────────────────────────────
/// Choix du domaine — énumération fermée côté plugin, donc liste fermée ici.
class DomainMenu extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const DomainMenu({super.key, required this.entry, required this.provider});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final current = entry.domain ?? kUnclassifiedDomain;
final classe = entry.domain == null;
// Un chip qui PORTE son libellé, pas une icône muette.
//
// Il n'affichait qu'une étiquette dessinée : le domaine d'une charge était donc
// illisible sur sa carte, alors que c'est lui qui décide de son groupe et de son
// rang. Et la couleur était figée en clair — le chip restait pâle en thème sombre.
return PopupMenuButton<String>(
tooltip: t.domainAssignAction,
onSelected: (d) => provider.assignDomain(entry.id, d),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: classe
? Colors.transparent
: EtmTokens.brand.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: classe
? EtmTokens.lineOf(context)
: EtmTokens.brand.withValues(alpha: 0.35),
),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.label_outline_rounded,
size: 14,
color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand),
const SizedBox(width: 5),
Text(
domainLabel(t, current),
style: TextStyle(
fontSize: 11.5,
fontWeight: classe ? FontWeight.w400 : FontWeight.w600,
color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down_rounded,
size: 16,
color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand),
]),
),
itemBuilder: (c) => [
PopupMenuItem<String>(
enabled: false,
child: Text(t.domainAssignTitle(entry.label),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
),
const PopupMenuDivider(),
for (final d in kAssignableDomains)
CheckedPopupMenuItem<String>(
value: d,
checked: current == d,
child: Text(domainLabel(t, d)),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: kUnclassifiedDomain,
child: Text(t.domainAssignNone),
),
],
);
}
}
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
class LoadLiveBlock extends StatelessWidget {
final LoadTelemetryEntry live;
final bool stale;
final LoadTelemetryProvider telemetry;
const LoadLiveBlock({
super.key,
required this.live,
required this.stale,
required this.telemetry,
});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final mech = live.mechanism;
final lock = live.lock;
// Depuis `+etm23`, l'arbitre est le SEUL à commander une borne : l'allocation
// publiée est bien un ordre, et la mise en garde qui la présentait comme une simple
// intention est retirée.
//
// Ce qui reste vrai, et qui ne doit pas se perdre dans la levée de la réserve :
// commander n'est pas obtenir. Un plafond matériel, un véhicule qui refuse, un câble
// débranché font diverger l'ordre et la réalité. La borne garde donc sa ligne d'état
// matériel — pas parce qu'un autre commandeur la contredirait, mais parce que c'est
// le seul endroit où cet écart-là se lit.
final borne = mech?.isEvCharger == true;
// La ligne de mécanisme redirait le courant déjà donné par l'état réel d'une borne.
final mechText =
(mech == null || borne) ? null : mechanismText(t, mech);
// Les données anciennes restent affichées — les masquer priverait du seul état
// connu — mais grisées : elles décrivent un cycle, pas l'instant présent.
final tone = stale ? const Color(0xFF9CA3AF) : const Color(0xFF374151);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Ce que la borne FAIT, à côté de ce qui lui a été commandé ───────
//
// `chargingEnabled` / `currentA` / `pluggedIn` viennent du Thing. L'allocation
// vient de l'arbitre, qui est désormais seul à commander — mais l'ordre et son
// effet restent deux choses, et c'est ici que l'écart se voit.
if (borne) ...[
Row(children: [
Icon(Icons.ev_station_rounded, size: 14, color: tone),
const SizedBox(width: 4),
Flexible(
child: Text(_etatBorne(t, mech),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
),
]),
const SizedBox(height: 2),
],
Row(children: [
Icon(Icons.bolt_rounded,
size: 14,
color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)),
const SizedBox(width: 4),
Text(t.telemetryAllocated(live.allocatedW.round()),
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
if (mechText != null) ...[
const SizedBox(width: 6),
Flexible(
child: Text('· $mechText',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: tone)),
),
],
]),
// La MESURE, à côté de l'allocation — jamais à sa place.
//
// L'une est ce que le waterfall a décidé, l'autre ce qui passe réellement.
// Commandé 3 000 W, mesuré 0 W : la charge ne fait pas ce qu'on lui demande, et
// c'est la seule chose que la mesure autorise à conclure. Une charge sans compteur
// n'affiche RIEN — un zéro ferait passer l'absence de mesure pour une absence de
// consommation.
// ── LA MESURE, et ce que sa SOURCE autorise à en conclure ──────────
//
// Le bloc est masqué sous `none` — un état positif : « cette charge n'est pas
// mesurable ». Afficher un vide, ou pire un zéro, confondrait ce cas avec un
// compteur en panne. Et surtout : ne PAS aller chercher une puissance nominale
// dans `mechanism` pour combler — elle produirait un écart identiquement nul à
// tous les cycles, c'est-à-dire un « tout concorde » permanent qui ne signale
// plus rien.
if (live.measurement.mesuree) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.speed_rounded, size: 13, color: tone),
const SizedBox(width: 4),
// `{device, 0}` est une MESURE, pas une absence : une borne branchée qui ne
// charge pas mesure zéro watt, et c'est une information.
Text('Mesuré ${live.measurement.powerW!.round()} W',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
const SizedBox(width: 6),
Flexible(
child: Text(
_ecartMesure(live),
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tone),
),
),
]),
if (_porteeMesure(live.measurement) != null) ...[
const SizedBox(height: 1),
Text(_porteeMesure(live.measurement)!,
style: TextStyle(fontSize: 10.5, height: 1.25, color: tone)),
],
],
const SizedBox(height: 2),
Text(decisionText(t, live.decision),
style: TextStyle(fontSize: 11, color: tone)),
// Financement — une charge servie au RÉSEAU n'est pas comptée au budget de
// surplus. Sans cette mention, l'utilisateur voit une charge servie alors que le
// surplus est nul, et cherche un défaut.
if (live.isGridFunded) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.power_rounded, size: 13, color: tone),
const SizedBox(width: 4),
Flexible(
child: Text(t.telemetryFundedByGrid,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tone)),
),
]),
],
if (lock != null) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.lock_clock_rounded, size: 13, color: tone),
const SizedBox(width: 4),
Flexible(
child: Text(
// Les secondes affichées datent du cycle publié : l'app ne les décompte
// pas, le plugin exclut volontairement ce champ de sa détection de
// changement.
'${lockText(t, lock)} (${t.lockAsOfCycle})',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: tone),
),
),
]),
],
if (live.inFault) ...[
const SizedBox(height: 6),
LoadFaultBox(live: live, telemetry: telemetry),
],
],
);
}
}
/// Charge en défaut, et levée du verrou.
///
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
/// de la télémétrie relue.
/// Charge en défaut, et levée du verrou.
///
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
/// de la télémétrie relue.
class LoadFaultBox extends StatelessWidget {
final LoadTelemetryEntry live;
final LoadTelemetryProvider telemetry;
const LoadFaultBox({super.key,required this.live, required this.telemetry});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final state = telemetry.clearStateOf(live.loadId);
final code = live.faultCode;
// « L'appareil n'existe plus » n'est pas un défaut de charge comme les autres : aucune
// action de l'app ne peut y remédier, et la seule sortie passe par nymea.
final thingAbsent = code == 'THING_MISSING';
final (String? message, Color messageColor) = switch (state) {
FaultClearState.pending => (t.faultClearPending, const Color(0xFF6B7280)),
FaultClearState.cleared => (t.faultCleared, const Color(0xFF2E7D32)),
FaultClearState.stillFaulty => (t.faultClearStillFaulty, const Color(0xFFC62828)),
FaultClearState.rejected => (
t.faultClearRejected(telemetry.clearErrorOf(live.loadId) ?? '?'),
const Color(0xFFC62828)
),
FaultClearState.idle => (null, Colors.transparent),
};
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFFDECEA),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.error_outline_rounded, size: 15, color: Color(0xFFC62828)),
const SizedBox(width: 6),
Text(t.faultTitle,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFFC62828))),
]),
const SizedBox(height: 4),
Text(code == null ? t.faultUnknown('—') : faultText(t, code),
style: const TextStyle(fontSize: 11, color: Color(0xFF7F1D1D))),
const SizedBox(height: 4),
// ── L'APPAREIL A DISPARU : il n'y a rien à lever ────────────────────
//
// `ClearLoadFault` répondrait `EnergyErrorNoError` — l'ack ne prouve rien — et
// le défaut reviendrait au cycle suivant, puisque aucun appareil ne répond. Un
// bouton qui ne peut pas tenir sa promesse est pire que pas de bouton : il fait
// douter de la box au lieu de désigner la cause.
//
// Le cas est banal, pas exotique : une borne remplacée. Le plugin crée d'office
// une entrée `LoadConfig` par borne détectée (`+etm23`) mais ne la retire pas
// quand le Thing s'en va — constaté sur `.75` avec une ABB Terra AC remplacée
// par une V2C, dont l'entrée occupait encore un rang deux jours plus tard.
if (thingAbsent) ...[
Text(t.faultThingMissingAction,
style: const TextStyle(fontSize: 10.5, color: Color(0xFF7F1D1D))),
const SizedBox(height: 6),
_BoutonRetirer(loadId: live.loadId),
] else ...[
Text(t.faultClearHint,
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
if (message != null) ...[
const SizedBox(height: 6),
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
],
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: state == FaultClearState.pending
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.lock_open_rounded, size: 16),
label: Text(t.faultClearAction),
onPressed: state == FaultClearState.pending
? null
: () => telemetry.clearFault(live.loadId),
),
),
],
],
),
);
}
}
/// **Retirer une charge dont l'appareil n'existe plus** — le seul geste destructeur.
///
/// Il n'apparaît que sur `THING_MISSING`, et c'est délibéré : sur une charge vivante, la
/// suppression n'aurait pas de sens, le plugin recréant d'office l'entrée d'une borne
/// détectée au cycle suivant. Proposer un bouton qui se défait tout seul ferait douter de
/// la box.
///
/// La confirmation dit ce qui va être écrit, pas seulement ce qui va disparaître :
/// `SetLoadConfig` remplace **tout** l'ensemble, donc retirer une charge réécrit les
/// autres. C'est la seule chose que l'utilisateur ne peut pas deviner de l'écran.
class _BoutonRetirer extends StatefulWidget {
final String loadId;
const _BoutonRetirer({required this.loadId});
@override
State<_BoutonRetirer> createState() => _BoutonRetirerState();
}
class _BoutonRetirerState extends State<_BoutonRetirer> {
bool _enCours = false;
Future<void> _retirer() async {
final provider = context.read<LoadConfigProvider>();
final messenger = ScaffoldMessenger.of(context);
// Un brouillon en cours emporterait des modifications que personne n'a demandé
// d'écrire : on le dit AVANT d'ouvrir la confirmation, pas après.
if (provider.hasPendingChanges) {
messenger.showSnackBar(const SnackBar(
content: Text('Enregistrez ou annulez vos modifications en cours avant de '
'retirer une charge : la suppression réécrit toute la configuration.'),
));
return;
}
final entree = provider.entries
.where((e) => e.id == widget.loadId)
.map((e) => e.label)
.firstOrNull;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Retirer cette charge ?'),
content: Text(
'${entree ?? widget.loadId} sera retirée de la configuration de la box.\n\n'
'L\'appareil n\'existe plus dans nymea : rien ne sera recréé. Si vous le '
'réinstallez plus tard, une nouvelle entrée apparaîtra d\'elle-même pour une '
'borne détectée.\n\n'
'La box réécrit TOUTE la configuration en une fois : les autres charges sont '
'réenvoyées à l\'identique, telles qu\'elle les porte en ce moment.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC62828)),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Retirer'),
),
],
),
);
if (ok != true || !mounted) return;
setState(() => _enCours = true);
final err = await provider.removeLoad(widget.loadId);
if (!mounted) return;
setState(() => _enCours = false);
// La suppression relit et compare elle-même : le message dit ce que la box PORTE
// ensuite, pas ce qu'elle a acquitté.
final ecarts = provider.removalDiff;
messenger.showSnackBar(SnackBar(
duration: Duration(seconds: err == null ? 4 : 10),
backgroundColor: err == null ? null : const Color(0xFF7F1D1D),
content: Text(err == null
? 'Charge retirée. Relu sur la box : les autres sont identiques clé par clé, '
'rangs compris.'
: err == 'Diverged'
? 'Charge retirée, MAIS la box a changé autre chose :\n'
'${ecarts.take(4).join('\n')}'
: _messageErreur(err)),
));
}
String _messageErreur(String code) => switch (code) {
'PendingDraft' =>
'Modifications en cours : enregistrez-les ou annulez-les d\'abord.',
'NotFound' =>
'Cette charge n\'est plus dans la configuration de la box — rien à retirer.',
'NoService' => 'Aucune connexion à la box.',
'TiedRanks' =>
'Deux charges partagent un rang, et la box refuse alors toute la configuration '
'(1.15.2+etm27). Classez-les d\'abord — rien n\'a été envoyé.',
'RereadFailed' =>
'Suppression envoyée, mais impossible de relire la configuration pour le '
'vérifier. Rouvrez l\'écran avant tout autre geste.',
_ => 'La box a refusé la suppression ($code). Rien n\'a été modifié.',
};
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: _enCours
? const SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.delete_outline_rounded, size: 16),
label: const Text('Retirer cette charge'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC62828),
side: const BorderSide(color: Color(0xFFC62828)),
minimumSize: const Size(0, 44),
),
onPressed: _enCours ? null : _retirer,
),
);
}
}
// ══════════════════════════════════════════════════════════════════════════════
// En-tête d'arbitrage — repris de l'écran « Ordre de service » à sa disparition.
//
// Il avait été perdu à la fusion, et c'est un manque qui se voit : sans le budget du
// cycle, les allocations affichées sur chaque carte ne se rattachent à rien. « Alloué
// 3 000 W » ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à
// distribuer.
// ══════════════════════════════════════════════════════════════════════════════
class TelemetryHeader extends StatelessWidget {
final LoadTelemetryProvider telemetry;
const TelemetryHeader({super.key, required this.telemetry});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final snapshot = telemetry.telemetry;
if (snapshot == null) {
return telemetry.loading
? const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(minHeight: 2))
: const SizedBox.shrink();
}
final age = telemetry.displayedAge;
final stale = telemetry.stale;
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
stale ? Icons.history_toggle_off_rounded : Icons.sensors_rounded,
size: 20,
color: stale ? const Color(0xFFB26A00) : const Color(0xFF2E7D32),
),
const SizedBox(width: 8),
Expanded(
child: Text(t.telemetryTitle,
style:
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
),
]),
const SizedBox(height: 8),
// Fraîcheur — un arbitre figé ne doit pas ressembler à une installation
// stable. L'absence de cycle est dite pour elle-même, jamais rendue par
// un âge de zéro.
if (snapshot.hasNeverRun)
_Note(
icon: Icons.hourglass_empty_rounded,
color: const Color(0xFFB26A00),
text: t.telemetryNeverRan)
else if (stale)
_Note(
icon: Icons.warning_amber_rounded,
color: const Color(0xFFB26A00),
text: t.telemetryStale(age?.inSeconds ?? 0))
else
_Note(
icon: Icons.schedule_rounded,
color: const Color(0xFF6B7280),
text: t.telemetryLastCycle(age?.inSeconds ?? 0)),
if (snapshot.degradedMode) ...[
const SizedBox(height: 6),
_Note(
icon: Icons.report_gmailerrorred_rounded,
color: const Color(0xFFC62828),
text: t.telemetryDegradedMode),
],
const SizedBox(height: 12),
Text(t.telemetryBudgetTitle,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Color(0xFF6B7280))),
const SizedBox(height: 6),
// `budget` absent = repli L2 : il n'existe AUCUN budget pour ce cycle.
// Afficher des zéros se lirait « arbitrage exécuté, rien à allouer ».
if (snapshot.budget == null)
Text(t.telemetryBudgetAbsent,
style: const TextStyle(fontSize: 12, color: Color(0xFFC62828)))
else
_BudgetTable(budget: snapshot.budget!),
],
),
),
);
}
}
class _BudgetTable extends StatelessWidget {
final TelemetryBudget budget;
const _BudgetTable({required this.budget});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final rows = <(String, double)>[
(t.budgetSurplus, budget.surplusW),
(t.budgetEvReserved, budget.evReservedW),
(t.budgetRecredited, budget.recreditedW),
(t.budgetAllocated, budget.allocatedW),
(t.budgetRemaining, budget.remainingW),
];
return Column(
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(children: [
Expanded(
child: Text(label,
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
),
Text(t.watts(value.round()),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()])),
]),
),
],
);
}
}
// ── Un domaine et ses charges ────────────────────────────────────────────────
/// Ligne d'information de l'en-tête d'arbitrage.
class _Note extends StatelessWidget {
final IconData icon;
final Color color;
final String text;
const _Note({required this.icon, required this.color, required this.text});
@override
Widget build(BuildContext context) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
),
]);
}
}
/// État RÉEL d'une borne, lu dans le Thing — jamais déduit du motif d'arbitrage.
///
/// L'ordre des tests n'est pas indifférent. « Débranchée » prime sur « ne charge pas » :
/// les deux sont vrais, mais le premier dit ce qu'il faut faire. Et `chargingEnabled`
/// absent ne devient PAS « ne charge pas » — le motif d'arbitrage pourrait le contredire,
/// et une absence présentée comme un état est exactement ce que ce bloc existe pour
/// éviter.
String _etatBorne(L10n t, LoadMechanism? mech) {
if (mech == null) return t.telemetryEvStateUnknown;
if (mech.pluggedIn == false) return t.telemetryEvUnplugged;
final on = mech.chargingEnabled;
if (on == null) return t.telemetryEvStateUnknown;
if (!on) return t.telemetryEvIdle;
final a = mech.currentA;
final ph = mech.phaseCount;
// Charge en cours sans courant publié : on dit qu'elle charge, sans inventer combien.
if (a == null || ph == null) return t.telemetryEvIdle;
return t.telemetryEvCharging(a, ph);
}
/// Commentaire d'écart entre commandé et mesuré, quand il y a de quoi le dire.
///
/// Volontairement prudent : un écart de mesure **n'est pas** un défaut d'écriture.
/// `WRITE_FAILED` dit « l'échelle de commande est épuisée » ; un écart dit « la commande
/// est partie, l'effet n'est pas là ». Deux causes, deux gestes chez le client — l'une
/// envoie au bus, l'autre au disjoncteur divisionnaire ou à la résistance. L'app constate,
/// elle ne diagnostique pas.
/// **Ce que la source autorise à conclure** — dit à l'écran, parce que ça change le sens
/// de ce qui est affiché juste au-dessus.
///
/// L'asymétrie n'est pas « le compteur mesure mieux » : sous `device`, une **concordance**
/// ne prouve rien, plusieurs wallbox renvoyant leur consigne en guise de puissance. Elle
/// détecte la désobéissance, jamais le mensonge. Sous `meter`, la mesure est indépendante
/// de l'organe commandé — la concordance devient une preuve, et un écart ouvre trois
/// hypothèses (charge, compteur, câblage) qu'il ne tranche pas.
///
/// `null` sous `inconnue` : une box plus récente que l'app publie une source qu'on ne sait
/// pas interpréter, et inventer une portée serait pire que se taire.
String? _porteeMesure(LoadMeasurement m) => switch (m.source) {
MeasurementSource.meter =>
'Compteur dédié : mesure indépendante de la commande — une concordance est une '
'preuve, un écart met en cause la charge, le compteur ou le câblage.',
MeasurementSource.device =>
'Mesure publiée par l\'appareil : un écart montre qu\'il n\'obéit pas, mais une '
'concordance ne prouve rien — certaines bornes renvoient leur consigne.',
MeasurementSource.none || MeasurementSource.inconnue => null,
};
String _ecartMesure(LoadTelemetryEntry live) {
final m = live.measurement.powerW;
if (!live.measurement.mesuree || m == null) return '';
final a = live.allocatedW;
if (a <= 0) return m > 20 ? '· alloué 0 W' : '';
final ecart = (m - a).abs();
// 15 % ou 100 W : en dessous, c'est la latence du compteur, pas un écart.
if (ecart < 100 || ecart / a < 0.15) return '· conforme';
return m < a ? '· sous la consigne' : '· au-dessus de la consigne';
}