Le geste sans repli de cet écran — déplacer une plage — puis le geste inverse laissaient un brouillon qui rend exactement la configuration de la box. L'écran le traitait comme n'importe quel brouillon, et n'offrait plus pour en sortir que des portes mal nommées. Quatre messages, tous faux au même instant : la barre « Modifications en attente » la sortie d'écran « vos changements n'ont pas été envoyés » adopter un rang « enregistrez ou annulez vos modifications » retirer une charge idem Il n'y avait plus rien à enregistrer, ni rien à perdre. `draftIsNoOp` nomme l'état : un brouillon dont les charges utiles brutes, clés triées, sont identiques à celles de la box — ce qui couvre l'ordre ET un patch de champ. `hasSubstantiveChanges` garde désormais les trois portes ; la barre, elle, continue de s'afficher, parce que c'est là qu'est la sortie. Elle dit maintenant « L'ordre est revenu à celui de la box — il n'y a rien à enregistrer » et n'offre qu'un geste, « Terminer ». Pas « Enregistrer » : réécrire tout l'ensemble pour n'y rien changer reconstruirait les charges et réarmerait les verrous, au prix de zéro. ⚠️ Le remède n'est PAS d'effacer le brouillon dès qu'il devient sans effet : la barre disparaîtrait sous le doigt à l'instant où l'utilisateur repose la plage, et « j'ai décidé » redeviendrait « il ne s'est rien passé ». Un état se conclut, il ne s'évapore pas. Un test le fige. Et la note sur le glissement est REPRISE. J'y affirmais un défaut ; le test au doigt montre que le réordonnancement fonctionne, liste dépliée comprise. Le mécanisme du shrinkWrap reste écrit — il est probablement juste — mais comme une LIMITE CONNUE non reproduite, avec le protocole en trois gestes (A témoin, B discriminant, C cause), le contournement (replier les blocs) et le remède nommé. On ne réécrit pas un écran qui marche sur la foi d'une lecture de code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
811 lines
34 KiB
Dart
811 lines
34 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),
|
|
// ⚠️ **Le parent doit DOMINER ses deux lignes.** Vu sur appareil le 2026-09-06 :
|
|
// à taille et graisse égales, l'allocation et les deux niveaux se lisaient comme
|
|
// trois lignes sœurs — et les pastilles ÉCO/CONFORT, colorées, tiraient même
|
|
// l'œil DAVANTAGE que la commande. C'est le contresens exact que la forme doit
|
|
// empêcher (LM-1003 : deux lignes, une seule décision).
|
|
Text(t.telemetryAllocated(live.allocatedW.round()),
|
|
style: TextStyle(
|
|
fontSize: 13.5, fontWeight: FontWeight.w700, 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),
|
|
..._lignesNiveaux(t, live, 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.hasSubstantiveChanges) {
|
|
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';
|
|
}
|
|
|
|
/// **Les niveaux d'une charge — la décomposition d'UNE commande, pas deux consignes.**
|
|
///
|
|
/// La ligne d'allocation reste le parent ; ces lignes disent d'où vient ce qui a été décidé.
|
|
/// Une charge reçoit **une seule commande par cycle** quelles que soient les passes
|
|
/// (LM-1003) : si l'écran montre deux lignes, elles décrivent une seule décision.
|
|
///
|
|
/// Repli sur le motif de la charge quand aucun niveau ne porte le sien — box antérieure à
|
|
/// `+etm43`. `levels[]` vide ne veut pas dire « aucune passe ».
|
|
List<Widget> _lignesNiveaux(L10n t, LoadTelemetryEntry live, Color tone) {
|
|
final avecMotif = [for (final n in live.levels) if (n.decision != null) n];
|
|
if (avecMotif.isEmpty) {
|
|
return [
|
|
Text(decisionText(t, live.decision),
|
|
style: TextStyle(fontSize: 11, color: tone)),
|
|
];
|
|
}
|
|
return [
|
|
for (final n in avecMotif)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 3),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
// Un filet vertical rattache les niveaux à la commande : ils en sont la
|
|
// décomposition, pas des lignes voisines. La pastille, elle, s'efface — colorée
|
|
// et grasse, elle captait l'œil avant le nombre qu'elle explique.
|
|
Container(
|
|
width: 2,
|
|
height: 30,
|
|
margin: const EdgeInsets.only(left: 2, right: 8, top: 2),
|
|
color: (n.estEco ? const Color(0xFFF9A825) : const Color(0xFF2E7D32))
|
|
.withValues(alpha: 0.35),
|
|
),
|
|
SizedBox(
|
|
width: 52,
|
|
child: Text(n.estEco ? t.niveauEco : t.niveauConfort,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.3,
|
|
color: (n.estEco
|
|
? const Color(0xFFF9A825)
|
|
: const Color(0xFF2E7D32))
|
|
.withValues(alpha: 0.75))),
|
|
),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(_phraseNiveau(t, n),
|
|
style: TextStyle(fontSize: 11, color: tone)),
|
|
if (ecoProgressText(t, n) case final avancement?)
|
|
Text(avancement,
|
|
style: TextStyle(
|
|
fontSize: 10.5, height: 1.3, color: const Color(0xFF6B7280))),
|
|
// L'ACHETÉ, à côté du livré et jamais à sa place : l'un est la commande du
|
|
// moteur, l'autre une observation. Ils ne coïncident que quand tout va bien,
|
|
// et c'est leur divergence qui informe — d'où l'affichage des deux.
|
|
if ((n.progress?.purchasedWh ?? 0) > 0)
|
|
Text(t.ecoAchete(energieCourte(n.progress!.purchasedWh)),
|
|
style: TextStyle(
|
|
fontSize: 10.5, height: 1.3, color: const Color(0xFF6B7280))),
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
];
|
|
}
|
|
|
|
/// La phrase d'un niveau — et la garde R6, qui **dénonce** au lieu de rendre.
|
|
///
|
|
/// `ECO_FLOOR_MET` sous `unmeasurable` est interdit des deux côtés de la frontière. Si la
|
|
/// combinaison arrivait, la rendre telle quelle annoncerait « tenue » une obligation dont
|
|
/// personne ne peut savoir si elle l'est : servie oui, tenue jamais.
|
|
String _phraseNiveau(L10n t, LoadLevel n) {
|
|
final d = n.decision!;
|
|
if (!n.estEco) return decisionText(t, d);
|
|
if (d.code == 'ECO_FLOOR_MET' && (n.progress?.nonMesurable ?? false)) {
|
|
return t.ecoIncoherenceTenue;
|
|
}
|
|
return ecoDecisionText(t, d);
|
|
}
|