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/load_telemetry_provider.dart'; import '../../providers/schema_provider.dart'; import '../../services/load_hardware.dart'; import '../../services/load_mechanism_switch.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 createState() => _LoadMechanismScreenState(); } class _LoadMechanismScreenState extends State { 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(); final schema = context.watch(); final service = context.watch(); final telemetry = context.watch(); 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( key: const ValueKey('liste-mecanisme'), 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, provider: provider), const SizedBox(height: 14), // Une borne ne porte AUCUNE charge utile de mécanisme, et pas non plus // de temporisation : `minOnS`/`minOffS` valent 0 et ne se règlent pas // ici. Le repli `_SectionModulable` lui proposait une puissance nominale // et des paliers — des réglages qui n'existent pas pour elle, et dont // l'écriture partirait quand même dans `SetLoadConfig`. if (e.isEvCharger) _SectionBorne(entry: e, telemetry: telemetry) else if (e.isRelayRouter) _SectionRelais( entry: e, provider: provider, service: service, telemetry: telemetry) else if (e.isSgReady) _SectionSgReady(entry: e, provider: provider, service: service) else _SectionModulable( entry: e, provider: provider, service: service, schema: schema, telemetry: telemetry), const SizedBox(height: 14), if (e.isEvCharger) const SizedBox.shrink() else 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), _CompteurEtSonde( entry: e, provider: provider, schema: schema.schema, service: service), 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)}.', ), ], ), ); } } /// Le mécanisme, et il **se change** — c'est un champ enregistré (`adapter`). /// /// Le geste n'est pas anodin : `adapter` et `mode` entrent dans `sameHardware()`, donc /// basculer **reconstruit l'adaptateur** (contacts à l'état sûr, verrous réarmés à froid). /// Et la charge utile du mécanisme quitté doit **partir** — la box refuse en bloc une /// configuration qui porterait celle d'un autre. D'où la confirmation, qui dit les deux. class _Mecanisme extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; const _Mecanisme({required this.entry, required this.provider}); Future _demander(BuildContext context, String vers) async { if (vers == entry.adapter) return; final ok = await showDialog( context: context, builder: (c) => AlertDialog( title: Text('Passer en « ${kMecanismeLabels[vers]} » ?'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'La charge sera RECONSTRUITE : ses contacts passent par leur état sûr, ' 'puis ses verrous se réarment à froid.', style: TextStyle(fontSize: 13, height: 1.4, color: EtmTokens.inkOf(c)), ), const SizedBox(height: 10), Text( _cePartQuoi(vers), style: TextStyle( fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(c)), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(c).pop(false), child: const Text('Annuler')), FilledButton( onPressed: () => Navigator.of(c).pop(true), child: const Text('Changer')), ], ), ); if (ok == true) { provider.replaceEntry(entry.id, basculerMecanisme(entry, vers)); } } /// Ce que la bascule emporte et ce qu'elle récupère — dit AVANT, pas après. String _cePartQuoi(String vers) => switch (vers) { 'relay-router' => 'Les contacts déjà déclarés deviennent des lignes de relais ; leur puissance ' 'reste à saisir — une estimation d\'état SG-Ready n\'est pas la puissance ' 'd\'une résistance.', 'etmvariableload' => 'Les contacts et la table d\'états sont retirés : ce mécanisme ne commande ' 'aucun relais. Une puissance nominale sera requise.', _ => 'Les deux premiers contacts deviennent SG1 et SG2 dans l\'encodage standard, ' 'et les puissances estimées restent à renseigner.', }; @override Widget build(BuildContext context) { // Une BORNE n'est pas un mécanisme au choix. Son entrée est créée d'office par le // plugin à la détection du Thing, et `adapter: "evcharger"` n'est pas dans // [kMecanismes] : proposer les trois autres reviendrait à offrir de transformer une // borne en routeur de relais — une bascule que `basculerMecanisme()` sait construire // et qui détruirait le rattachement, sans qu'aucun écran n'ait de quoi le défaire. if (entry.isEvCharger) { return _Carte( titre: 'Mécanisme', badge: 'imposé · evcharger', sousTitre: 'La borne est déclarée par la box à la détection de l\'appareil.', child: Text( 'Une borne ne porte aucun réglage de mécanisme : ni contacts, ni paliers, ni ' 'puissance nominale. Ses limites viennent de la borne et de la voiture ' 'branchée, et changent avec elle — les figer ici en ferait une seconde source ' 'de vérité, fausse dès le prochain véhicule.\n\n' 'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de ' 'recharge, lui, se règle depuis la carte véhicule du tableau de bord.', style: TextStyle( fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)), ), ); } return _Carte( titre: 'Mécanisme', badge: 'enregistré · adapter', sousTitre: 'Où sont les contacteurs ? Sur les sorties de la box → routeur de ' 'relais. Dans l\'appareil, piloté par une consigne → modulable.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, children: [ for (final m in kMecanismes) InkWell( borderRadius: BorderRadius.circular(9), onTap: () => _demander(context, m), child: Container( padding: const EdgeInsets.symmetric( horizontal: 11, vertical: 7), decoration: BoxDecoration( color: m == entry.adapter ? EtmTokens.brand.withValues(alpha: 0.14) : Colors.transparent, borderRadius: BorderRadius.circular(9), border: Border.all( color: m == entry.adapter ? EtmTokens.brand.withValues(alpha: 0.5) : EtmTokens.lineOf(context)), ), child: Text(kMecanismeLabels[m]!, style: TextStyle( fontSize: 12, fontWeight: m == entry.adapter ? FontWeight.w700 : FontWeight.w400, color: m == entry.adapter ? EtmTokens.brand : EtmTokens.mutedOf(context), )), ), ), ], ), if (estHeritageFixed(entry)) ...[ const SizedBox(height: 10), _HeritageFixed(entry: entry, provider: provider), ], ], ), ); } } /// Borne de recharge — **rien à régler ici, et le dire vaut mieux que se taire**. /// /// L'entrée `evcharger` est créée d'office par le plugin à la détection du Thing, pour que /// l'invariant `loads[] ⊆ GetLoadConfig` tienne : une charge publiée en télémétrie doit /// pouvoir résoudre son libellé, son domaine et son rang quelque part. Elle ne porte /// **aucune** charge utile de mécanisme. /// /// Ce que cette section montre est donc du **runtime**, en lecture seule : le nombre de /// phases, le courant, le plancher, et la présence d'un véhicule. Les afficher n'est pas /// décoratif — c'est ici que se lit l'écart entre ce que l'arbitre a commandé et ce que la /// borne fait, écart que l'allocation seule ne montre jamais. /// /// Et le plancher d'une borne n'est pas un réglage : il vaut `max(borne, voiture)` et /// change avec le véhicule branché. Le proposer à la saisie créerait une seconde source de /// vérité, fausse dès la voiture suivante. class _SectionBorne extends StatelessWidget { final LoadConfigEntry entry; final LoadTelemetryProvider telemetry; const _SectionBorne({required this.entry, required this.telemetry}); @override Widget build(BuildContext context) { final muted = EtmTokens.mutedOf(context); final live = telemetry.entryFor(entry.id); final mech = live?.mechanism; final lignes = []; if (mech != null) { if (mech.phaseCount != null) lignes.add('${mech.phaseCount} phase(s)'); if (mech.currentA != null) { lignes.add('${mech.currentA!.toStringAsFixed(1)} A'); } if (mech.minPowerW != null) { lignes.add('plancher ${mech.minPowerW!.round()} W'); } if (mech.pluggedIn != null) { lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule'); } } return _Carte( titre: 'Borne', badge: 'lecture seule · runtime', sousTitre: 'Ce que la box publie pour cette borne au dernier cycle.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (lignes.isEmpty) // Absente de la télémétrie : configurée, mais hors arbitrage EN CE MOMENT. // Depuis +etm24, c'est notamment le cas quand aucun véhicule n'est branché. // « Perdue » serait faux, et « désactivée » aussi. Text( 'Hors arbitrage en ce moment — la box ne publie rien pour elle au dernier ' 'cycle. C\'est le cas sans véhicule branché, sans voiture assignée, ou en ' 'mode manuel. La borne n\'est ni perdue ni désactivée : son entrée de ' 'configuration est là, au-dessus.', style: TextStyle(fontSize: 12, height: 1.4, color: muted), ) else ...[ Text(lignes.join(' · '), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: EtmTokens.inkOf(context))), const SizedBox(height: 6), Text( 'Mesuré, pas réglé. Le plancher d\'une borne vaut max(borne, voiture) et ' 'suit le véhicule branché : il n\'y a pas de champ à saisir, et il ne faut ' 'pas en fabriquer un.', style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), ), ], ], ), ); } } /// Configuration **héritée** : `etmvariableload` en multipalier natif (`fixed`). /// /// L'app ne propose plus ce mode — le plugin lui-même le qualifie de *legacy* — mais elle /// ne le réécrit pas en douce : basculer `fixed` vers `dynamic` change l'arbitrage réel de /// la charge, et cela demande un geste explicite. **Ouvrir cet écran ne convertit /// personne.** class _HeritageFixed extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; const _HeritageFixed({required this.entry, required this.provider}); Future _convertir(BuildContext context) async { final ctrl = TextEditingController( text: entry.powerLevelsInt.isEmpty ? '' : entry.powerLevelsInt.last.toString()); final w = await showDialog( context: context, builder: (c) => AlertDialog( title: const Text('Convertir en modulation continue'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'La box refuse « dynamic » sans plafond, et refuse les paliers avec. La ' 'déclaration en paliers ne porte pas de puissance nominale : il faut donc ' 'la fournir.', style: TextStyle( fontSize: 12.5, height: 1.4, color: EtmTokens.mutedOf(c)), ), const SizedBox(height: 12), TextField( controller: ctrl, keyboardType: TextInputType.number, decoration: const InputDecoration( isDense: true, labelText: 'Puissance nominale', suffixText: 'W'), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(c).pop(), child: const Text('Annuler')), FilledButton( onPressed: () { final n = int.tryParse(ctrl.text.trim()); if (n != null && n > 0) Navigator.of(c).pop(n); }, child: const Text('Convertir'), ), ], ), ); if (w != null && w > 0) { provider.replaceEntry(entry.id, convertirEnModulation(entry, w)); } } @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(11), decoration: BoxDecoration( color: EtmTokens.orange.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Configuration héritée — lecture seule · mode fixed', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 4), Text('Paliers déclarés : ${entry.powerLevelsInt.join(' · ')} W', style: TextStyle( fontSize: 11.5, fontFamily: 'monospace', color: EtmTokens.inkOf(context))), const SizedBox(height: 5), Text( 'Le multipalier natif n\'est plus proposé, mais il n\'est pas réécrit sans ' 'votre accord : passer en modulation continue change l\'arbitrage réel de ' 'cette charge.', style: TextStyle( fontSize: 11, height: 1.35, color: EtmTokens.mutedOf(context)), ), Align( alignment: Alignment.centerLeft, child: TextButton( onPressed: () => _convertir(context), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: EtmTokens.orange), child: const Text('Convertir en modulation continue…'), ), ), ], ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Routeur de relais // ═══════════════════════════════════════════════════════════════════════════ /// Les contacteurs, et **rien d'autre de matériel**. /// /// Les paliers atteignables sont **publiés par la box** (`mechanism.stagesW`, depuis /// `+etm16`) et jamais enregistrés : ce sont les relais qui se déclarent, les paliers s'en /// déduisent. L'app les LIT au lieu de refaire la combinatoire — une seule fonction moteur /// sert la construction du routeur, la comparaison matérielle et cette publication, donc /// plus de divergence possible. /// /// Tant que le brouillon n'a pas touché aux contacteurs, c'est cette table publiée qui /// s'affiche. Dès qu'il y touche, elle devient périmée : on montre alors une **prévision**, /// annoncée comme telle — mieux vaut dire « voici ce que ça donnera » que d'afficher une /// table qui décrit encore l'état d'avant. class _SectionRelais extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; final NymeaService service; final LoadTelemetryProvider telemetry; const _SectionRelais({ required this.entry, required this.provider, required this.service, required this.telemetry, }); /// Vrai si le brouillon a touché aux contacteurs — la table publiée est alors périmée. bool get _relaisModifies { for (final e in provider.entries) { if (e.id == entry.id) return hardwareDiff(e, entry).contains('relays'); } return false; } void _remplacer(List> relais) => provider.replaceEntry(entry.id, entry.patched(relays: relais)); List> get _actuels => [for (final r in entry.relays) r.toMap()]; @override Widget build(BuildContext context) { // La table PUBLIÉE par la box fait foi. Elle décrit ce qui tourne — donc elle est // périmée dès que le brouillon touche aux contacteurs. final publiee = telemetry.entryFor(entry.id)?.mechanism?.stagesW; final aJour = publiee != null && !_relaisModifies; final p = aJour ? publiee : deriveStages(entry.relays).levels; 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( aJour ? '${entry.relays.length} relais → paliers publiés par la box :' : '${entry.relays.length} relais → paliers prévus :', 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( aJour ? 'Publiés par la box en télémétrie, jamais enregistrés : ce sont ' 'les contacteurs qui se déclarent, les paliers s\'en déduisent.' : 'PRÉVISION — les contacteurs ont changé dans ce brouillon. La box ' 'publiera sa propre table au premier cycle après enregistrement.', 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 dejaPris; final ValueChanged onThing; final ValueChanged 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; final SchemaProvider schema; final LoadTelemetryProvider telemetry; const _SectionModulable({ required this.entry, required this.provider, required this.service, required this.schema, required this.telemetry, }); @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.', ), const SizedBox(height: 14), _Plancher( entry: entry, provider: provider, schema: schema.schema, telemetry: telemetry), ], ), ), ], ); } } // ═══════════════════════════════════════════════════════════════════════════ // 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 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 = []; 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 dejaPris; final ValueChanged 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 contacts; final Map etiquettes; final void Function(String contact, bool ferme) onBasculer; final ValueChanged 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 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 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é et sonde — deux champs de **même forme**, une même règle. /// /// ## Ils ne sont ni du matériel, ni exclusifs /// /// Rattacher un compteur ne reconstruit rien : ces deux champs n'entrent pas dans /// `sameHardware()`, donc la mise à jour est **en place**, verrous préservés. Ils n'entrent /// pas non plus dans le contrôle d'unicité des Things — deux charges peuvent légitimement /// partager une sonde de pièce, et un compteur de tableau divisionnaire en couvrir /// plusieurs. Le sélecteur ne grise donc **rien** ici, contrairement aux contacteurs. /// /// ## Ce que le compteur autorise à conclure, et rien de plus /// /// Il sert à **vérifier**, jamais à décider : commandé 3 000 W, mesuré 0 W sur plusieurs /// cycles, la charge ne fait pas ce qu'on lui demande. La mesure n'entre pas dans le /// budget — elle est en retard sur la commande, et la charge est déjà comptée dans le /// compteur racine dont elle n'est qu'une décomposition. L'ajouter compterait deux fois la /// même énergie. /// /// ## Sélection par INTERFACE, jamais par plugin /// /// Le compteur se choisit parmi les Things portant `smartmeter`, la sonde parmi celles qui /// portent une interface de température. Nommer un plugin rendrait le modèle faux au /// prochain qui rend le même service, et obligerait à tenir une table à la main. /// /// ## Et si la box ne les porte pas /// /// Le grisage vient du **schéma**, jamais d'une table de versions : sur une box antérieure /// le champ est absent, le bloc se grise et le dit. Sur `.75` en `+etm16` il est là — le /// réglage s'est donc activé **seul**, sans nouvelle version de l'app, exactement comme /// promis. class _CompteurEtSonde extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; final RpcSchema schema; final NymeaService service; const _CompteurEtSonde({ required this.entry, required this.provider, required this.schema, required this.service, }); FieldSupport _support(String champ) => schema.paramSupport( 'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]); @override Widget build(BuildContext context) { final supportCompteur = _support('meterThingId'); final supportSonde = _support('sensorThingId'); return Column( children: [ _Carte( titre: 'Compteur dédié', badge: supportCompteur.isWritable ? 'enregistré · meterThingId' : 'pas encore supporté', sousTitre: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, ' 'jamais pour décider.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SchemaGated( support: supportCompteur, fieldName: 'meterThingId', label: 'Compteur dédié', child: ThingPicker( service: service, interfaceRequise: 'smartmeter', valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId, placeholder: 'Choisir un appareil de mesure…', onChoisi: (id) => provider.replaceEntry( entry.id, entry.patched(meterThingId: id)), ), ), const SizedBox(height: 6), _Aide( 'La mesure n\'entre pas dans le budget : elle est en retard sur la ' 'commande, et cette charge est déjà comptée dans le compteur racine dont ' 'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la ' 'même énergie.', ), ], ), ), const SizedBox(height: 14), _Carte( titre: 'Sonde de température', badge: supportSonde.isWritable ? 'enregistré · sensorThingId' : 'pas encore supporté', sousTitre: 'La grandeur physique du service rendu — ce que la charge produit, ' 'pas ce qu\'elle consomme.', child: SchemaGated( support: supportSonde, fieldName: 'sensorThingId', label: 'Sonde de température', child: ThingPicker( service: service, interfaceRequise: 'temperaturesensor', valeur: entry.sensorThingId.isEmpty ? null : entry.sensorThingId, placeholder: 'Choisir une sonde…', onChoisi: (id) => provider.replaceEntry( entry.id, entry.patched(sensorThingId: id)), ), ), ), ], ); } } /// 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 _valider() { final erreurs = []; 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 = {}; 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() .fold(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)), ), ], ); } } /// Plancher de modulation — **préparé avant son arrivée au schéma**, délibérément. /// /// ## Pourquoi l'écrire maintenant /// /// Le grisage de cet écran se dérive de `JSONRPC.Introspect` : le jour où le plugin ajoute /// le champ, le bloc s'active **tout seul**. C'est la promesse, et elle a déjà été tenue — /// `meterThingId` et `sensorThingId` se sont activés sans nouvelle version de l'app. /// /// Mais ce jour-là, ils se sont activés sur un **placeholder mort** : un contrôle d'allure /// utilisable qui n'écrivait nulle part. Pire qu'un bloc grisé, parce qu'un bloc grisé dit /// ce qu'il est. La leçon vaut d'être appliquée en avance : le plancher est câblé /// maintenant, il restera grisé tant que le champ n'existe pas, et il fonctionnera le jour /// où il apparaîtra — sans que personne n'ait à y revenir. /// /// ## Ce que la box publie déjà /// /// `mechanism.minPowerW` est **en télémétrie** avant d'être configurable. On l'affiche /// donc en lecture, ce qui donne la valeur réellement en vigueur même quand le réglage, /// lui, n'est pas encore ouvert. /// /// ## Ce plancher-ci n'est PAS celui d'une borne de recharge /// /// Même mot, deux natures. Ici, une **propriété déclarée de l'appareil** : la consigne /// non nulle la plus basse qu'il accepte, saisie une fois et stable. /// /// Le minimum d'une borne, lui, vaut `max(borne, voiture)` et **dépend du véhicule /// branché** — il change quand la voiture change. Il dépend aussi du nombre de phases /// visé, de l'ordre de 1,4 kW en monophasé contre 4,1 kW en triphasé. C'est du **runtime**, /// il se constate et ne se règle pas ; il s'affiche donc sur la carte de la borne, en /// lecture, tel que la box le donne — sans conversion côté app. /// /// Ce bloc ne vit que dans la section `etmvariableload`, ce qui l'écarte structurellement /// des bornes : elles ne sont pas des `LoadConfig` et n'ouvrent pas cet écran. class _Plancher extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; final RpcSchema schema; final LoadTelemetryProvider telemetry; const _Plancher({ required this.entry, required this.provider, required this.schema, required this.telemetry, }); /// Nom pressenti du champ. S'il diffère à l'arrivée, le bloc restera simplement grisé — /// une erreur visible et sans conséquence, là où une écriture inventée ferait rejeter /// l'enregistrement de TOUTES les charges. static const String champ = 'minPowerW'; @override Widget build(BuildContext context) { final support = schema .paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]); final publie = telemetry.entryFor(entry.id)?.mechanism?.minPowerW; return _Carte( titre: 'Plancher de modulation', badge: support.isWritable ? 'enregistré · $champ' : 'pas encore supporté', sousTitre: 'La consigne non nulle la plus basse que CET appareil accepte — en ' 'dessous, il vaut mieux ne rien commander du tout. Propriété déclarée, stable : ' 'à ne pas confondre avec le minimum d\'une borne, qui suit le véhicule branché.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SchemaGated( support: support, fieldName: champ, label: 'Plancher de modulation', child: TextFormField( key: ValueKey('plancher-${entry.id}'), initialValue: (publie ?? 0).round().toString(), enabled: support.isWritable, keyboardType: TextInputType.number, decoration: const InputDecoration(isDense: true, suffixText: 'W'), onChanged: (v) { final n = int.tryParse(v.trim()); // Rien ne part tant que le champ n'est pas au schéma : nymea rejette // l'appel ENTIER sur une clé inconnue, et le refus emporterait toutes // les charges. if (n != null && n >= 0 && support.isWritable) { provider.replaceEntry( entry.id, entry.patchedRaw({champ: n})); } }, ), ), if (publie != null) ...[ const SizedBox(height: 6), _Aide('Valeur en vigueur, publiée par la box : ${publie.round()} W.'), ], ], ), ); } }