/// 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 '../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( 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( enabled: false, child: Text(t.domainAssignTitle(entry.label), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), ), const PopupMenuDivider(), for (final d in kAssignableDomains) CheckedPopupMenuItem( value: d, checked: current == d, child: Text(domainLabel(t, d)), ), const PopupMenuDivider(), PopupMenuItem( 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))) 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), ), ), ], ], ), ); } } // ══════════════════════════════════════════════════════════════════════════════ // 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'; }