etm-powersync-app/lib/screens/energy/load_mechanism_screen.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

1503 lines
53 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/l10n_ext.dart';
import '../../models/load_config_entry.dart';
import '../../providers/load_config_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/load_hardware.dart';
import '../../services/load_priority.dart';
import '../../services/nymea_service.dart';
import '../../services/rpc_schema.dart';
import '../../theme/etm_tokens.dart';
import '../../widgets/load_cards.dart';
import '../../widgets/schema_gated.dart';
import '../../widgets/thing_picker.dart';
/// **Configurer** une charge — un écran, trois charges utiles qui ne se ressemblent pas.
///
/// Le bouton « Configurer » ne peut pas ouvrir un formulaire unique : un routeur de relais
/// déclare des **contacteurs**, une charge modulable un **plafond**, une PAC SG-Ready un
/// **encodage à quatre états normés**. Ce qui est commun — nom, domaine, temporisations,
/// arbitrage — encadre une section centrale qui, elle, change entièrement.
///
/// ## Tout ce qui se règle ici n'a pas le même coût
///
/// `label`, `domain` et `enabled` passent en **mise à jour sur place**. Tout le reste —
/// contacteurs, table d'états, plafond, temporisations — franchit `sameHardware()` :
/// l'adaptateur est **reconstruit**, ses contacts passent par l'état sûr et ses verrous se
/// réarment à froid. L'écran l'annonce **avant** d'écrire.
///
/// ## Valider avant d'envoyer
///
/// `SetLoadConfig` refuse **en bloc et sans motif** : la règle enfreinte reste au journal
/// de la box. Un formulaire qui laisse partir une configuration invalide condamne donc
/// l'installateur à un refus muet, et fait perdre **toutes** les charges de l'appel. D'où
/// [_valider], qui reprend les règles du plugin côté app.
class LoadMechanismScreen extends StatefulWidget {
final String loadId;
const LoadMechanismScreen({super.key, required this.loadId});
@override
State<LoadMechanismScreen> createState() => _LoadMechanismScreenState();
}
class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
LoadConfigEntry? _entree(LoadConfigProvider p) {
for (final g in p.groups) {
for (final e in g.loads) {
if (e.id == widget.loadId) return e;
}
}
return null;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
final schema = context.watch<SchemaProvider>();
final service = context.watch<NymeaService>();
final e = _entree(provider);
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: Text(e == null ? 'Charge' : 'Configurer · ${e.label}'),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
body: e == null
? const Center(child: Text('Charge introuvable dans la configuration.'))
: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
SchemaVersionBanner(
versionLine: schema.versionLine(),
schemaLoaded: schema.schema.loaded,
),
const SizedBox(height: 14),
_Identite(entry: e, provider: provider),
const SizedBox(height: 14),
_Mecanisme(entry: e),
const SizedBox(height: 14),
if (e.isRelayRouter)
_SectionRelais(entry: e, provider: provider, service: service)
else if (e.isSgReady)
_SectionSgReady(entry: e, provider: provider, service: service)
else
_SectionModulable(
entry: e, provider: provider, service: service),
const SizedBox(height: 14),
if (e.isSgReady)
_Temporisation(
titre: 'Anti court-cycle',
sousTitre: 'Durée avant tout changement d\'état.',
lignes: [
_LigneDuree(
libelle: 'Maintien d\'état minimal',
aide: 'produit le verrou LOCK_MIN_STATE_HOLD',
valeur: e.minStateHoldS,
onChange: (v) => _majSgReady(provider, e, hold: v),
),
],
)
else
_Temporisation(
titre: 'Temporisations',
sousTitre:
'Ces deux durées produisent les verrous que la carte de la '
'charge affiche en secondes restantes.',
lignes: [
_LigneDuree(
libelle: 'Maintien ON minimal',
aide: 'anti court-cycle à l\'enclenchement',
valeur: e.minOnS,
onChange: (v) => provider.replaceEntry(
e.id, e.patched(minOnS: v)),
),
_LigneDuree(
libelle: 'Maintien OFF minimal',
aide: 'délai avant réenclenchement',
valeur: e.minOffS,
onChange: (v) => provider.replaceEntry(
e.id, e.patched(minOffS: v)),
),
],
),
const SizedBox(height: 14),
_CompteurDedie(schema: schema.schema),
const SizedBox(height: 14),
_Arbitrage(entry: e, provider: provider),
const SizedBox(height: 18),
_BarreEnregistrement(
entry: e, provider: provider, service: service),
],
),
);
}
void _majSgReady(LoadConfigProvider p, LoadConfigEntry e, {required int hold}) {
p.replaceEntry(
e.id,
e.patched(sgReady: {
'minStateHoldS': hold,
'states': [for (final s in e.sgReadyStates) s.toMap()],
}),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Identité — nom, domaine
// ═══════════════════════════════════════════════════════════════════════════
class _Identite extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Identite({required this.entry, required this.provider});
@override
State<_Identite> createState() => _IdentiteState();
}
class _IdentiteState extends State<_Identite> {
late final TextEditingController _c =
TextEditingController(text: widget.entry.label);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
return _Carte(
titre: 'Nom de la charge',
badge: 'enregistré · label',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _c,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(isDense: true),
onChanged: (v) {
if (v.trim().isNotEmpty) {
widget.provider.setLabel(widget.entry.id, v.trim());
}
},
),
const SizedBox(height: 6),
_Aide(
'Le seul texte de l\'installation que l\'utilisateur voit dans chaque motif '
'de décision.',
),
const SizedBox(height: 14),
_SousTitre('Domaine', badge: 'enregistré · domain'),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: DomainMenu(entry: widget.entry, provider: widget.provider),
),
const SizedBox(height: 4),
_Aide(
'Métadonnée d\'intention : elle groupe l\'ordre de service. '
'L\'arbitre ne la lit pas — ${domainLabel(t, widget.entry.domain ?? kUnclassifiedDomain)}.',
),
],
),
);
}
}
/// Rappelle quel mécanisme porte cette charge — et pourquoi il ne se change pas ici.
class _Mecanisme extends StatelessWidget {
final LoadConfigEntry entry;
const _Mecanisme({required this.entry});
@override
Widget build(BuildContext context) {
const noms = {
'relay-router': 'Routeur de relais',
'etmvariableload': 'Modulable',
'sg-ready': 'SG-Ready',
};
return _Carte(
titre: 'Mécanisme',
badge: 'enregistré · adapter',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
for (final e in noms.entries)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 11, vertical: 7),
decoration: BoxDecoration(
color: e.key == entry.adapter
? EtmTokens.brand.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: e.key == entry.adapter
? EtmTokens.brand.withValues(alpha: 0.5)
: EtmTokens.lineOf(context)),
),
child: Text(e.value,
style: TextStyle(
fontSize: 12,
fontWeight: e.key == entry.adapter
? FontWeight.w700
: FontWeight.w400,
color: e.key == entry.adapter
? EtmTokens.brand
: EtmTokens.mutedOf(context),
)),
),
),
],
),
const SizedBox(height: 8),
// Changer de mécanisme, ce n'est pas régler une option : c'est remplacer la
// charge utile entière (relays[] ⇄ sgReady ⇄ maxPowerW), que la box refuse de
// voir coexister. Le geste appartient à la création d'une charge, pas à
// l'édition de celle-ci.
_Aide(
'Le mécanisme se choisit à la déclaration de la charge. En changer ici '
'remplacerait toute la charge utile — les trois ne peuvent pas coexister, '
'et la box refuse en bloc.',
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Routeur de relais
// ═══════════════════════════════════════════════════════════════════════════
/// Les contacteurs, et **rien d'autre de matériel**.
///
/// Les paliers atteignables sont **déduits, jamais enregistrés** : la box les compose
/// elle-même en essayant toutes les combinaisons de contacts. On les affiche parce qu'ils
/// sont ce que l'installateur veut vérifier d'un coup d'œil — et on dit qu'ils sont
/// déduits, pour que personne ne les cherche dans la configuration.
class _SectionRelais extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _SectionRelais({
required this.entry,
required this.provider,
required this.service,
});
/// Toutes les puissances atteignables par combinaison de contacts, dédupliquées.
///
/// Miroir de ce que fait le `RelayRouter` : 2^N combinaisons, doublons écartés (deux
/// relais de même puissance ne créent pas de palier supplémentaire), plafond 16 relais.
static List<int> paliers(List<LoadRelay> relais) {
if (relais.isEmpty || relais.length > 16) return const [];
final vus = <int>{0};
for (var masque = 1; masque < (1 << relais.length); masque++) {
var somme = 0;
for (var i = 0; i < relais.length; i++) {
if (masque & (1 << i) != 0) somme += relais[i].powerW;
}
vus.add(somme);
}
return vus.toList()..sort();
}
void _remplacer(List<Map<String, dynamic>> relais) =>
provider.replaceEntry(entry.id, entry.patched(relays: relais));
List<Map<String, dynamic>> get _actuels =>
[for (final r in entry.relays) r.toMap()];
@override
Widget build(BuildContext context) {
final p = paliers(entry.relays);
final pris = {
for (final r in entry.relays) normalizeThingId(r.thingId),
};
return _Carte(
titre: 'Relais commandés',
badge: 'enregistré · relays[]',
sousTitre:
'Un contacteur par ligne — puissance de la résistance câblée derrière.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < entry.relays.length; i++)
_LigneRelais(
index: i,
relais: entry.relays[i],
service: service,
dejaPris: pris,
onThing: (id) {
final l = _actuels;
l[i] = {'thingId': id, 'powerW': entry.relays[i].powerW};
_remplacer(l);
},
onPuissance: (w) {
final l = _actuels;
l[i] = {'thingId': entry.relays[i].thingId, 'powerW': w};
_remplacer(l);
},
onSupprimer: entry.relays.length <= 1
? null
: () {
final l = _actuels..removeAt(i);
_remplacer(l);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
_remplacer([..._actuels, {'thingId': '', 'powerW': 0}]),
icon: const Icon(Icons.add_rounded, size: 18),
label: const Text('Ajouter un relais'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.brand),
),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${entry.relays.length} relais → paliers atteignables :',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context)),
),
const SizedBox(height: 3),
Text(
p.isEmpty ? '—' : '${p.join(' · ')} W',
style: TextStyle(
fontSize: 12.5,
fontFamily: 'monospace',
color: EtmTokens.inkOf(context)),
),
const SizedBox(height: 5),
Text(
'Déduit, jamais enregistré. La box compose elle-même les paliers en '
'essayant toutes les combinaisons de contacts ; deux relais de même '
'puissance ne créent pas de palier supplémentaire.',
style: TextStyle(
fontSize: 10.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
const SizedBox(height: 8),
_Aide(
'La puissance saisie est celle de la RÉSISTANCE, pas le calibre du '
'contacteur : rien dans nymea ne dit ce qu\'un relais commande.',
),
],
),
);
}
}
class _LigneRelais extends StatelessWidget {
final int index;
final LoadRelay relais;
final NymeaService service;
final Set<String> dejaPris;
final ValueChanged<String> onThing;
final ValueChanged<int> onPuissance;
final VoidCallback? onSupprimer;
const _LigneRelais({
required this.index,
required this.relais,
required this.service,
required this.dejaPris,
required this.onThing,
required this.onPuissance,
this.onSupprimer,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text('K${index + 1}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
),
const SizedBox(width: 10),
Expanded(
child: ThingPicker(
service: service,
interfaceRequise: 'power',
valeur: relais.thingId.isEmpty ? null : relais.thingId,
dejaPris: dejaPris,
placeholder: 'Choisir un contacteur…',
onChoisi: onThing,
),
),
const SizedBox(width: 10),
SizedBox(
width: 86,
child: TextFormField(
key: ValueKey('w-$index-${relais.thingId}'),
initialValue: '${relais.powerW}',
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
decoration: const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null) onPuissance(n);
},
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.remove_circle_outline, size: 19),
color: EtmTokens.mutedOf(context),
onPressed: onSupprimer,
tooltip: onSupprimer == null
? 'Une charge relais doit garder au moins un contacteur'
: 'Retirer ce contacteur',
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Modulable
// ═══════════════════════════════════════════════════════════════════════════
class _SectionModulable extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _SectionModulable({
required this.entry,
required this.provider,
required this.service,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_Carte(
titre: 'Appareil piloté',
badge: 'enregistré · id',
sousTitre: 'Un seul Thing, portant l\'interface etmvariableload.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// L'identifiant de la charge EST le ThingId : le sélecteur ne peut donc
// pas « changer d'appareil » sur place. On montre lequel est piloté.
ThingPicker(
service: service,
interfaceRequise: 'etmvariableload',
valeur: entry.id,
onChoisi: (_) {},
placeholder: 'Appareil introuvable',
),
const SizedBox(height: 8),
_Aide(
'⚠ Changer d\'appareil, c\'est changer de charge. L\'identifiant de '
'cette charge EST le ThingId piloté : choisir un autre appareil ne la '
'modifie pas, cela en crée une autre et supprime celle-ci — avec son '
'passage par l\'état sûr (consigne 0 W). Le geste se fait donc depuis la '
'liste, pas ici.',
alerte: true,
),
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Puissance nominale',
badge: 'enregistré · maxPowerW',
sousTitre:
'Plafond de modulation — la consigne ira de 0 à cette valeur.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
initialValue: '${entry.maxPowerW}',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null) {
provider.replaceEntry(
entry.id, entry.patched(maxPowerW: n));
}
},
),
const SizedBox(height: 6),
_Aide(
'Requis, et strictement positif : la box refuse une charge modulable '
'sans plafond. C\'est la plaque signalétique de la résistance, pas une '
'estimation — et elle sert au recrédit du budget rendu au reste de la '
'cascade.',
),
],
),
),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SG-Ready
// ═══════════════════════════════════════════════════════════════════════════
/// Les deux contacts, puis la table des quatre états.
///
/// ## « SG1 » et « SG2 » n'existent pas côté box
///
/// `sgReady.states[]` ne stocke qu'une **liste de relais par état**, sans nom ni ordre.
/// Les deux étiquettes sont une convention de cet écran, retrouvée à la lecture :
/// **SG1 = le contact fermé en état 1** (blocage), **SG2 = celui fermé en état 3**
/// (recommandation). C'est l'encodage SG-Ready standard.
///
/// Si la configuration lue ne correspond pas à ce schéma — trois contacts, un état 1 sans
/// contact fermé — l'écran **renonce aux noms** et affiche les relais tels quels. Attribuer
/// une étiquette au hasard serait pire que de ne pas en donner. Le tableau reste éditable ;
/// ce sont les **noms** qui disparaissent, pas la configuration.
class _SectionSgReady extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _SectionSgReady({
required this.entry,
required this.provider,
required this.service,
});
static const _nomsEtats = {
1: ('Blocage', 'arrêt forcé'),
2: ('Normal', 'aucune consigne'),
3: ('Recommandation', 'incitation douce'),
4: ('Forcé', 'marche imposée'),
};
/// Contacts déduits : SG1 depuis l'état 1, SG2 depuis l'état 3.
///
/// `null` quand l'encodage ne suit pas la convention — c'est ce qui fait tomber les
/// étiquettes.
({String? sg1, String? sg2, bool standard}) _contacts() {
String? unSeul(int etat) {
for (final s in entry.sgReadyStates) {
if (s.state == etat) return s.relays.length == 1 ? s.relays.first : null;
}
return null;
}
final sg1 = unSeul(1);
final sg2 = unSeul(3);
final tous = {
for (final s in entry.sgReadyStates) ...s.relays.map(normalizeThingId)
};
final standard = sg1 != null &&
sg2 != null &&
normalizeThingId(sg1) != normalizeThingId(sg2) &&
tous.length == 2;
return (sg1: sg1, sg2: sg2, standard: standard);
}
void _ecrire(List<SgReadyState> etats) {
provider.replaceEntry(
entry.id,
entry.patched(sgReady: {
'minStateHoldS': entry.minStateHoldS,
'states': [for (final s in etats) s.toMap()],
}),
);
}
/// Remplace un contact partout où il apparaît — le choix des Things se fait **une fois,
/// en haut**, pas état par état.
void _majContact(String? ancien, String neuf) {
final etats = [
for (final s in entry.sgReadyStates)
SgReadyState(
state: s.state,
relays: [
for (final r in s.relays)
if (ancien != null && normalizeThingId(r) == normalizeThingId(ancien))
neuf
else
r
],
estimatedPowerW: s.estimatedPowerW,
)
];
_ecrire(etats);
}
/// Coche/décoche un contact pour un état — c'est ça qui devient `relays[]`.
void _basculer(int etat, String contact, bool ferme) {
final etats = [
for (final s in entry.sgReadyStates)
if (s.state != etat)
s
else
SgReadyState(
state: s.state,
relays: ferme
? [
...s.relays.where(
(r) => normalizeThingId(r) != normalizeThingId(contact)),
contact
]
: [
...s.relays.where(
(r) => normalizeThingId(r) != normalizeThingId(contact))
],
estimatedPowerW: s.estimatedPowerW,
)
];
_ecrire(etats);
}
void _majEstimation(int etat, double w) {
final etats = [
for (final s in entry.sgReadyStates)
if (s.state != etat)
s
else
SgReadyState(
state: s.state, relays: s.relays, estimatedPowerW: w)
];
_ecrire(etats);
}
@override
Widget build(BuildContext context) {
final c = _contacts();
final etats = [...entry.sgReadyStates]..sort((a, b) => a.state - b.state);
final tousContacts = <String>[];
for (final s in etats) {
for (final r in s.relays) {
if (!tousContacts.any((x) => normalizeThingId(x) == normalizeThingId(r))) {
tousContacts.add(r);
}
}
}
return Column(
children: [
_Carte(
titre: 'Contacts SG-Ready',
badge: 'déduit · pas de champ dédié',
sousTitre:
'Les deux entrées de la pompe à chaleur, et le relais qui les commande.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (c.standard) ...[
_LigneContact(
etiquette: 'SG1',
service: service,
valeur: c.sg1,
dejaPris: {if (c.sg2 != null) normalizeThingId(c.sg2!)},
onChoisi: (id) => _majContact(c.sg1, id),
),
_LigneContact(
etiquette: 'SG2',
service: service,
valeur: c.sg2,
dejaPris: {if (c.sg1 != null) normalizeThingId(c.sg1!)},
onChoisi: (id) => _majContact(c.sg2, id),
),
const SizedBox(height: 6),
_Aide(
'« SG1 » et « SG2 » n\'existent pas côté box : la configuration ne '
'stocke qu\'une liste de relais par état. Ces deux noms sont une '
'convention de cet écran — SG1 est le contact fermé en état 1, SG2 '
'celui fermé en état 3.',
),
] else ...[
for (var i = 0; i < tousContacts.length; i++)
_LigneContact(
etiquette: 'C${i + 1}',
service: service,
valeur: tousContacts[i],
dejaPris: {
for (final x in tousContacts)
if (normalizeThingId(x) != normalizeThingId(tousContacts[i]))
normalizeThingId(x)
},
onChoisi: (id) => _majContact(tousContacts[i], id),
),
const SizedBox(height: 6),
_Aide(
'⚠ Encodage non standard : les étiquettes SG1/SG2 sont abandonnées et '
'les contacts affichés tels quels. Leur attribuer un nom au hasard '
'serait pire que de n\'en donner aucun — la configuration, elle, reste '
'éditable.',
alerte: true,
),
],
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Table d\'états',
badge: 'enregistré · sgReady.states[]',
sousTitre:
'Quels contacts fermer pour chaque état, et ce que la PAC consomme alors.',
child: Column(
children: [
for (final s in etats)
_LigneEtat(
etat: s,
nom: _nomsEtats[s.state]?.$1 ?? 'État ${s.state}',
detail: _nomsEtats[s.state]?.$2 ?? '',
contacts: tousContacts,
etiquettes: c.standard
? {
normalizeThingId(c.sg1!): 'SG1',
normalizeThingId(c.sg2!): 'SG2',
}
: {
for (var i = 0; i < tousContacts.length; i++)
normalizeThingId(tousContacts[i]): 'C${i + 1}'
},
onBasculer: (contact, ferme) =>
_basculer(s.state, contact, ferme),
onEstimation: (w) => _majEstimation(s.state, w),
),
const SizedBox(height: 4),
_Aide(
'estimatedPowerW est une ESTIMATION, jamais un engagement. Elle sert de '
'base au recrédit de budget et au choix d\'état : une valeur fausse ne '
'décale pas seulement la PAC, elle décale l\'allocation de tout le '
'waterfall en aval. À relever sur la plaque, puis à mesurer.',
),
],
),
),
],
);
}
}
class _LigneContact extends StatelessWidget {
final String etiquette;
final NymeaService service;
final String? valeur;
final Set<String> dejaPris;
final ValueChanged<String> onChoisi;
const _LigneContact({
required this.etiquette,
required this.service,
required this.valeur,
required this.dejaPris,
required this.onChoisi,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: 34,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(etiquette,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
),
const SizedBox(width: 10),
Expanded(
child: ThingPicker(
service: service,
interfaceRequise: 'power',
valeur: valeur,
dejaPris: dejaPris,
placeholder: 'Choisir le relais…',
onChoisi: onChoisi,
),
),
],
),
);
}
}
class _LigneEtat extends StatelessWidget {
final SgReadyState etat;
final String nom;
final String detail;
final List<String> contacts;
final Map<String, String> etiquettes;
final void Function(String contact, bool ferme) onBasculer;
final ValueChanged<double> onEstimation;
const _LigneEtat({
required this.etat,
required this.nom,
required this.detail,
required this.contacts,
required this.etiquettes,
required this.onBasculer,
required this.onEstimation,
});
bool _ferme(String c) =>
etat.relays.any((r) => normalizeThingId(r) == normalizeThingId(c));
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(7),
),
child: Text('${etat.state}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
const SizedBox(width: 8),
Text(nom,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(width: 6),
Expanded(
child: Text(detail,
style: TextStyle(fontSize: 11, color: muted),
overflow: TextOverflow.ellipsis),
),
]),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final c in contacts)
_Bascule(
libelle: etiquettes[normalizeThingId(c)] ?? '?',
ferme: _ferme(c),
onChange: (v) => onBasculer(c, v),
),
],
),
const SizedBox(height: 8),
Row(children: [
Text('Puissance estimée',
style: TextStyle(fontSize: 11.5, color: muted)),
const Spacer(),
SizedBox(
width: 96,
child: TextFormField(
key: ValueKey('est-${etat.state}'),
initialValue: etat.estimatedPowerW.round().toString(),
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
decoration:
const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = double.tryParse(v.trim().replaceAll(',', '.'));
if (n != null && n >= 0) onEstimation(n);
},
),
),
]),
],
),
);
}
}
/// Contact fermé / ouvert pour un état donné.
class _Bascule extends StatelessWidget {
final String libelle;
final bool ferme;
final ValueChanged<bool> onChange;
const _Bascule({
required this.libelle,
required this.ferme,
required this.onChange,
});
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => onChange(!ferme),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: ferme
? EtmTokens.eco.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: ferme
? EtmTokens.eco.withValues(alpha: 0.55)
: EtmTokens.lineOf(context)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(ferme ? Icons.circle : Icons.circle_outlined,
size: 11,
color: ferme ? EtmTokens.eco : EtmTokens.mutedOf(context)),
const SizedBox(width: 5),
Text('$libelle ${ferme ? "fermé" : "ouvert"}',
style: TextStyle(
fontSize: 11.5,
fontWeight: ferme ? FontWeight.w600 : FontWeight.w400,
color: ferme
? EtmTokens.inkOf(context)
: EtmTokens.mutedOf(context))),
]),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Communs : temporisations, compteur, arbitrage, enregistrement
// ═══════════════════════════════════════════════════════════════════════════
class _Temporisation extends StatelessWidget {
final String titre;
final String sousTitre;
final List<_LigneDuree> lignes;
const _Temporisation({
required this.titre,
required this.sousTitre,
required this.lignes,
});
@override
Widget build(BuildContext context) => _Carte(
titre: titre,
badge: 'enregistré',
sousTitre: sousTitre,
child: Column(children: lignes),
);
}
class _LigneDuree extends StatelessWidget {
final String libelle;
final String aide;
final int valeur;
final ValueChanged<int> onChange;
const _LigneDuree({
required this.libelle,
required this.aide,
required this.valeur,
required this.onChange,
});
/// Pas de réglage : 30 s en dessous de 5 min, 60 s au-delà. Une PAC se règle en
/// centaines de secondes, un relais en dizaines.
int get _pas => valeur >= 300 ? 60 : 30;
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(libelle,
style: TextStyle(
fontSize: 13, color: EtmTokens.inkOf(context))),
Text(aide, style: TextStyle(fontSize: 10.5, color: muted)),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.remove_circle_outline, size: 21),
onPressed: valeur <= 0
? null
: () => onChange((valeur - _pas).clamp(0, 86400)),
),
SizedBox(
width: 58,
child: Text('$valeur s',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.add_circle_outline, size: 21),
onPressed: () => onChange((valeur + _pas).clamp(0, 86400)),
),
]),
);
}
}
/// Compteur dédié — **prévu, pas encore supporté**, et l'écran le dit.
///
/// Le champ n'est pas grisé « au cas où » : il est grisé parce que le schéma de la box ne
/// le porte pas, et le grisage se dérive de ce schéma. Inventer `meterThingId` ferait
/// échouer l'enregistrement de **toutes** les charges — nymea rejette toute clé inconnue
/// **avant** le handler, et le refus porte sur l'appel entier.
class _CompteurDedie extends StatelessWidget {
final RpcSchema schema;
const _CompteurDedie({required this.schema});
@override
Widget build(BuildContext context) {
final support = schema.paramSupport(
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'meterThingId']);
return _Carte(
titre: 'Compteur dédié',
badge: 'prévu · pas encore supporté',
sousTitre: 'Rattacher un compteur à cette charge.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaGated(
support: support,
fieldName: 'meterThingId',
label: 'Compteur dédié',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Row(children: [
Expanded(
child: Text('Choisir un appareil de mesure…',
style: TextStyle(
fontSize: 13.5, color: EtmTokens.mutedOf(context))),
),
Icon(Icons.expand_more_rounded,
size: 20, color: EtmTokens.mutedOf(context)),
]),
),
),
const SizedBox(height: 6),
_Aide(
'La box estime la consommation depuis les paliers déclarés, elle ne la '
'mesure pas — c\'est ici que le besoin est le plus fort. Mais aucun champ '
'n\'existe : en inventer un ferait rejeter l\'enregistrement de toutes les '
'charges, pas seulement de celle-ci.',
),
],
),
);
}
}
/// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment.
class _Arbitrage extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Arbitrage({required this.entry, required this.provider});
String get _etatSur => entry.isSgReady
? 'état 2 (normal), jamais le blocage'
: entry.isRelayRouter
? 'tous les relais ouverts'
: 'consigne 0 W';
@override
Widget build(BuildContext context) {
final t = context.l10n;
return _Carte(
titre: 'Arbitrage',
badge: 'enregistré · enabled',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Inclure dans l\'arbitrage',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
Text(
'rang ${entry.priority} · '
'${domainLabel(t, entry.domain ?? kUnclassifiedDomain)}',
style: TextStyle(
fontSize: 11.5, color: EtmTokens.mutedOf(context)),
),
],
),
),
Switch.adaptive(
value: entry.enabled,
onChanged: (v) => provider.setEnabled(entry.id, v),
),
]),
const SizedBox(height: 6),
_Aide(
'À la désactivation, le retrait passe par l\'état sûr : $_etatSur. '
'Vérifié sur matériel côté plugin — mais c\'est le RETRAIT qui protège, pas '
'le drapeau : il n\'agit ni au redémarrage du service, ni sur une charge '
'déjà désactivée.',
),
],
),
);
}
}
/// Valide **avant** d'envoyer, annonce l'impact, puis enregistre.
class _BarreEnregistrement extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _BarreEnregistrement({
required this.entry,
required this.provider,
required this.service,
});
/// Règles que la box applique et **ne motive pas**. Les reprendre ici, c'est la
/// différence entre « corrigez la ligne 2 » et un refus muet qui perd tout l'appel.
List<String> _valider() {
final erreurs = <String>[];
if (entry.label.trim().isEmpty) erreurs.add('Le nom ne peut pas être vide.');
if (entry.isRelayRouter) {
if (entry.relays.isEmpty) {
erreurs.add('Au moins un contacteur est requis.');
}
final vus = <String>{};
for (var i = 0; i < entry.relays.length; i++) {
final r = entry.relays[i];
if (r.thingId.trim().isEmpty) {
erreurs.add('Ligne ${i + 1} : aucun contacteur choisi.');
} else if (!vus.add(normalizeThingId(r.thingId))) {
erreurs.add(
'Ligne ${i + 1} : ce contacteur est déjà utilisé par une autre ligne.');
}
if (r.powerW <= 0) {
erreurs.add('Ligne ${i + 1} : la puissance doit être supérieure à 0 W.');
}
}
} else if (entry.isSgReady) {
final etats = {for (final s in entry.sgReadyStates) s.state};
if (!etats.contains(2)) {
// ECS-110 : sans état 2, la PAC n'est jamais pilotable — la box publie
// UNUSABLE_ENCODING et la charge sort de l'arbitrage définitivement.
erreurs.add(
'L\'état 2 (normal) doit être exprimable, sans quoi la pompe à chaleur '
'ne sera jamais pilotable.');
}
for (final s in entry.sgReadyStates) {
if (s.state < 1 || s.state > 4) {
erreurs.add('État ${s.state} hors plage (1 à 4).');
}
if (s.estimatedPowerW < 0) {
erreurs.add('État ${s.state} : puissance estimée négative.');
}
for (final r in s.relays) {
if (r.trim().isEmpty) {
erreurs.add('État ${s.state} : un contact n\'est pas choisi.');
}
}
}
} else {
if (entry.maxPowerW <= 0) {
erreurs.add(
'La puissance nominale est requise et doit être supérieure à 0 W.');
}
}
return erreurs;
}
@override
Widget build(BuildContext context) {
final erreurs = _valider();
final impacts =
provider.pendingImpact().where((i) => i.loadId == entry.id).toList();
final rebuild = impacts.any((i) => i.impact == LoadChangeImpact.rebuild);
final rearm = impacts
.map((i) => i.rearmSeconds)
.whereType<int>()
.fold<int>(0, (a, b) => a > b ? a : b);
final enCours = provider.saveState == LoadSaveState.pending;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (erreurs.isNotEmpty)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('La box refuserait cette configuration',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: EtmTokens.danger)),
const SizedBox(height: 5),
for (final e in erreurs)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('• $e',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.inkOf(context))),
),
const SizedBox(height: 4),
Text(
'Le refus porterait sur l\'ensemble des charges, sans motif : la règle '
'enfreinte ne serait visible que dans le journal de la box.',
style: TextStyle(
fontSize: 10.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
if (rebuild && erreurs.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _Aide(
'À l\'enregistrement : les contacts de cette charge passent par leur état '
'sûr, l\'adaptateur est reconstruit, et son verrou se réarme'
'${rearm > 0 ? rearm >= 60 ? ' pendant ${(rearm / 60).round()} min' : ' pendant $rearm s' : ''}.',
alerte: true,
),
),
Row(children: [
TextButton(
onPressed: provider.hasPendingChanges ? provider.discardDraft : null,
child: const Text('Annuler'),
),
const Spacer(),
FilledButton(
onPressed: (!provider.hasPendingChanges ||
erreurs.isNotEmpty ||
enCours)
? null
: provider.save,
child: Text(enCours ? 'Envoi…' : 'Enregistrer'),
),
]),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Habillage
// ═══════════════════════════════════════════════════════════════════════════
class _Carte extends StatelessWidget {
final String titre;
final String? badge;
final String? sousTitre;
final Widget child;
const _Carte({
required this.titre,
this.badge,
this.sousTitre,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SousTitre(titre, badge: badge),
if (sousTitre != null) ...[
const SizedBox(height: 3),
Text(sousTitre!,
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.mutedOf(context))),
],
const SizedBox(height: 12),
child,
],
),
);
}
}
class _SousTitre extends StatelessWidget {
final String texte;
final String? badge;
const _SousTitre(this.texte, {this.badge});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(texte,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
),
if (badge != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: EtmTokens.lineOf(context),
borderRadius: BorderRadius.circular(6),
),
child: Text(badge!,
style: TextStyle(
fontSize: 9.5,
fontFamily: 'monospace',
color: EtmTokens.mutedOf(context))),
),
],
],
);
}
}
class _Aide extends StatelessWidget {
final String texte;
final bool alerte;
const _Aide(this.texte, {this.alerte = false});
@override
Widget build(BuildContext context) {
final couleur = alerte ? EtmTokens.orange : EtmTokens.mutedOf(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(alerte ? Icons.warning_amber_rounded : Icons.info_outline,
size: 14, color: couleur),
const SizedBox(width: 6),
Expanded(
child: Text(texte,
style: TextStyle(
fontSize: 11,
height: 1.4,
color: alerte ? EtmTokens.inkOf(context) : couleur)),
),
],
);
}
}