/// 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'; /// 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; return PopupMenuButton( tooltip: t.domainAssignAction, icon: const Icon(Icons.label_outline_rounded, size: 20, color: Color(0xFF6B7280)), onSelected: (d) => provider.assignDomain(entry.id, d), 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 mechText = mech == null ? null : mechanismText(t, mech); final lock = live.lock; // 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: [ 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)), ), ], ]), const SizedBox(height: 2), Text(decisionText(t, live.decision), 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; 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), 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), ), ), ], ), ); } }