etm-powersync-app/lib/widgets/load_cards.dart
Patrick Schurig 3d2aab2b42 fix(config): les écrans suivent enfin la maquette — sélecteurs, table d'états, budget, borne
Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief
seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais
suivies.

ÉCRANS DE MÉCANISME, refaits :
- sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload).
  Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide
  qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things
  déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un
  ThingId configuré qui n'existe plus sur la box ;
- routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers
  atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais
  enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse
  était une sur-interprétation du brief ;
- SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient
  éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les
  étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3)
  et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ;
- modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre
  charge puisque l'identifiant EST le ThingId ;
- temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ;
- « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et
  inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ;
- « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait
  vraiment selon le mécanisme ;
- validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent,
  maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config
  invalide condamne l'installateur à un refus muet qui perd tout l'appel.

LISTE :
- l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans
  eux « Alloué 3 000 W » ne se rattache à rien ;
- le rang passe en pastille près de la poignée, et le sous-titre devient lisible :
  « Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ;
- section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc
  NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte.

MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une
redirection, donc sur le même écran sous deux noms.

Tests 80/80, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:27:24 +02:00

396 lines
15 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 '../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<String>(
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<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 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),
),
),
],
),
);
}
}
// ══════════════════════════════════════════════════════════════════════════════
// 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)),
),
]);
}
}