diff --git a/lib/screens/drawer/main_drawer.dart b/lib/screens/drawer/main_drawer.dart index 9d2c36a..8d1b6cd 100644 --- a/lib/screens/drawer/main_drawer.dart +++ b/lib/screens/drawer/main_drawer.dart @@ -572,12 +572,6 @@ class _InstallerSection extends StatelessWidget { route: '/energy/setup', push: true, ), - _NavItem( - icon: Icons.low_priority_rounded, - label: 'Ordre de service des charges', - route: '/energy/loads', - push: true, - ), _NavItem( icon: Icons.tune_rounded, label: 'Configuration Things', diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index add420c..418960d 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -1,55 +1,74 @@ 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'; -/// Détail d'une charge — **un écran par mécanisme**, pas un formulaire unique. +/// **Configurer** une charge — un écran, trois charges utiles qui ne se ressemblent pas. /// -/// Les trois contrats ne se ressemblent pas et n'ont pas à se ressembler : un routeur de -/// relais déclare des contacteurs et laisse la box **dériver** ses paliers ; une charge -/// continue déclare une puissance nominale et rien d'autre ; une PAC SG-Ready déclare un -/// encodage à quatre états normés. Les fondre dans un formulaire commun obligerait à -/// inventer des champs vides pour les deux tiers des cas. +/// 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 est du MATÉRIEL +/// ## Tout ce qui se règle ici n'a pas le même coût /// -/// C'est la différence avec l'écran de liste, où l'on ne touche qu'au rang, au domaine, -/// au libellé et à l'activation. Ici, chaque champ entre dans la comparaison -/// `sameHardware()` du moteur : l'enregistrer **reconstruit l'adaptateur**, ce qui amène -/// les contacts à l'état sûr et **réarme les verrous à froid**. L'écran l'annonce avant -/// d'écrire — jamais après. -class LoadMechanismScreen extends StatelessWidget { +/// `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(); - - LoadConfigEntry? entry; - for (final g in provider.groups) { - for (final e in g.loads) { - if (e.id == loadId) entry = e; - } - } + final service = context.watch(); + final e = _entree(provider); return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( - title: Text(entry?.label ?? 'Charge'), + title: Text(e == null ? 'Charge' : 'Configurer · ${e.label}'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), - body: entry == null + body: e == null ? const Center(child: Text('Charge introuvable dans la configuration.')) : ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), @@ -59,183 +78,432 @@ class LoadMechanismScreen extends StatelessWidget { schemaLoaded: schema.schema.loaded, ), const SizedBox(height: 14), - if (entry.isRelayRouter) - RelayRouterForm(entry: entry, provider: provider, schema: schema) - else if (entry.isSgReady) - SgReadyForm(entry: entry, provider: provider, schema: schema) + _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 - VariableLoadForm(entry: entry, provider: provider, schema: schema), - const SizedBox(height: 16), - _HardwareWarning(provider: provider, loadId: loadId), + _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 // ═══════════════════════════════════════════════════════════════════════════ -/// Un contacteur par ligne, et la **puissance de la résistance câblée derrière**. +/// Les contacteurs, et **rien d'autre de matériel**. /// -/// ## Les paliers atteignables ne sont PAS dans le contrat -/// -/// La box les dérive de `relays[]` — toutes les combinaisons, dédupliquées — mais elle -/// **ne les republie pas**. Vérifié sur `.75` (plugin `1.15.2+etm15`) : `GetLoadConfig` -/// rend `"powerLevels": []` sur la charge `chauffe-eau` à trois contacteurs, et -/// `GetLoadTelemetry` n'expose que `maxStageW` et le `stageW` courant — jamais la liste. -/// -/// **On ne les recalcule donc pas ici**, et c'est délibéré : deux implémentations de la -/// même combinatoire divergeraient, et c'est l'app qui aurait tort. L'écran affiche ce -/// que la box publie — le maximum atteignable — et dit franchement que le détail des -/// paliers n'est pas exposé. -class RelayRouterForm extends StatelessWidget { +/// 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 SchemaProvider schema; + final NymeaService service; - const RelayRouterForm({ - super.key, + const _SectionRelais({ required this.entry, required this.provider, - required this.schema, + 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 paliers(List relais) { + if (relais.isEmpty || relais.length > 16) return const []; + final vus = {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> 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) { - final support = schema.schema - .paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'relays']); - final total = entry.relays.fold(0, (a, r) => a + r.powerW); + final p = paliers(entry.relays); + final pris = { + for (final r in entry.relays) normalizeThingId(r.thingId), + }; - return _Section( - title: 'Contacteurs', - subtitle: 'Un contacteur par ligne, et la puissance de la résistance câblée ' - 'derrière lui.', + 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: [ - SchemaGated( - support: support, - fieldName: 'relays', - label: 'Contacteurs', + 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: [ - for (var i = 0; i < entry.relays.length; i++) - _RelayRow( - index: i, - thingId: entry.relays[i].thingId, - powerW: entry.relays[i].powerW, - onChanged: support.isWritable - ? (w) => _setPower(i, w) - : null, - ), + 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: 10), - _Note( - 'Somme câblée : $total W. Les **paliers atteignables** sont calculés par la ' - 'box à partir de ces contacteurs — elle ne les publie pas au RPC ' - '(`powerLevels` revient vide sur ce mécanisme). Ils ne sont donc pas ' - 'affichés ici : les recalculer côté app ferait diverger deux implémentations ' - 'de la même combinatoire.', - ), const SizedBox(height: 8), - _Note( - '⚠️ **L\'ordre des lignes compte.** Le moteur compare les contacteurs ' - 'position par position : déplacer une ligne sans rien changer d\'autre ' - 'suffit à reconstruire la charge, donc à ouvrir les contacts et à réarmer ' - 'les temporisations. Vérifié sur le banc.', - warn: true, + _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.', ), ], ), ); } - - void _setPower(int index, int powerW) { - final relays = [ - for (var i = 0; i < entry.relays.length; i++) - { - 'thingId': entry.relays[i].thingId, - 'powerW': i == index ? powerW : entry.relays[i].powerW, - } - ]; - provider.replaceEntry(entry.id, entry.patched(relays: relays)); - } } -class _RelayRow extends StatelessWidget { +class _LigneRelais extends StatelessWidget { final int index; - final String thingId; - final int powerW; - final void Function(int)? onChanged; + final LoadRelay relais; + final NymeaService service; + final Set dejaPris; + final ValueChanged onThing; + final ValueChanged onPuissance; + final VoidCallback? onSupprimer; - const _RelayRow({ + const _LigneRelais({ required this.index, - required this.thingId, - required this.powerW, - this.onChanged, + required this.relais, + required this.service, + required this.dejaPris, + required this.onThing, + required this.onPuissance, + this.onSupprimer, }); @override Widget build(BuildContext context) { - final muted = EtmTokens.mutedOf(context); return Padding( - padding: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 26, - height: 26, - alignment: Alignment.center, - decoration: BoxDecoration( - color: EtmTokens.brand.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), + 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)), ), - child: Text('${index + 1}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: EtmTokens.brand)), ), const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Le ThingId est l'identité du contacteur ; le nom lisible vient du - // catalogue de Things, pas de la config de charge. - Text(thingId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - fontFamily: 'monospace', - color: muted)), - ], + 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: 92, + width: 86, child: TextFormField( - initialValue: '$powerW', - enabled: onChanged != null, + key: ValueKey('w-$index-${relais.thingId}'), + initialValue: '${relais.powerW}', keyboardType: TextInputType.number, textAlign: TextAlign.right, - decoration: const InputDecoration( - isDense: true, - suffixText: 'W', - ), - onFieldSubmitted: (v) { + decoration: const InputDecoration(isDense: true, suffixText: 'W'), + onChanged: (v) { final n = int.tryParse(v.trim()); - if (n != null && n >= 0) onChanged?.call(n); + 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', + ), ], ), ); @@ -243,71 +511,85 @@ class _RelayRow extends StatelessWidget { } // ═══════════════════════════════════════════════════════════════════════════ -// Charge modulable (etmvariableload) +// Modulable // ═══════════════════════════════════════════════════════════════════════════ -/// **Un seul appareil, une consigne continue, aucun contacteur.** -class VariableLoadForm extends StatelessWidget { +class _SectionModulable extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; - final SchemaProvider schema; + final NymeaService service; - const VariableLoadForm({ - super.key, + const _SectionModulable({ required this.entry, required this.provider, - required this.schema, + required this.service, }); @override Widget build(BuildContext context) { - final support = schema.schema - .paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'maxPowerW']); - - return _Section( - title: 'Puissance nominale', - subtitle: 'Un seul appareil, piloté par une consigne continue. Aucun contacteur.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SchemaGated( - support: support, - fieldName: 'maxPowerW', - label: 'Puissance nominale', - child: TextFormField( - initialValue: '${entry.maxPowerW}', - enabled: support.isWritable, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - isDense: true, - labelText: 'Puissance nominale', - suffixText: 'W', + 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', ), - onFieldSubmitted: (v) { - final n = int.tryParse(v.trim()); - if (n != null && n >= 0) { - provider.replaceEntry(entry.id, entry.patched(maxPowerW: n)); - } - }, - ), + 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: 10), - _Note( - 'C\'est une **valeur de plaque que vous saisissez**, jamais une mesure et ' - 'jamais une déduction — « résistance ECS = 3000 W ». Elle n\'est pas ' - 'seulement indicative : le moteur s\'en sert pour recréditer le budget, si ' - 'bien qu\'une valeur fausse ne fausse pas que cette charge — elle décale ' - 'l\'allocation rendue à **tout le reste de la cascade**.', + ), + 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: 8), - _Note( - 'Changer d\'appareil, c\'est changer de charge : l\'identifiant de cette ' - 'charge **est** le ThingId piloté (`${entry.id}`). Une substitution passe ' - 'donc par la suppression de la charge — qui amène l\'appareil à son état ' - 'sûr — puis par la création de la nouvelle.', - ), - ], - ), + ), + ], ); } } @@ -316,221 +598,288 @@ class VariableLoadForm extends StatelessWidget { // SG-Ready // ═══════════════════════════════════════════════════════════════════════════ -/// Table des quatre états normés et de leurs deux contacts. +/// Les deux contacts, puis la table des quatre états. /// -/// ## Le tableau SG1/SG2 n'est pas éditable, et c'est un choix +/// ## « SG1 » et « SG2 » n'existent pas côté box /// -/// L'encodage des quatre états SG-Ready est **normé** : ce sont deux contacts secs dont -/// les combinaisons ont un sens fixé par la norme, pas par l'installation. Rendre la -/// table éditable permettrait de saisir un encodage non standard — auquel cas les -/// étiquettes (« blocage », « normal », « recommandation », « forcé ») ne voudraient plus -/// rien dire. Et attribuer une étiquette au hasard serait pire que de ne rien afficher. +/// `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. /// -/// Ce qui se règle ici, c'est la **puissance estimée** de chaque état et la durée de -/// maintien — pas la sémantique des contacts. -class SgReadyForm extends StatelessWidget { +/// 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 SchemaProvider schema; + final NymeaService service; - const SgReadyForm({ - super.key, + const _SectionSgReady({ required this.entry, required this.provider, - required this.schema, + required this.service, }); - /// Étiquettes normées des quatre états SG-Ready. - static const _stateLabels = { - 1: 'Blocage', - 2: 'Normal (repos)', - 3: 'Recommandation de marche', - 4: 'Marche forcée', + static const _nomsEtats = { + 1: ('Blocage', 'arrêt forcé'), + 2: ('Normal', 'aucune consigne'), + 3: ('Recommandation', 'incitation douce'), + 4: ('Forcé', 'marche imposée'), }; - @override - Widget build(BuildContext context) { - final sgSupport = schema.schema - .paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'sgReady']); + /// 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; + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Section( - title: 'États SG-Ready', - subtitle: 'Deux contacts secs, quatre états normés. L\'encodage est fixé par ' - 'la norme : il s\'affiche, il ne se modifie pas.', - child: SchemaGated( - support: sgSupport, - fieldName: 'sgReady', - label: 'États SG-Ready', - child: Column( - children: [ - for (final s in entry.sgReadyStates) - _SgStateRow( - state: s.state, - label: _stateLabels[s.state] ?? 'État ${s.state}', - relayCount: s.relays.length, - estimatedPowerW: s.estimatedPowerW, - onChanged: sgSupport.isWritable - ? (w) => _setEstimate(s.state, w) - : null, - ), - ], - ), - ), - ), - const SizedBox(height: 14), - _Section( - title: 'Protection du compresseur', - subtitle: 'Durée minimale de maintien d\'un état.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - initialValue: '${entry.minStateHoldS}', - enabled: sgSupport.isWritable, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - isDense: true, - labelText: 'Maintien minimal', - suffixText: 's', - ), - onFieldSubmitted: (v) { - final n = int.tryParse(v.trim()); - if (n != null && n >= 0) _setHold(n); - }, - ), - const SizedBox(height: 8), - _Note( - 'Toute modification de cette page **reconstruit la charge** et réarme ce ' - 'verrou pour sa durée entière — la PAC reste figée dans son état courant ' - 'le temps qu\'il expire. Sur une machine réelle, cette durée vaut plutôt ' - '900 s que 300.', - warn: true, - ), - ], - ), - ), - const SizedBox(height: 14), - // Le banc porte des estimations INVENTÉES. L'écran ne doit pas les présenter - // comme une donnée d'installation. - if (_looksLikeBenchDefaults) - _Note( - '⚠️ **Puissances non mesurées.** Les valeurs {état 3 : 1500 W, ' - 'état 4 : 3000 W} sont celles du banc d\'essai — elles décrivent une PAC ' - 'fictive et **aucune machine réelle**. À remplacer par les valeurs de la ' - 'machine câblée (plaque signalétique, puis mesure sous les deux régimes) ' - 'avant toute conclusion transposable à une installation.', - warn: true, - ), - ], - ); + 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); } - /// Vrai si la configuration porte encore les estimations fictives du banc. - bool get _looksLikeBenchDefaults { - final byState = {for (final s in entry.sgReadyStates) s.state: s.estimatedPowerW}; - return byState[3] == 1500.0 && byState[4] == 3000.0; - } - - void _setEstimate(int state, double w) { - final states = [ - for (final s in entry.sgReadyStates) - { - 'state': s.state, - 'relays': s.relays, - 'estimatedPowerW': s.state == state ? w : s.estimatedPowerW, - } - ]; + void _ecrire(List etats) { provider.replaceEntry( entry.id, entry.patched(sgReady: { 'minStateHoldS': entry.minStateHoldS, - 'states': states, + 'states': [for (final s in etats) s.toMap()], }), ); } - void _setHold(int seconds) { - provider.replaceEntry( - entry.id, - entry.patched(sgReady: { - 'minStateHoldS': seconds, - 'states': [for (final s in entry.sgReadyStates) 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 _SgStateRow extends StatelessWidget { - final int state; - final String label; - final int relayCount; - final double estimatedPowerW; - final void Function(double)? onChanged; +class _LigneContact extends StatelessWidget { + final String etiquette; + final NymeaService service; + final String? valeur; + final Set dejaPris; + final ValueChanged onChoisi; - const _SgStateRow({ - required this.state, - required this.label, - required this.relayCount, - required this.estimatedPowerW, - this.onChanged, + const _LigneContact({ + required this.etiquette, + required this.service, + required this.valeur, + required this.dejaPris, + required this.onChoisi, }); @override Widget build(BuildContext context) { - final muted = EtmTokens.mutedOf(context); return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 26, - height: 26, - alignment: Alignment.center, - decoration: BoxDecoration( - color: EtmTokens.brand.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), + 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)), ), - child: Text('$state', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: EtmTokens.brand)), ), const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: EtmTokens.inkOf(context))), - Text( - relayCount == 0 - ? 'aucun contact fermé' - : '$relayCount contact${relayCount > 1 ? 's' : ''} fermé' - '${relayCount > 1 ? 's' : ''}', - style: TextStyle(fontSize: 11, color: muted)), - ], - ), - ), - SizedBox( - width: 96, - child: TextFormField( - initialValue: estimatedPowerW.round().toString(), - enabled: onChanged != null, - keyboardType: TextInputType.number, - textAlign: TextAlign.right, - decoration: const InputDecoration(isDense: true, suffixText: 'W'), - onFieldSubmitted: (v) { - final n = double.tryParse(v.trim().replaceAll(',', '.')); - if (n != null && n >= 0) onChanged?.call(n); - }, + child: ThingPicker( + service: service, + interfaceRequise: 'power', + valeur: valeur, + dejaPris: dejaPris, + placeholder: 'Choisir le relais…', + onChoisi: onChoisi, ), ), ], @@ -539,59 +888,500 @@ class _SgStateRow extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Communs -// ═══════════════════════════════════════════════════════════════════════════ +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; -/// Annonce d'impact + enregistrement, propre à l'écran de mécanisme. -class _HardwareWarning extends StatelessWidget { - final LoadConfigProvider provider; - final String loadId; + const _LigneEtat({ + required this.etat, + required this.nom, + required this.detail, + required this.contacts, + required this.etiquettes, + required this.onBasculer, + required this.onEstimation, + }); - const _HardwareWarning({required this.provider, required this.loadId}); + bool _ferme(String c) => + etat.relays.any((r) => normalizeThingId(r) == normalizeThingId(c)); @override Widget build(BuildContext context) { - if (!provider.hasPendingChanges) { - return _Note( - 'Aucune modification en attente. Tout ce qui se règle sur cette page est du ' - 'matériel : l\'enregistrer reconstruira la charge.', + 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é — **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 _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.'); + } } - final impact = provider - .pendingImpact() - .where((i) => i.loadId == loadId) - .toList(); - final rebuild = impact.any((i) => i.impact == LoadChangeImpact.rebuild); - final rearm = impact + 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 (rebuild) - _Note( - 'À 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 ? ' pendant ${rearm >= 60 ? '${(rearm / 60).round()} min' : '$rearm s'}' : ''}.', - warn: true, + 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, + ), ), - const SizedBox(height: 10), Row(children: [ TextButton( - onPressed: provider.discardDraft, + onPressed: provider.hasPendingChanges ? provider.discardDraft : null, child: const Text('Annuler'), ), const Spacer(), FilledButton( - onPressed: provider.saveState == LoadSaveState.pending + onPressed: (!provider.hasPendingChanges || + erreurs.isNotEmpty || + enCours) ? null : provider.save, - child: Text(provider.saveState == LoadSaveState.pending - ? 'Envoi…' - : 'Enregistrer'), + child: Text(enCours ? 'Envoi…' : 'Enregistrer'), ), ]), ], @@ -599,14 +1389,20 @@ class _HardwareWarning extends StatelessWidget { } } -class _Section extends StatelessWidget { - final String title; - final String subtitle; +// ═══════════════════════════════════════════════════════════════════════════ +// Habillage +// ═══════════════════════════════════════════════════════════════════════════ + +class _Carte extends StatelessWidget { + final String titre; + final String? badge; + final String? sousTitre; final Widget child; - const _Section({ - required this.title, - required this.subtitle, + const _Carte({ + required this.titre, + this.badge, + this.sousTitre, required this.child, }); @@ -623,17 +1419,15 @@ class _Section extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: EtmTokens.inkOf(context))), - const SizedBox(height: 3), - Text(subtitle, - style: TextStyle( - fontSize: 11.5, - height: 1.35, - color: EtmTokens.mutedOf(context))), + _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, ], @@ -642,32 +1436,65 @@ class _Section extends StatelessWidget { } } -/// Note explicative. Le `**gras**` markdown y est rendu littéralement — volontairement : -/// ces textes sont relus par des installateurs sur un écran de téléphone, et une -/// dépendance de rendu riche pour trois mots en gras ne se justifie pas. -class _Note extends StatelessWidget { - final String text; - final bool warn; +class _SousTitre extends StatelessWidget { + final String texte; + final String? badge; - const _Note(this.text, {this.warn = false}); + const _SousTitre(this.texte, {this.badge}); @override Widget build(BuildContext context) { - final color = warn ? EtmTokens.orange : EtmTokens.mutedOf(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(warn ? Icons.warning_amber_rounded : Icons.info_outline, - size: 15, color: color), - const SizedBox(width: 7), + Icon(alerte ? Icons.warning_amber_rounded : Icons.info_outline, + size: 14, color: couleur), + const SizedBox(width: 6), Expanded( - child: Text( - text.replaceAll('**', ''), - style: TextStyle( - fontSize: 11.5, - height: 1.4, - color: warn ? EtmTokens.inkOf(context) : color), - ), + child: Text(texte, + style: TextStyle( + fontSize: 11, + height: 1.4, + color: alerte ? EtmTokens.inkOf(context) : couleur)), ), ], ); diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index ec2306f..a757b1b 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -8,6 +8,7 @@ import '../providers/load_config_provider.dart'; import '../providers/load_telemetry_provider.dart'; import '../services/load_hardware.dart'; import '../services/load_priority.dart'; +import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; import 'load_cards.dart'; @@ -70,6 +71,9 @@ class ControllableLoadsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire + // tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer. + TelemetryHeader(telemetry: telemetry), for (var i = 0; i < groups.length; i++) _DomainBlock( key: ValueKey('domain-${groups[i].domain}'), @@ -80,6 +84,10 @@ class ControllableLoadsSection extends StatelessWidget { telemetry: telemetry, onConfigure: onConfigure, ), + // La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine + // ci-dessus, et sans cette section elle serait tout simplement invisible — ce + // qu'un installateur qui vient de la configurer lit comme une perte. + const BorneSection(), if (provider.hasPendingChanges) ...[ const SizedBox(height: 4), ImpactSaveBar(provider: provider), @@ -200,6 +208,27 @@ class LoadConfigCard extends StatelessWidget { _ => t.adapterUnknown(entry.adapter), }; + /// « Routeur de relais · 3 relais · min ON 60 s » — le mécanisme, son matériel, ses + /// verrous. Chaque morceau n'apparaît que s'il a une valeur à montrer. + String _resume(L10n t) { + final bouts = [_adapterLabel(t)]; + if (entry.isRelayRouter) { + bouts.add('${entry.relays.length} relais'); + } else if (entry.isSgReady) { + bouts.add('${entry.sgReadyStates.length} états'); + if (entry.minStateHoldS > 0) { + bouts.add('maintien d\'état ${entry.minStateHoldS} s'); + } + } else if (entry.maxPowerW > 0) { + bouts.add('${entry.maxPowerW} W'); + } + if (!entry.isSgReady) { + if (entry.minOnS > 0) bouts.add('min ON ${entry.minOnS} s'); + if (entry.minOffS > 0) bouts.add('min OFF ${entry.minOffS} s'); + } + return bouts.join(' · '); + } + @override Widget build(BuildContext context) { final t = context.l10n; @@ -223,8 +252,27 @@ class LoadConfigCard extends StatelessWidget { ReorderableDragStartListener( index: handleIndex, child: Padding( - padding: const EdgeInsets.only(top: 2, right: 8), - child: Icon(Icons.drag_indicator_rounded, size: 20, color: muted), + padding: const EdgeInsets.only(top: 1, right: 9), + child: Column(children: [ + Icon(Icons.drag_indicator_rounded, size: 20, color: muted), + const SizedBox(height: 3), + // Le rang est ce que le glisser-déposer produit : il se lit ici, + // pas noyé dans une phrase. + Container( + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: EtmTokens.brand.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text('${entry.priority}', + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: EtmTokens.brand)), + ), + ]), ), ), Expanded( @@ -242,8 +290,14 @@ class LoadConfigCard extends StatelessWidget { ), ), const SizedBox(height: 2), - Text('${_adapterLabel(t)} · rang ${entry.priority}', - style: TextStyle(fontSize: 11.5, color: muted)), + // Ce que l'installateur doit reconnaître d'un coup d'œil : le + // mécanisme, ce qu'il commande, et les verrous qui le brident. + // « rang 1 » n'apprend rien ici — la pastille le dit déjà. + Text(_resume(t), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, height: 1.3, color: muted)), ], ), ), @@ -607,3 +661,93 @@ class _EmptyNote extends StatelessWidget { ); } } + + +/// Les bornes de recharge, qui **ne sont pas des charges pilotables**. +/// +/// ## Pourquoi elles ont leur propre section +/// +/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par +/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît +/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa +/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]` +/// à l'étape 3g. +/// +/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non, +/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la +/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu. +class BorneSection extends StatelessWidget { + const BorneSection({super.key}); + + @override + Widget build(BuildContext context) { + final service = context.watch(); + final classesBorne = { + for (final c in service.thingClasses) + if (c.interfaces.contains('evcharger')) c.id + }; + final bornes = [ + for (final t in service.things) + if (classesBorne.contains(t.thingClassId)) t + ]; + if (bornes.isEmpty) return const SizedBox.shrink(); + + final muted = EtmTokens.mutedOf(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + Text('Recharge véhicule', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 8), + for (final b in bornes) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: EtmTokens.surfaceOf(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusCard), + border: Border.all(color: EtmTokens.lineOf(context)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.ev_station_rounded, size: 19, color: muted), + const SizedBox(width: 9), + Expanded( + child: Text(b.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + ), + ]), + const SizedBox(height: 6), + Text( + 'Pas une charge pilotable : la borne se règle par un autre contrat ' + '(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en ' + 'amont du waterfall — sa puissance est réservée dans le budget, pas ' + 'allouée depuis lui.', + style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), + ), + const SizedBox(height: 4), + Text( + 'Le mode de recharge se règle depuis la carte véhicule du tableau de ' + 'bord.', + style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index e39b6b7..49d04e1 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -236,3 +236,160 @@ class LoadFaultBox extends StatelessWidget { } } +// ══════════════════════════════════════════════════════════════════════════════ +// En-tête d'arbitrage — repris de l'écran « Ordre de service » à sa disparition. +// +// Il avait été perdu à la fusion, et c'est un manque qui se voit : sans le budget du +// cycle, les allocations affichées sur chaque carte ne se rattachent à rien. « Alloué +// 3 000 W » ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à +// distribuer. +// ══════════════════════════════════════════════════════════════════════════════ + +class TelemetryHeader extends StatelessWidget { + final LoadTelemetryProvider telemetry; + const TelemetryHeader({super.key, required this.telemetry}); + + @override + Widget build(BuildContext context) { + final t = context.l10n; + final snapshot = telemetry.telemetry; + if (snapshot == null) { + return telemetry.loading + ? const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator(minHeight: 2)) + : const SizedBox.shrink(); + } + + final age = telemetry.displayedAge; + final stale = telemetry.stale; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon( + stale ? Icons.history_toggle_off_rounded : Icons.sensors_rounded, + size: 20, + color: stale ? const Color(0xFFB26A00) : const Color(0xFF2E7D32), + ), + const SizedBox(width: 8), + Expanded( + child: Text(t.telemetryTitle, + style: + const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)), + ), + ]), + const SizedBox(height: 8), + + // Fraîcheur — un arbitre figé ne doit pas ressembler à une installation + // stable. L'absence de cycle est dite pour elle-même, jamais rendue par + // un âge de zéro. + if (snapshot.hasNeverRun) + _Note( + icon: Icons.hourglass_empty_rounded, + color: const Color(0xFFB26A00), + text: t.telemetryNeverRan) + else if (stale) + _Note( + icon: Icons.warning_amber_rounded, + color: const Color(0xFFB26A00), + text: t.telemetryStale(age?.inSeconds ?? 0)) + else + _Note( + icon: Icons.schedule_rounded, + color: const Color(0xFF6B7280), + text: t.telemetryLastCycle(age?.inSeconds ?? 0)), + + if (snapshot.degradedMode) ...[ + const SizedBox(height: 6), + _Note( + icon: Icons.report_gmailerrorred_rounded, + color: const Color(0xFFC62828), + text: t.telemetryDegradedMode), + ], + + const SizedBox(height: 12), + Text(t.telemetryBudgetTitle, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + color: Color(0xFF6B7280))), + const SizedBox(height: 6), + + // `budget` absent = repli L2 : il n'existe AUCUN budget pour ce cycle. + // Afficher des zéros se lirait « arbitrage exécuté, rien à allouer ». + if (snapshot.budget == null) + Text(t.telemetryBudgetAbsent, + style: const TextStyle(fontSize: 12, color: Color(0xFFC62828))) + else + _BudgetTable(budget: snapshot.budget!), + ], + ), + ), + ); + } +} + +class _BudgetTable extends StatelessWidget { + final TelemetryBudget budget; + const _BudgetTable({required this.budget}); + + @override + Widget build(BuildContext context) { + final t = context.l10n; + final rows = <(String, double)>[ + (t.budgetSurplus, budget.surplusW), + (t.budgetEvReserved, budget.evReservedW), + (t.budgetRecredited, budget.recreditedW), + (t.budgetAllocated, budget.allocatedW), + (t.budgetRemaining, budget.remainingW), + ]; + return Column( + children: [ + for (final (label, value) in rows) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row(children: [ + Expanded( + child: Text(label, + style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))), + ), + Text(t.watts(value.round()), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()])), + ]), + ), + ], + ); + } +} + +// ── Un domaine et ses charges ──────────────────────────────────────────────── + +/// Ligne d'information de l'en-tête d'arbitrage. +class _Note extends StatelessWidget { + final IconData icon; + final Color color; + final String text; + + const _Note({required this.icon, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 15, color: color), + const SizedBox(width: 6), + Expanded( + child: Text(text, style: TextStyle(fontSize: 12, color: color)), + ), + ]); + } +} diff --git a/lib/widgets/thing_picker.dart b/lib/widgets/thing_picker.dart new file mode 100644 index 0000000..eb934d8 --- /dev/null +++ b/lib/widgets/thing_picker.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; + +import '../models/load_config_entry.dart'; +import '../models/nymea_models.dart'; +import '../services/nymea_service.dart'; +import '../theme/etm_tokens.dart'; + +/// Choisit un Thing **par son nom**, filtré sur une interface nymea. +/// +/// ## Pourquoi un sélecteur, et pas un champ de saisie +/// +/// La configuration d'une charge ne contient que des `ThingId` — des UUID. Les faire taper +/// au doigt est une erreur garantie, et une erreur **muette** : la box accepte un UUID +/// syntaxiquement valide qui ne désigne rien, et la charge ne commandera jamais rien. +/// +/// Le sélecteur résout **nom → ThingId** et n'ajoute **aucun champ au contrat** : ce qui +/// part reste `{thingId, powerW}`. Il est purement d'interface. +/// +/// ## Le filtre par interface est ce qui rend la liste utilisable +/// +/// Un routeur de relais commande des Things portant l'interface `power` ; une charge +/// modulable un Thing portant `etmvariableload`. Sans filtre, l'installateur choisit dans +/// une liste où figurent l'onduleur, la batterie et le compteur — et rien ne l'avertirait +/// de son erreur avant le premier cycle d'arbitrage. +class ThingPicker extends StatelessWidget { + /// Interface nymea exigée (`power`, `etmvariableload`, …). + final String interfaceRequise; + + /// ThingId actuellement choisi, forme `{uuid}` ou nue. `null` = rien de choisi. + final String? valeur; + + /// Appelé avec le ThingId **dans la forme rendue par la box**. + final ValueChanged onChoisi; + + /// Texte affiché quand rien n'est choisi. + final String placeholder; + + final NymeaService service; + + /// ThingIds déjà pris par d'autres lignes — grisés, parce que la box refuse **en bloc** + /// une configuration où un même Thing apparaît deux fois (LM-302-b / ECS-110-b). + final Set dejaPris; + + const ThingPicker({ + super.key, + required this.service, + required this.interfaceRequise, + required this.valeur, + required this.onChoisi, + this.placeholder = 'Choisir un appareil…', + this.dejaPris = const {}, + }); + + /// Things portant [interfaceRequise], triés par nom. + List _candidats() { + final classesOk = { + for (final c in service.thingClasses) + if (c.interfaces.contains(interfaceRequise)) c.id + }; + final out = [ + for (final t in service.things) + if (classesOk.contains(t.thingClassId)) t + ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return out; + } + + /// Nom lisible du Thing choisi, ou `null` s'il n'est pas (ou plus) sur la box. + String? nomDe(String? id) { + if (id == null) return null; + for (final t in service.things) { + if (normalizeThingId(t.id) == normalizeThingId(id)) return t.name; + } + return null; + } + + @override + Widget build(BuildContext context) { + final candidats = _candidats(); + final nom = nomDe(valeur); + final muted = EtmTokens.mutedOf(context); + + // Un Thing configuré qui a disparu de la box est un état À DIRE : la charge le + // commande toujours dans la config, mais plus rien ne répond en face. + final orphelin = valeur != null && valeur!.isNotEmpty && nom == null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: candidats.isEmpty ? null : () => _ouvrir(context, candidats), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: orphelin + ? EtmTokens.danger.withValues(alpha: 0.6) + : EtmTokens.lineOf(context)), + ), + child: Row(children: [ + Expanded( + child: Text( + nom ?? (orphelin ? 'Appareil absent de la box' : placeholder), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + color: nom == null ? muted : EtmTokens.inkOf(context), + fontWeight: nom == null ? FontWeight.w400 : FontWeight.w600, + ), + ), + ), + Icon(Icons.expand_more_rounded, size: 20, color: muted), + ]), + ), + ), + if (orphelin) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Ce ThingId est dans la configuration mais n\'existe plus sur la box ' + '(${valeur!}). La charge ne commandera rien tant qu\'il n\'est pas remplacé.', + style: const TextStyle( + fontSize: 11, height: 1.3, color: EtmTokens.danger), + ), + ) + else if (candidats.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Aucun appareil « $interfaceRequise » sur cette box. Il faut d\'abord ' + 'l\'appairer dans les Things.', + style: TextStyle(fontSize: 11, height: 1.3, color: muted), + ), + ), + ], + ); + } + + void _ouvrir(BuildContext context, List candidats) { + showModalBottomSheet( + context: context, + backgroundColor: EtmTokens.surfaceOf(context), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (c) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 4), + child: Text('Appareils « $interfaceRequise »', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: EtmTokens.inkOf(c))), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 10), + child: Text( + 'Choisis par nom ; l\'app enregistre son identifiant.', + style: + TextStyle(fontSize: 11.5, color: EtmTokens.mutedOf(c)), + ), + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: candidats.length, + itemBuilder: (_, i) { + final t = candidats[i]; + final pris = dejaPris.contains(normalizeThingId(t.id)) && + normalizeThingId(t.id) != normalizeThingId(valeur ?? ''); + final choisi = normalizeThingId(t.id) == normalizeThingId(valeur ?? ''); + return ListTile( + enabled: !pris, + dense: true, + leading: Icon( + choisi + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + color: choisi ? EtmTokens.brand : EtmTokens.mutedOf(c), + ), + title: Text(t.name, + style: TextStyle( + fontSize: 14, + color: pris + ? EtmTokens.mutedOf(c) + : EtmTokens.inkOf(c))), + subtitle: pris + ? const Text('déjà utilisé par une autre ligne', + style: TextStyle(fontSize: 11)) + : null, + onTap: pris + ? null + : () { + onChoisi(t.id); + Navigator.of(c).pop(); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index 4eae48e..68e6295 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -16,6 +16,7 @@ import 'package:etm_powersync_app/models/load_config_entry.dart'; import 'package:etm_powersync_app/providers/load_config_provider.dart'; import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/services/load_hardware.dart'; +import 'package:etm_powersync_app/services/nymea_service.dart'; import 'package:etm_powersync_app/services/rpc_schema.dart'; import 'package:etm_powersync_app/widgets/controllable_loads_section.dart'; import 'package:flutter/material.dart'; @@ -45,6 +46,10 @@ Widget _harness({ providers: [ ChangeNotifierProvider.value(value: config), ChangeNotifierProvider.value(value: telemetry), + // La section « Recharge véhicule » lit les Things de la box pour savoir s'il y a + // une borne. Hors connexion, la liste est vide et la section s'efface — c'est + // exactement le comportement attendu sur une installation sans borne. + ChangeNotifierProvider.value(value: NymeaService()), ], child: MaterialApp( locale: const Locale('fr'),