/// 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( 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), // ⚠️ **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 _retirer() async { final provider = context.read(); 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( 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 _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); }