import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n_ext.dart'; import '../models/ev_charger_live.dart'; import '../models/load_config_entry.dart'; import '../providers/load_config_provider.dart'; import '../providers/load_telemetry_provider.dart'; import '../services/load_hardware.dart'; import '../services/nymea_service.dart'; import '../services/load_priority.dart'; import '../theme/etm_tokens.dart'; import 'load_cards.dart'; /// Les **charges pilotables** de l'écran « Rôles & appareils », telles que la box les /// déclare. /// /// ## Le sens de la fusion /// /// Cette section remplace l'ancien écran « Ordre de service » — c'est son **chemin de /// données** qui a migré ici, pas l'inverse. La distinction n'est pas de forme : l'écran /// des rôles reposait sur une énumération figée de six rôles /// (`gridMeter`, `solarMeter`, `evCharger`, `dhw`, `heatPump`, `battery`), et une /// énumération figée ne peut pas décrire une installation. /// /// **LM-201 le dit sans détour : une charge existe quand il y a un canal de commande /// distinct pour un service.** Un canal, une charge. Une PAC air/eau qui fait chauffage /// ET ECS sans ballon séparé est **une** charge — le SG-Ready commande la machine /// entière et la répartition appartient à sa régulation. Le modèle des rôles, lui, aurait /// exigé d'en déclarer deux (`dhw` **et** `heatPump`) pour une seule machine, donc deux /// entrées se disputant un canal unique. C'est exactement ce que `validateSet()` refuse /// côté box, et ce refus n'est pas une limite subie : c'est le modèle. /// /// La liste affichée vient donc de `GetLoadConfig`, avec ses identifiants et ses domaines /// tels quels — y compris ceux qu'aucune version d'app ne connaît encore. /// /// ## Deux sources qu'il ne faut pas confondre /// /// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable ; /// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a fait au dernier cycle, /// en lecture seule et jamais reconstruite côté app. /// /// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en /// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie /// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur. class ControllableLoadsSection extends StatefulWidget { /// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la /// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de /// formulaire unique à ouvrir. final void Function(BuildContext, LoadConfigEntry) onConfigure; const ControllableLoadsSection({super.key, required this.onConfigure}); @override State createState() => _ControllableLoadsSectionState(); } class _ControllableLoadsSectionState extends State { /// Plages repliées, par l'**identifiant de leur première charge**. /// /// Ni par index — il replierait le voisin dès qu'une plage se déplace — ni par code de /// domaine, depuis que les groupes sont des plages contiguës : deux plages d'un même /// domaine partageraient la clé et se replieraient ensemble. /// /// **Non persisté**, délibérément : c'est une commodité de lecture, pas une /// configuration. La rendre durable ferait retrouver un écran à moitié caché des /// semaines plus tard, sans savoir pourquoi. final Set _replies = {}; @override Widget build(BuildContext context) { final provider = context.watch(); final telemetry = context.watch(); final groups = provider.groups; if (provider.loading && groups.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ); } if (groups.isEmpty) { return _EmptyNote(error: provider.error); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // La note de rang reste EN TÊTE : elle prévient que l'ordre affiché juste en // dessous n'en est pas un. La lire après les cartes arriverait trop tard. _NoteOrdreEntrelace(provider: provider), _NoteRangsExAequo(entries: provider.entries), // Le niveau glissable est celui des DOMAINES, pas des cartes. // // Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et // l'intérieur devient inerte. Il faut donc choisir, et le choix se décide par ce // que l'installateur cherche à faire : sur une installation ordinaire, chaque // domaine porte UNE charge, et l'ordre de service se règle donc entre domaines. // C'est là que la prise doit être. Réordonner deux charges d'un même domaine reste // possible, par les flèches de leur carte. ReorderableListView.builder( shrinkWrap: true, primary: false, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, itemCount: groups.length, onReorder: provider.reorderDomains, itemBuilder: (context, i) => _DomainBlock( // ⚠️ La clé porte l'id de la PREMIÈRE charge de la plage, pas le domaine : // deux plages d'un même domaine partageraient sinon la même clé, et une liste // réordonnable à clés dupliquées casse — l'assertion tombe dans les // sémantiques, loin de la cause. key: ValueKey('plage-${groups[i].loads.first.id}'), group: groups[i], index: i, total: groups.length, provider: provider, telemetry: telemetry, onConfigure: widget.onConfigure, // Le repli suit la PLAGE, pas le domaine : deux plages d'un même domaine se // replieraient ensemble, et l'une des deux se fermerait sans qu'on l'ait // touchée. replie: _replies.contains(groups[i].loads.first.id), onReplier: () => setState(() { final k = groups[i].loads.first.id; _replies.contains(k) ? _replies.remove(k) : _replies.add(k); }), ), ), // Plus de section séparée pour les bornes : depuis `+etm23` elles ONT une entrée // `GetLoadConfig` (`adapter: "evcharger"`, `domain: "ev"`, un rang), donc elles // apparaissent dans leur domaine comme n'importe quelle charge. La section qui // existait pour les rattraper les ferait maintenant apparaître DEUX fois, en // affirmant qu'elles n'ont « ni rang ni domaine » — ce qui n'est plus vrai. if (provider.hasPendingChanges) ...[ const SizedBox(height: 4), ImpactSaveBar(provider: provider), ], // L'arbitrage en direct EN BAS. // // Il était en tête, avec cet argument : « 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 ». L'argument // reste juste pour LIRE une allocation — mais cet écran sert à CONFIGURER, et // faire descendre l'installateur sous un pavé de télémétrie à chaque ouverture // met la lecture devant le geste. Le budget reste là, sous les charges qu'il // explique, atteignable quand on le cherche. const SizedBox(height: 4), TelemetryHeader(telemetry: telemetry), ], ); } } /// **L'ordre n'est pas groupé par domaine — et ce n'est pas un défaut.** /// /// ## Elle a cessé d'être un avertissement le 2026-08-29 /// /// Elle annonçait un ordre « coupé en deux » et prévenait que l'enregistrement le /// regrouperait. Les deux moitiés de cette phrase ont changé : /// /// - **l'entrelacement n'est pas une incohérence.** La contiguïté n'a jamais été un /// invariant : le waterfall trie sur le **rang seul**, et `domain` n'est pas lu par /// l'arbitre. Depuis `+etm27`, une borne détectée est même rangée en queue de liste, ce /// qui produit un ordre non groupé sur une installation parfaitement saine ; /// - **l'enregistrement ne regroupe plus.** Un groupe est désormais une **plage /// contiguë** : l'affichage épouse l'ordre réel, et réaplatir rend exactement ce qui a /// été lu. Un regroupement subi serait une machine qui réordonne ce qu'un humain a /// décidé — ce que LM-1209 interdit au moteur, et qu'on ne va pas faire à sa place. /// /// Ce qui reste : une **information**, et un **geste** pour qui veut grouper — un bouton, /// dont l'effet se voit en brouillon et s'annule avant écriture. L'annoncer comme un défaut /// pousserait à réparer un ordre que personne n'a cassé. class _NoteOrdreEntrelace extends StatelessWidget { final LoadConfigProvider provider; const _NoteOrdreEntrelace({required this.provider}); @override Widget build(BuildContext context) { if (!provider.isNotGrouped) return const SizedBox.shrink(); final doms = provider.interleavedDomains .map((d) => domainLabel(context.l10n, d)) .join(', '); return Padding( padding: const EdgeInsets.only(bottom: 10), child: Container( width: double.infinity, padding: const EdgeInsets.all(11), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), border: Border.all(color: EtmTokens.lineOf(context)), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.segment_rounded, size: 15, color: EtmTokens.mutedOf(context)), const SizedBox(width: 7), Expanded( child: Text( 'L\'ordre de service n\'est pas groupé par domaine : $doms apparaît en ' 'plusieurs endroits. Ce n\'est pas un défaut — l\'arbitrage se fait sur le ' 'rang seul, le domaine n\'est qu\'une étiquette. Les blocs ci-dessous ' 'suivent l\'ordre réel de la box, et l\'enregistrement ne le regroupera pas.', style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), ), ), ]), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: provider.groupByDomainNow, icon: const Icon(Icons.merge_rounded, size: 16), label: const Text('Regrouper par domaine', style: TextStyle(fontSize: 12)), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: EtmTokens.brand, ), ), ), ]), ), ); } } /// **« Rang par défaut, pas un choix »** — `rankOrigin == "auto"` (`1.15.2+etm27`). /// /// ## Ce qu'il remplace, et pourquoi il le fait mieux /// /// La note de rangs ex æquo portait cette idée jusqu'au 2026-08-29 : elle la **déduisait** /// d'une égalité de rangs, donc elle ne pouvait rien dire d'une borne arrivée seule sur une /// installation où son rang ne heurtait personne. Le moteur publie désormais la /// provenance ; l'app la lit au lieu de la supposer, charge par charge. /// /// Depuis `+etm27`, une borne auto-provisionnée entre en **queue** de liste (`1 + max`) et /// ne passe donc plus devant l'ECS que l'installateur a classé exprès — le badge n'annonce /// plus un problème d'ordre, seulement une **provenance** : ce rang est celui que le moteur /// a posé, personne ne l'a regardé. /// /// ## Ce qu'il n'affirme pas /// /// Il n'apparaît que sur `"auto"`. Un champ **absent** n'affirme rien — ni choix humain, ni /// pose d'office — et une box antérieure à `+etm27` n'en publie aucun : l'écran est alors /// exactement celui d'avant, sans badge et sans mensonge. /// /// ## Les deux façons de l'éteindre, et ce qu'elles affirment /// /// - **Déplacer** la charge : le moteur efface `rankOrigin` dès qu'un rang différent arrive /// pour cet id. C'est le geste de qui voulait un autre ordre. /// - **Adopter** : écrire `rankOrigin: "user"`, c'est-à-dire « ce rang me va, je /// l'assume ». Ce n'est PAS « j'ai vu le badge » — c'est un jugement porté sur l'ordre de /// service, la seule voie pour éteindre le badge **sans changer l'arbitrage**. Il ne /// s'annule pas tout seul : une fois posé, seul un déplacement le lève, côté moteur. /// /// L'app ne peut jamais écrire `"auto"` — le moteur le refuse, et c'est ce qui empêche un /// écran d'inventer une provenance. class _BadgeRangDOffice extends StatefulWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; const _BadgeRangDOffice({required this.entry, required this.provider}); @override State<_BadgeRangDOffice> createState() => _BadgeRangDOfficeState(); } class _BadgeRangDOfficeState extends State<_BadgeRangDOffice> { bool _enCours = false; /// **« Ce rang me convient »** — écrit `rankOrigin: "user"`, et rien d'autre. /// /// Le geste est **explicite**, jamais un effet de bord d'un autre enregistrement : /// écrire `"user"` parce que l'utilisateur a touché à autre chose affirmerait un jugement /// qu'il n'a pas porté — le reproche exact qu'on fait à un `"auto"` fabriqué côté client. Future _adopter() async { final messenger = ScaffoldMessenger.of(context); if (widget.provider.hasPendingChanges) { messenger.showSnackBar(const SnackBar( content: Text('Enregistrez ou annulez vos modifications en cours : adopter un ' 'rang réécrit toute la configuration.'), )); return; } setState(() => _enCours = true); final err = await widget.provider.adoptRank(widget.entry.id); if (!mounted) return; setState(() => _enCours = false); messenger.showSnackBar(SnackBar( backgroundColor: err == null ? null : const Color(0xFF7F1D1D), content: Text(err == null ? 'Rang adopté. Il ne bouge pas — seul un déplacement le changera.' : err == 'TiedRanks' ? 'Deux charges partagent un rang : la box refuse toute la configuration ' '(1.15.2+etm27). Classez-les d\'abord.' : err == 'PendingDraft' ? 'Modifications en cours : enregistrez-les ou annulez-les d\'abord.' : 'La box a refusé ($err). Rien n\'a été modifié.'), )); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: EtmTokens.solar.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(6), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.auto_awesome_rounded, size: 11, color: EtmTokens.solar), const SizedBox(width: 4), Flexible( child: Text( 'Rang posé par la box, pas un choix', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10.5, fontWeight: FontWeight.w600, color: EtmTokens.inkOf(context)), ), ), ]), ), const SizedBox(height: 2), // ⚠️ Le libellé doit empêcher la confusion la plus coûteuse : adopter n'est PAS // classer. Sans cette phrase, un installateur « valide » un ordre qu'il voulait // changer — et il n'aura aucune raison d'y revenir, puisque le badge s'est éteint. Text( 'Adopter ce rang ne le change pas. Pour en changer, glissez la charge.', style: TextStyle(fontSize: 10.5, color: EtmTokens.mutedOf(context)), ), Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: _enCours ? null : _adopter, icon: _enCours ? const SizedBox( width: 11, height: 11, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.check_rounded, size: 15), label: const Text('Ce rang me convient', style: TextStyle(fontSize: 11.5)), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), foregroundColor: EtmTokens.brand, ), ), ), ], ); } } /// **Deux charges au même rang : la box REFUSERA cette configuration.** /// /// ## Cette note a changé de nature le 2026-08-29 /// /// Elle disait « l'ordre affiché n'est pas un choix » — une information sur l'ordre de /// service, née du rang par défaut d'une borne détectée. Ce rôle passe au **badge** /// `rankOrigin` (`_BadgeRangDOffice`), qui le dit charge par charge et sans supposition : /// deux formulations pour une même idée font qu'aucune n'est lue. /// /// Ce qui reste ici est **autre chose, et plus urgent**. Depuis `1.15.2+etm27` la box /// refuse **en bloc** tout ensemble où deux charges partagent un rang. En voir deux à /// l'écran signifie donc une configuration **héritée d'avant `+etm27`** — le plugin /// lui-même créait ces égalités — qui sera **rejetée à la première écriture**, sans motif /// lisible : le refus ne franchit pas la frontière RPC. /// /// D'où une alerte avec un remède, et non un constat : réordonner répare, et c'est le seul /// geste qui répare. Le contrôle côté suppression le dit déjà au moment du geste ; l'écran /// doit le dire **avant** qu'on le tente. /// /// Elle disparaît d'elle-même dès que les rangs sont distincts — c'est-à-dire dès que la /// configuration redevient acceptable. class _NoteRangsExAequo extends StatelessWidget { final List entries; const _NoteRangsExAequo({required this.entries}); @override Widget build(BuildContext context) { final exAequo = tiedByPriority(entries); if (exAequo.isEmpty) return const SizedBox.shrink(); final noms = exAequo.map((e) => e.label).join(', '); return Padding( padding: const EdgeInsets.only(bottom: 10), child: Container( width: double.infinity, padding: const EdgeInsets.all(11), decoration: BoxDecoration( color: EtmTokens.danger.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), border: Border.all(color: EtmTokens.danger.withValues(alpha: 0.45)), ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.report_problem_rounded, size: 15, color: EtmTokens.danger), const SizedBox(width: 7), Expanded( child: Text( 'Rangs à égalité : $noms. Depuis la version 1.15.2+etm27, la box REFUSE ' 'toute configuration où deux charges partagent un rang — celle-ci sera ' 'donc rejetée en bloc à la première écriture, y compris pour un simple ' 'renommage.\n\n' 'Le remède est le classement : donnez-leur des rangs distincts, puis ' 'enregistrez. Rien d\'autre ne répare cette configuration, et rien ne ' 'part à la box tant qu\'elle porte l\'égalité.', style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), ), ), ]), ), ); } } /// **Une borne en pleine puissance ne passe pas par l'arbitrage — et c'est son défaut.** /// /// Le moteur crée d'office une entrée de configuration par borne détectée, en /// `ChargingModeNormal` : pleine puissance, hors arbitrage. Tant que personne n'a posé un /// mode éco, le HEMS ne pilote donc pas cette borne — et rien ne le disait. C'est le /// pendant exact de la note de rangs ex æquo : un état par défaut qui a toutes les /// apparences d'un réglage. /// /// ⚠️ Elle ne dit pas « personne n'a choisi » : la box publie le même `chargingMode` pour /// un boost délibéré et pour une borne jamais réglée. Elle nomme l'ÉTAT, et rappelle qu'il /// est aussi le défaut. class _NoteBornePleinePuissance extends StatelessWidget { final LoadConfigEntry entry; const _NoteBornePleinePuissance({required this.entry}); @override Widget build(BuildContext context) { // `GetChargingInfos` porte l'`evChargerId` sans accolades, `GetLoadConfig` l'id de // charge avec : `chargingModeOf` réconcilie les deux. final mode = context.watch().chargingModeOf(entry.id); if (mode != kChargingModeNormal) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( width: double.infinity, padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: EtmTokens.solar.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar), const SizedBox(width: 7), Expanded( child: Text(kEvUnmanagedNote, style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context))), ), ]), ), ); } } /// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à /// l'intérieur — les deux niveaux que l'ordre de service demande. class _DomainBlock extends StatelessWidget { final DomainGroup group; final int index; final int total; final LoadConfigProvider provider; final LoadTelemetryProvider telemetry; final void Function(BuildContext, LoadConfigEntry) onConfigure; /// Bloc replié : l'en-tête reste, les cartes sont retirées de l'arbre. final bool replie; final VoidCallback onReplier; const _DomainBlock({ super.key, required this.group, required this.index, required this.total, required this.provider, required this.telemetry, required this.onConfigure, required this.replie, required this.onReplier, }); @override Widget build(BuildContext context) { final t = context.l10n; final muted = EtmTokens.mutedOf(context); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ // Cible tactile de 48 px — le minimum Material, et il n'est pas décoratif. // // Mesurée à **26 × 27 px** sur le Redmi Note 9S au premier passage du test // d'appareil. Le `Container` transparent posé lors de l'enquête précédente // avait supprimé la bande morte entre l'icône et le reste, mais il n'avait // pas AGRANDI la cible : le geste accrochait quand on visait juste, et ratait // sinon. Aucun test unitaire ne pouvait le montrer — ils ne rendent pas ces // widgets à une taille d'écran réelle. // // C'est le seul geste de cet écran qui n'a pas de repli : sans lui, l'ordre // des domaines ne se change pas du tout. ReorderableDragStartListener( key: ValueKey('poignee-plage-${group.loads.first.id}'), index: index, child: Container( color: Colors.transparent, width: 48, height: 48, alignment: Alignment.centerLeft, child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted), ), ), Expanded( child: Text( domainLabel(t, group.domain), style: TextStyle( fontWeight: FontWeight.w700, fontSize: 13, letterSpacing: 0.2, color: group.isUnclassified ? muted : EtmTokens.inkOf(context), ), ), ), // Quand le bloc est replié, dire ce qu'il contient — sinon replier revient à // faire disparaître des charges, et une charge qu'on ne voit plus se lit // comme une charge perdue. if (replie) Padding( padding: const EdgeInsets.only(right: 4), child: Text( group.loads.length > 1 ? '${group.loads.length} charges' : '${group.loads.length} charge', style: TextStyle(fontSize: 11.5, color: muted), ), ), // Un SEUL chevron, qui replie — à la place des deux flèches monter/descendre. // // Elles faisaient doublon avec la poignée de glissement, désormais vérifiée // au doigt sur appareil (48 px, réordonnancement mesuré). Deux chemins pour // le même geste, dont l'un n'a pas de repli, valaient moins qu'un chemin sûr // plus une commande qui manquait. IconButton( key: ValueKey('replier-plage-${group.loads.first.id}'), visualDensity: VisualDensity.compact, icon: Icon( replie ? Icons.keyboard_arrow_down_rounded : Icons.keyboard_arrow_up_rounded, size: 22), tooltip: replie ? 'Déplier ce domaine' : 'Replier ce domaine', onPressed: onReplier, ), ]), if (!replie) const SizedBox(height: 6), if (!replie) for (var i = 0; i < group.loads.length; i++) Padding( key: ValueKey('load-${group.loads[i].id}'), padding: const EdgeInsets.only(bottom: 10), child: LoadConfigCard( entry: group.loads[i], groupIndex: index, indexDansDomaine: i, totalDansDomaine: group.loads.length, provider: provider, telemetry: telemetry, onConfigure: onConfigure, ), ), ], ), ); } } /// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme. class LoadConfigCard extends StatelessWidget { final LoadConfigEntry entry; /// Position dans son domaine, et taille du domaine. Ensemble, ils disent si un /// réordonnancement INTERNE a un sens — seul dans son domaine, il n'y a rien à déplacer, /// et l'ordre se change alors en glissant le domaine. final int indexDansDomaine; final int totalDansDomaine; /// Index de la PLAGE à laquelle la charge appartient. Depuis le 2026-08-29 un groupe est /// une plage contiguë, donc un même domaine peut en occuper plusieurs : le désigner par /// son code déplacerait la charge dans la première plage venue. final int groupIndex; final LoadConfigProvider provider; final LoadTelemetryProvider telemetry; final void Function(BuildContext, LoadConfigEntry) onConfigure; const LoadConfigCard({ super.key, required this.entry, required this.indexDansDomaine, required this.totalDansDomaine, required this.groupIndex, required this.provider, required this.telemetry, required this.onConfigure, }); /// Libellé du mécanisme. Code inconnu → repli lisible, jamais une phrase vide : une /// box plus récente peut publier un mécanisme que cette version d'app ignore. String _adapterLabel(L10n t) => switch (entry.adapter) { 'relay-router' => t.adapterRelayRouter, 'etmvariableload' => t.adapterEtmVariableLoad, 'sg-ready' => t.adapterSgReady, 'evcharger' => t.adapterEvCharger, _ => 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; final muted = EtmTokens.mutedOf(context); final live = telemetry.entryFor(entry.id); // Dernière charge de la dernière plage : elle est déjà en queue de l'ordre de service. final plages = provider.groups; final estDejaEnQueue = plages.isNotEmpty && plages.last.loads.isNotEmpty && plages.last.loads.last.id == entry.id; 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: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Le rang, et — si le domaine porte plusieurs charges — de quoi les // ordonner entre elles. Le glissement, lui, appartient au domaine. Padding( padding: const EdgeInsets.only(top: 1, right: 9), child: Column(children: [ 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)), ), // Les deux flèches CÔTE À CÔTE, pas empilées. // // Empilées, deux cibles de 44 px font 88 px de haut pour deux icônes de // 18 : la colonne de rang devient une colonne vide, constaté à la // capture d'appareil. Côte à côte, la hauteur retombe à 44 et la // largeur — 88 px sur une carte pleine largeur — ne coûte rien au // libellé. // // La cible tactile, elle, ne bouge pas : c'est la contrainte, pas la // disposition. if (totalDansDomaine > 1) Row(mainAxisSize: MainAxisSize.min, children: [ _FlecheRang( key: ValueKey('monter-${entry.id}'), icone: Icons.keyboard_arrow_up_rounded, actif: indexDansDomaine > 0, onTap: () => provider.reorderWithinGroup( groupIndex, indexDansDomaine, indexDansDomaine - 1), ), _FlecheRang( key: ValueKey('descendre-${entry.id}'), icone: Icons.keyboard_arrow_down_rounded, actif: indexDansDomaine < totalDansDomaine - 1, onTap: () => provider.reorderWithinGroup( groupIndex, indexDansDomaine, indexDansDomaine + 2), ), ]), ]), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.label, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: entry.enabled ? EtmTokens.inkOf(context) : muted, ), ), const SizedBox(height: 2), // Le rang de CETTE charge vient-il du moteur ? Le badge le dit ici, // charge par charge — là où la note d'ensemble ne pouvait que le // supposer d'une égalité. if (entry.rangPoseDOffice) ...[ _BadgeRangDOffice(entry: entry, provider: provider), const SizedBox(height: 3), ], // 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)), ], ), ), // `enabled` : entrée/sortie de l'arbitrage. Le libellé de l'aide dit ce que // le drapeau fait VRAIMENT — voir _EnabledSwitch. _EnabledSwitch(entry: entry, provider: provider), ], ), const SizedBox(height: 10), Row(children: [ DomainMenu( key: ValueKey('chip-domaine-${entry.id}'), entry: entry, provider: provider), const Spacer(), TextButton.icon( onPressed: () => onConfigure(context, entry), icon: const Icon(Icons.tune_rounded, size: 17), label: const Text('Configurer'), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: EtmTokens.brand, ), ), ]), // « Renommer » et « Placer en dernier » sur la MÊME ligne : ce sont les deux // gestes qui ne touchent pas au mécanisme, et les garder ensemble rend à // « Configurer » sa place — un bouton d'action principale ne se fait pas // pousser par un geste secondaire. _LigneGestes( entry: entry, provider: provider, dejaEnQueue: estDejaEnQueue), const SizedBox(height: 8), Divider(height: 1, color: EtmTokens.lineOf(context)), const SizedBox(height: 8), // ── Une borne à pleine puissance n'est pas arbitrée ──────────────── // // Elle se lit ici et nulle part ailleurs : la télémétrie n'en dit rien (une // borne en `ChargingModeNormal` sans véhicule branché est simplement absente de // `loads[]`), et l'écran de configuration est celui que l'installateur ouvre à // la mise en service. Sans cette ligne, il pose une borne, elle charge à fond, // et il cherche du côté du HEMS un défaut qui n'existe pas. if (entry.isEvCharger) _NoteBornePleinePuissance(entry: entry), if (live != null) LoadLiveBlock( live: live, stale: telemetry.stale, telemetry: telemetry, ) else // Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement — // un vide se lirait comme une panne. Row(children: [ Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted), const SizedBox(width: 5), Expanded( child: Text( entry.enabled ? 'Non arbitrée à ce cycle — configurée, mais absente de la ' 'télémétrie du dernier cycle.' : 'Désactivée — rôle déclaré, exclue de l\'arbitrage.', style: TextStyle(fontSize: 11.5, color: muted, height: 1.3), ), ), ]), ], ), ); } } /// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement. class _EnabledSwitch extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; const _EnabledSwitch({required this.entry, required this.provider}); @override Widget build(BuildContext context) { return Tooltip( // Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle : // sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les // contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau. message: entry.enabled ? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr ' '(contacts ouverts) avant de la retirer.\n' 'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du ' 'drapeau.' : 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.', child: Switch.adaptive( value: entry.enabled, onChanged: (v) => provider.setEnabled(entry.id, v), ), ); } } /// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé. /// Les deux gestes qui ne touchent pas au mécanisme : renommer, et sortir de sa plage. class _LigneGestes extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; final bool dejaEnQueue; const _LigneGestes({ required this.entry, required this.provider, required this.dejaEnQueue, }); @override Widget build(BuildContext context) { return Row(children: [ Flexible(child: _RenameRow(entry: entry, provider: provider)), // « Placer en dernier » — le seul geste qui SORT une charge de sa plage. // // Les flèches la déplacent dans son bloc, le glissement déplace le bloc entier : // sans lui, une charge regroupée est prisonnière de son groupe. Une fois seule en // queue, le glissement de plage la porte partout ailleurs. // // Masqué quand elle y est déjà : un bouton qui ne fait rien use la confiance dans // ceux qui font quelque chose. if (!dejaEnQueue) TextButton.icon( onPressed: () => provider.moveToTail(entry.id), icon: const Icon(Icons.vertical_align_bottom_rounded, size: 15), // ⚠️ Le style va sur le LIBELLÉ, pas dans `styleFrom(textStyle:)` : celui-ci // porte `inherit: true` là où le thème porte `inherit: false`, et Flutter refuse // d'interpoler les deux — l'assertion tombe pendant l'animation du glissement, // très loin de sa cause. label: const Text('Placer en dernier', style: TextStyle(fontSize: 12)), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: EtmTokens.mutedOf(context), ), ), ]); } } class _RenameRow extends StatefulWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; const _RenameRow({required this.entry, required this.provider}); @override State<_RenameRow> createState() => _RenameRowState(); } class _RenameRowState extends State<_RenameRow> { bool _editing = false; late final TextEditingController _c = TextEditingController(text: widget.entry.label); @override void dispose() { _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (!_editing) { return Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () => setState(() => _editing = true), icon: const Icon(Icons.edit_outlined, size: 15), label: const Text('Renommer'), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: EtmTokens.mutedOf(context), textStyle: const TextStyle(fontSize: 12), ), ), ); } return Padding( padding: const EdgeInsets.only(top: 6), child: Row(children: [ Expanded( child: TextField( controller: _c, autofocus: true, style: const TextStyle(fontSize: 14), decoration: const InputDecoration( isDense: true, labelText: 'Libellé', helperText: 'Sans effet sur le matériel — aucun verrou réarmé.', helperMaxLines: 2, ), onSubmitted: (_) => _commit(), ), ), IconButton( icon: const Icon(Icons.check_rounded, size: 20), onPressed: _commit, ), IconButton( icon: const Icon(Icons.close_rounded, size: 20), onPressed: () => setState(() { _c.text = widget.entry.label; _editing = false; }), ), ]), ); } void _commit() { final v = _c.text.trim(); if (v.isNotEmpty && v != widget.entry.label) { widget.provider.setLabel(widget.entry.id, v); } setState(() => _editing = false); } } /// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**. /// /// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à /// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things /// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un /// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est /// bloquée un quart d'heure. class ImpactSaveBar extends StatelessWidget { final LoadConfigProvider provider; const ImpactSaveBar({super.key, required this.provider}); @override Widget build(BuildContext context) { final impacts = provider.pendingImpact(); final warnings = impacts.where((i) => i.needsWarning).toList(); final pending = provider.saveState == LoadSaveState.pending; return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), border: Border.all( color: warnings.isEmpty ? EtmTokens.lineOf(context) : EtmTokens.orange.withValues(alpha: 0.6), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (warnings.isNotEmpty) ...[ for (final w in warnings) _ImpactLine(impact: w), const SizedBox(height: 8), ], Row(children: [ Expanded( child: Text( warnings.isEmpty ? 'Modifications en attente — rien n\'est parti vers la box.' : 'À confirmer : l\'enregistrement touche au matériel.', style: TextStyle( fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3), ), ), TextButton( onPressed: pending ? null : provider.discardDraft, child: const Text('Annuler'), ), const SizedBox(width: 4), FilledButton( onPressed: pending ? null : provider.save, child: Text(pending ? 'Envoi…' : 'Enregistrer'), ), ]), if (provider.saveState == LoadSaveState.rejected) _RejectionNote(provider: provider), ], ), ); } } /// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché. /// /// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera /// reconstruite » ne l'est pas. class _ImpactLine extends StatelessWidget { final LoadImpact impact; const _ImpactLine({required this.impact}); static const _fieldNames = { 'relays': 'la liste des contacteurs', 'sgReady': 'l\'encodage SG-Ready', 'minOnS': 'la temporisation de marche', 'minOffS': 'la temporisation d\'arrêt', 'maxPowerW': 'la puissance nominale', 'powerLevels': 'les paliers déclarés', 'adapter': 'le mécanisme', 'mode': 'le mode', }; @override Widget build(BuildContext context) { final quoi = impact.changedFields .map((f) => _fieldNames[f] ?? f) .join(', '); final rearm = impact.rearmSeconds; final duree = rearm == null ? '' : rearm >= 60 ? ' pendant ${(rearm / 60).round()} min' : ' pendant $rearm s'; final texte = switch (impact.impact) { LoadChangeImpact.rebuild => '« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : ' 'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.', LoadChangeImpact.removed => '« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr ' '(contacts ouverts) avant retrait.', _ => '', }; if (texte.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange), const SizedBox(width: 7), Expanded( child: Text(texte, style: TextStyle( fontSize: 12, height: 1.35, color: EtmTokens.inkOf(context))), ), ], ), ); } } /// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et /// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie. class _RejectionNote extends StatelessWidget { final LoadConfigProvider provider; const _RejectionNote({required this.provider}); @override Widget build(BuildContext context) { final conflit = provider.conflict; final err = provider.saveError; return Padding( padding: const EdgeInsets.only(top: 10), child: Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: EtmTokens.danger.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( switch (conflit?.kind) { 'thingClaimedTwice' => 'Deux charges revendiquent le même appareil.', 'duplicateId' => 'Deux charges portent le même identifiant.', 'tiedPriority' => 'Deux charges se retrouvent au rang ${conflit!.rank} : ' '${conflit.firstLabel} et ${conflit.secondLabel}.', _ => 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.', }, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, color: EtmTokens.danger), ), const SizedBox(height: 4), Text( switch (conflit?.kind) { // LM-201 : deux charges sur un canal unique se contrediraient, et le // budget compterait la même puissance deux fois. Ce n'est pas une // limite technique — c'est le modèle. 'thingClaimedTwice' => 'Un appareil ne peut appartenir qu\'à une seule charge : deux ' 'charges sur un même canal de commande se contrediraient.', 'duplicateId' => 'Chaque charge a un identifiant unique côté box — deux entrées ne ' 'peuvent pas le partager.', // Depuis +etm27 la box refuse TOUT l'ensemble sur une égalité de rang, et // son motif ne franchit pas la frontière RPC : sans ce message, le // symptôme serait « ça ne s'enregistre pas », sans cause visible. 'tiedPriority' => 'Depuis la version 1.15.2+etm27, la box refuse toute configuration où ' 'deux charges partagent un rang. Classez-les l\'une après l\'autre ' '— rien n\'a été envoyé.', _ => 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle ' 'enfreinte et le libellé en cause sont dans le journal de la box ' '(journalctl -u nymead).', }, style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.mutedOf(context)), ), ], ), ), ); } } class _EmptyNote extends StatelessWidget { final String? error; const _EmptyNote({this.error}); @override Widget build(BuildContext context) { final muted = EtmTokens.mutedOf(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: Text( error == null ? 'Aucune charge pilotable déclarée sur cette box.' : 'Configuration illisible : $error', style: TextStyle(fontSize: 12.5, color: muted, height: 1.35), ), ); } } /// Petite flèche de rang, à l'intérieur d'un domaine. /// /// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent /// par ces flèches. Rendre les deux niveaux glissables est impossible — les listes /// réordonnables imbriquées se disputent le geste, et c'est l'extérieure qui gagne. class _FlecheRang extends StatelessWidget { final IconData icone; final bool actif; final VoidCallback onTap; const _FlecheRang({ super.key, required this.icone, required this.actif, required this.onTap, }); @override Widget build(BuildContext context) { final muted = EtmTokens.mutedOf(context); // 44 px, pas 48. Les deux flèches sont EMPILÉES dans la colonne de rang, sous le // badge : à 48 chacune, la carte gagne près de 100 px de haut pour deux commandes // secondaires. 44 est le plancher au-dessous duquel un pouce rate régulièrement, et // il tient dans la carte. Mesurées à **18 × 18 px** au premier passage sur appareil — // la taille de l'icône, sans aucune marge de touche. return InkWell( onTap: actif ? onTap : null, borderRadius: BorderRadius.circular(8), child: SizedBox( width: 44, height: 44, child: Icon(icone, size: 18, color: actif ? muted : muted.withValues(alpha: 0.3)), ), ); } }