import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n_ext.dart'; import '../models/load_config_entry.dart'; import '../providers/load_config_provider.dart'; import '../providers/load_telemetry_provider.dart'; import '../services/load_hardware.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 { /// Domaines repliés, par leur code. /// /// Clé par **code de domaine** et non par index : un domaine qui se déplace emporte /// son repli, ce qu'un index ne saurait pas faire — il replierait le voisin. /// /// **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( key: ValueKey('domain-${groups[i].domain}'), group: groups[i], index: i, total: groups.length, provider: provider, telemetry: telemetry, onConfigure: widget.onConfigure, replie: _replies.contains(groups[i].domain), onReplier: () => setState(() { final d = groups[i].domain; _replies.contains(d) ? _replies.remove(d) : _replies.add(d); }), ), ), // 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 en place n'est PAS groupé par domaine — et l'enregistrer le changerait.** /// /// `GroupedOrder` calcule cette contiguïté depuis le premier jour, et le provider /// l'expose (`isNotGrouped`). Rien ne l'affichait : le cas ne s'était jamais produit sur /// le banc, chaque domaine y ayant ses charges d'un seul tenant. /// /// Il s'est produit le 2026-08-28, à l'arrivée de la V2C. Créée d'office au rang 1 — que /// le chauffe-eau occupait déjà — elle a donné l'ordre à plat `ecs · ev · heating · ev · /// ev` : le domaine `ev` est coupé en deux par le chauffage. /// /// **Pourquoi il faut le dire, et pas seulement le savoir.** L'écran groupe les charges /// par domaine pour les afficher. Sur un ordre entrelacé, ce regroupement ne correspond /// plus à l'ordre réel de la box, et le premier enregistrement réaplatit en groupant — /// donc **change l'arbitrage** sans que personne ne l'ait demandé. Mesuré sur la /// configuration du banc : la PAC passerait du rang 2 au rang 5. /// /// La note ne bloque rien : elle nomme l'écart et annonce ce que l'enregistrement fera. /// Décider reste à l'installateur — mais en connaissance de cause. 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.orange.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.45)), ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.shuffle_rounded, size: 15, color: EtmTokens.orange), const SizedBox(width: 7), Expanded( child: Text( 'L\'ordre en place sur la box n\'est pas groupé par domaine : $doms ' 'est coupé en deux par une autre famille de charges. L\'écran les regroupe ' 'pour les afficher, donc ce que vous voyez n\'est pas l\'ordre réel.\n\n' 'Le premier enregistrement REGROUPERA pour de bon, et changera l\'ordre de ' 'service du surplus. Vérifiez les rangs avant d\'enregistrer.', style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), ), ), ]), ), ); } } /// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.** /// /// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins /// un, borné à 1 ». Dès qu'une charge occupe déjà le rang 1 — le cas de toute installation /// déjà réglée — l'intention « en tête » dégénère en égalité, et plusieurs charges se /// retrouvent au même rang. /// /// Le tri reste **total** : l'identifiant départage, donc l'ordre ne bouge pas d'un cycle /// à l'autre. Mais reproductible n'est pas choisi, et c'est toute la différence pour qui /// regarde cette liste à la mise en service. Sans cette note, l'installateur lit un /// classement, le trouve plausible, et n'y touche pas — validant par inadvertance un /// ordre que personne n'a posé. /// /// Elle disparaît d'elle-même dès que les rangs sont distincts, c'est-à-dire dès le /// premier vrai réglage. 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.solar.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.low_priority_rounded, size: 15, color: EtmTokens.inkOf(context)), const SizedBox(width: 7), Expanded( child: Text( 'Rangs à égalité : $noms. L\'ordre affiché entre elles est stable, mais ' 'ce n\'est pas un réglage — c\'est ce qui reste quand personne n\'a ' 'tranché. Les bornes détectées arrivent au rang 1 par défaut, sur lequel ' 'une autre charge est souvent déjà posée. Classez-les avant la mise en ' 'service.', 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-domaine-${group.domain}'), 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-${group.domain}'), 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], 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; 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.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); 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.reorderWithinDomain( entry.domain ?? kUnclassifiedDomain, indexDansDomaine, indexDansDomaine - 1), ), _FlecheRang( key: ValueKey('descendre-${entry.id}'), icone: Icons.keyboard_arrow_down_rounded, actif: indexDansDomaine < totalDansDomaine - 1, onTap: () => provider.reorderWithinDomain( entry.domain ?? kUnclassifiedDomain, 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), // 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, ), ), ]), _RenameRow(entry: entry, provider: provider), const SizedBox(height: 8), Divider(height: 1, color: EtmTokens.lineOf(context)), const SizedBox(height: 8), 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é. 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( conflit != null ? 'Deux charges revendiquent le même appareil.' : '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( conflit != null // 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. ? 'Un appareil ne peut appartenir qu\'à une seule charge : deux ' 'charges sur un même canal de commande se contrediraient.' : '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)), ), ); } }