etm-powersync-app/lib/widgets/load_cards.dart
Patrick Schurig e362fb7430 chore(telemetry): l'alias measuredW est retiré — +etm26 est sur .75
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
2026-08-28 15:32:02 +02:00

725 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.funding == 'grid') ...[
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.',
'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';
}