Deux manques trouvés en usage réel sur téléphone, contre .75. ── 1. Retirer une charge dont l'appareil n'existe plus ──────────────────────── Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et l'app savait le dire sans savoir y remédier. Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul ferait douter de la box. Le chemin est celui qui était demandé, et pour la raison qui était donnée : · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis) · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ) · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble) Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne, alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées. Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant qu'un brouillon est en cours — sinon la suppression emporterait des modifications non enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une charge réécrit toutes les autres. ── 2. Le mode de recharge, un seul lieu d'écriture ─────────────────────────── Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne : c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements qui divergent. La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit aller ailleurs pour le corriger. ⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement, et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données). 181 tests (+12), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
679 lines
28 KiB
Dart
679 lines
28 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.
|
|
if (live.measuredW != null) ...[
|
|
const SizedBox(height: 2),
|
|
Row(children: [
|
|
Icon(Icons.speed_rounded, size: 13, color: tone),
|
|
const SizedBox(width: 4),
|
|
Text('Mesuré ${live.measuredW!.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),
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
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);
|
|
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(err == null
|
|
// « Demandée » et non « faite » : l'acquittement du RPC ne vaut pas application,
|
|
// c'est `LoadConfigChanged` qui tranche — et la liste se mettra à jour seule.
|
|
? 'Suppression demandée. La liste se met à jour dès que la box confirme.'
|
|
: _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.',
|
|
_ => '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.
|
|
String _ecartMesure(LoadTelemetryEntry live) {
|
|
final m = live.measuredW;
|
|
if (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';
|
|
}
|