etm-powersync-app/lib/providers/load_config_provider.dart
Patrick Schurig 1dece8eb33 fix(brouillon): la barre suit — et le déplacement a révélé un défaut de contrainte
La barre d'action passe en pied d'écran fixe (`bottomNavigationBar`), parce
qu'un correctif juste mais hors du champ reste inaccessible : le geste se fait
en haut de la liste, la barre était un écran entier plus bas. C'est le défaut
d'hier sous une autre forme — cause de disposition au lieu de cause de logique.

`bottomNavigationBar` plutôt qu'une superposition : il RÉDUIT la hauteur du
corps au lieu de le recouvrir, donc rien ne se cache derrière la barre.

⚠️ ET LE DÉPLACEMENT A CASSÉ L'ÉCRAN. Le `Column` d'`ImpactSaveBar` était en
`MainAxisSize.max` — le défaut. Dans un `ListView` la hauteur disponible est
infinie et le Column se contente de ses enfants : le défaut ne pouvait pas se
voir. En pied d'écran la contrainte est lâche mais BORNÉE, le Column a pris
toute la hauteur, le corps a été écrasé à zéro. Écran vide sous une barre
géante, vu en release sur appareil — les 319 tests étaient verts.

Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer
change ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.

Le test ajouté monte la barre dans un `bottomNavigationBar` et mesure sa
hauteur. Éprouvé par son contraire : sans `mainAxisSize: min` il rend 600 sur
600 et échoue. Aucun test précédent ne pouvait mordre — tous montaient la barre
dans une hauteur infinie, où `max` se comporte comme `min`.

Vérifié sur appareil, chemin des flèches : brouillon réel -> barre en bas avec
Annuler/Enregistrer, corps intact ; ordre rétabli -> « L'ordre est revenu à
celui de la box » avec le seul « Terminer » ; Terminer -> barre absente. Banc
inchangé, aucune écriture.

Et la note gardée sur `draftIsNoOp` : « rien à enregistrer » veut dire « rien
par rapport à ce que tu as vu ». Comparer à l'état vivant conclurait à la place
de l'utilisateur sur des faits qu'il n'a pas vus. Le référentiel de la
CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.

Capture : docs/captures/2026-09-08_barre-en-pied-terminer.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 08:30:11 +02:00

785 lines
32 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../models/load_config_entry.dart';
import '../services/load_hardware.dart';
import '../services/load_priority.dart';
import '../services/nymea_service.dart';
/// État de l'écriture en cours — l'acquittement d'un RPC ne vaut PAS application.
enum LoadSaveState {
/// Rien en cours.
idle,
/// `SetLoadConfig` envoyé, pas encore confirmé par `LoadConfigChanged`.
pending,
/// La box a confirmé, et ce qu'elle a confirmé correspond à ce qui a été demandé.
confirmed,
/// La box a confirmé, mais l'état reçu diffère de celui demandé.
/// L'affichage montre alors ce que la box dit, pas ce que l'utilisateur voulait.
diverged,
/// La box a refusé (ou le transport a échoué).
rejected,
}
/// Configuration des charges pilotables — lecture, réordonnancement, écriture.
///
/// **Aucune persistance côté app.** La source de vérité est
/// `/var/lib/nymea/energy-load-configuration.json` sur la box ; une copie côté téléphone
/// créerait deux configurations divergentes.
class LoadConfigProvider extends ChangeNotifier {
NymeaService? _service;
void Function()? _unsubscribe;
List<LoadConfigEntry> _entries = const [];
GroupedOrder? _grouped;
/// Ordre en cours d'édition. `null` tant que l'installateur n'a rien déplacé —
/// c'est ce qui garantit qu'aucune écriture ne peut partir sans geste explicite.
List<DomainGroup>? _draft;
bool _loading = false;
String? _error;
LoadSaveState _saveState = LoadSaveState.idle;
String? _saveError;
LoadSetConflict? _conflict;
/// Signature de ce qui a été envoyé, pour comparer à ce que la box confirme.
String? _expectedSignature;
Timer? _confirmTimeout;
// ── Lecture ────────────────────────────────────────────────────────────────
List<LoadConfigEntry> get entries => _entries;
GroupedOrder? get grouped => _grouped;
bool get loading => _loading;
String? get error => _error;
LoadSaveState get saveState => _saveState;
String? get saveError => _saveError;
LoadSetConflict? get conflict => _conflict;
/// Groupes tels qu'affichés : le brouillon s'il existe, sinon l'état de la box.
List<DomainGroup> get groups => _draft ?? _grouped?.groups ?? const [];
/// Vrai si l'ordre en place sur la box n'est pas groupé par domaine.
bool get isNotGrouped => _grouped != null && !_grouped!.isContiguous;
List<String> get interleavedDomains => _grouped?.interleavedDomains ?? const [];
/// Vrai s'il existe un brouillon — **quel que soit son contenu**.
///
/// Ne pas l'utiliser pour décider si quelque chose serait perdu : voir
/// [hasSubstantiveChanges]. Il sert à savoir s'il faut afficher la barre, y compris
/// quand elle n'a rien à enregistrer.
bool get hasPendingChanges => _draft != null;
/// **« J'ai décidé de ne rien changer » — un état, pas une absence d'état.**
///
/// Un brouillon qui rend exactement la configuration de la box. On y arrive en
/// déplaçant une plage puis en la remettant : le geste a eu lieu, l'intention a été
/// portée, et elle s'est conclue par « finalement non ».
///
/// C'était un **brouillon sans issue**, et c'est le défaut que ce champ existe pour
/// fermer. « Rétablir l'ordre initial » n'est pas « annuler » : l'écran traitait les
/// deux pareil et n'offrait plus, pour en sortir, que des portes mal nommées — la barre
/// annonçait « Modifications en attente », la sortie d'écran menaçait d'abandonner des
/// changements, l'adoption d'un rang et le retrait d'une charge réclamaient
/// d'« enregistrer ou annuler ». Quatre messages, tous faux au même moment : il n'y
/// avait plus rien à enregistrer, ni rien à perdre.
///
/// Aucun test ne pouvait le trouver — il faut faire le geste, puis le défaire, sur le
/// seul geste de cet écran qui n'a pas de repli.
///
/// ⚠️ **« Rien à enregistrer » veut dire « rien par rapport à ce que tu as vu ».** La
/// comparaison porte sur la dernière lecture de l'app, **pas sur l'état vivant de la
/// box**, et c'est délibéré : l'utilisateur conclut sur ce qu'il avait sous les yeux.
/// Comparer à l'état vivant conclurait à sa place sur des faits qu'il n'a pas vus — la
/// box a pu bouger pendant qu'il hésitait, et lui annoncer « finalement si, ça change »
/// désignerait une modification qui n'est pas la sienne.
///
/// L'écriture, elle, relit toujours frais avant d'envoyer (voir [save]) : le référentiel
/// de la CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.
///
/// ⚠️ **Et surtout : ne pas effacer le brouillon tout seul quand il devient sans
/// effet.** La barre disparaîtrait sous le doigt au moment précis où l'utilisateur
/// repose la plage, et « j'ai décidé » redeviendrait « il ne s'est rien passé ». Un
/// état se conclut, il ne s'évapore pas.
bool get draftIsNoOp {
final draft = _draft;
if (draft == null) return false;
final apres = [for (final g in draft) ...g.loads];
if (apres.length != _entries.length) return false;
// Comparaison sur les charges utiles BRUTES, clés triées : le brouillon peut porter
// un patch de champ autant qu'un changement d'ordre, et seule la charge utile dit
// les deux. Un ordre de clés différent ferait conclure « ça change » à tort — c'est
// le sens sûr de l'erreur, on ne perd rien.
String canon(Map<String, dynamic> m) {
final k = m.keys.toList()..sort();
return jsonEncode({for (final key in k) key: m[key]});
}
for (var i = 0; i < apres.length; i++) {
if (canon(apres[i].raw) != canon(_entries[i].raw)) return false;
}
return true;
}
/// Vrai quand un brouillon changerait **vraiment** quelque chose.
///
/// C'est lui qui doit garder les portes : quitter l'écran, adopter un rang, retirer une
/// charge. Un brouillon sans effet ne fait rien perdre, donc il ne justifie ni alerte
/// ni blocage.
bool get hasSubstantiveChanges => _draft != null && !draftIsNoOp;
void attach(NymeaService service) {
if (identical(_service, service)) return;
_unsubscribe?.call();
_service = service;
_unsubscribe = service.onLoadConfigChanged(_onBoxConfirmed);
}
Future<void> load() async {
final service = _service;
if (service == null) return;
_loading = true;
_error = null;
notifyListeners();
try {
final raw = await service.getLoadConfig();
_adopt(raw);
} catch (e) {
_error = e.toString();
} finally {
_loading = false;
notifyListeners();
}
}
void _adopt(List<Map<String, dynamic>> raw) {
_entries = raw.map(LoadConfigEntry.new).toList();
_grouped = groupByDomain(_entries);
_draft = null;
_conflict = null;
}
/// Injecte un état de box sans transport — **tests uniquement**.
///
/// Le chemin normal est `load()` ou la notification `LoadConfigChanged` ; cette porte
/// existe pour éprouver l'édition sans monter un faux WebSocket.
@visibleForTesting
void adoptForTest(List<Map<String, dynamic>> raw) {
_adopt(raw);
notifyListeners();
}
// ── Édition (brouillon, aucune écriture) ───────────────────────────────────
/// Déplace une charge à l'intérieur de son domaine.
/// Déplace une charge à l'intérieur de **sa plage**, désignée par son index.
///
/// ⚠️ Elle était désignée par son **domaine** jusqu'au 2026-08-29. Depuis qu'un groupe
/// est une plage contiguë, un même domaine peut en occuper plusieurs — `heating` séparant
/// deux plages `ev`, par exemple — et une clé de domaine désignerait alors la première
/// venue. L'index est le seul identifiant exact d'une plage.
void reorderWithinGroup(int groupIndex, int oldIndex, int newIndex) {
final current = [...groups];
final gi = groupIndex;
if (gi < 0 || gi >= current.length) return;
final loads = [...current[gi].loads];
if (oldIndex < 0 || oldIndex >= loads.length) return;
if (newIndex > oldIndex) newIndex--;
final item = loads.removeAt(oldIndex);
loads.insert(newIndex.clamp(0, loads.length), item);
current[gi] = current[gi].copyWith(loads: loads);
_draft = current;
notifyListeners();
}
/// **Placer une charge en dernier** — la sortir de sa plage, seule, en queue.
///
/// ## Le manque qu'elle comble
///
/// Les flèches déplacent une charge **dans sa plage** ; le glissement déplace une plage
/// **entière**. Rien ne sortait une charge de son groupe : une borne regroupée avec les
/// autres ne pouvait plus être renvoyée en queue, et son rang devenait prisonnier du
/// bloc. Le geste manquait d'autant plus que le regroupement, lui, existe.
///
/// ## Pourquoi « en dernier » et pas « n'importe où »
///
/// C'est la position qu'une borne détectée reçoit d'office depuis `+etm27` (`1 + max`),
/// donc un repère que l'installateur connaît. Et une fois la charge **seule dans sa
/// plage**, le glissement de plage la porte partout ailleurs : ce geste rend la liberté
/// de placement sans qu'il faille inventer un glisser-déposer entre blocs, qui
/// demanderait de choisir un point d'insertion au pixel près sur un téléphone.
///
/// Brouillon seulement : rien ne part avant [save].
void moveToTail(String loadId) {
// Déjà la dernière de la dernière plage : ne rien faire plutôt que de poser un
// brouillon identique, qui allumerait la barre d'enregistrement pour une écriture sans
// effet. Le test porte sur l'état AVANT retrait — après, la plage d'origine a pu
// disparaître, et la comparaison ne veut plus rien dire.
final avant = groups;
if (avant.isNotEmpty &&
avant.last.loads.isNotEmpty &&
avant.last.loads.last.id == loadId) {
return;
}
final current = [for (final g in groups) g.copyWith(loads: [...g.loads])];
LoadConfigEntry? deplacee;
for (final g in current) {
final i = g.loads.indexWhere((e) => e.id == loadId);
if (i >= 0) {
deplacee = g.loads.removeAt(i);
break;
}
}
if (deplacee == null) return;
current.removeWhere((g) => g.loads.isEmpty);
final domaine = deplacee.domain ?? kUnclassifiedDomain;
// Elle rejoint la dernière plage si celle-ci porte son domaine — sinon elle ouvre la
// sienne. Deux plages de même domaine collées seraient artificielles : elles n'en font
// qu'une à la relecture, puisqu'une plage est une suite contiguë.
if (current.isNotEmpty && current.last.domain == domaine) {
current[current.length - 1] =
current.last.copyWith(loads: [...current.last.loads, deplacee]);
} else {
current.add(DomainGroup(domain: domaine, loads: [deplacee]));
}
_draft = current;
notifyListeners();
}
/// **Regrouper par domaine, à la demande** — jamais à l'enregistrement.
///
/// Rassemble les plages d'un même domaine à la position de leur première apparition, et
/// pose le résultat en **brouillon** : l'installateur voit le nouvel ordre, peut
/// l'annuler, et rien ne part avant `save()`. C'est ce que l'enregistrement faisait en
/// douce jusqu'au 2026-08-29 — un regroupement subi est une machine qui réordonne ce
/// qu'un humain a décidé.
void groupByDomainNow() {
final regroupe = groupContiguous(groups);
// Rien à faire si l'ordre est déjà groupé : poser un brouillon identique allumerait la
// barre d'enregistrement pour une écriture sans effet.
final avant = [for (final g in groups) ...g.loads.map((e) => e.id)];
final apres = [for (final g in regroupe) ...g.loads.map((e) => e.id)];
if (avant.join('|') == apres.join('|')) return;
_draft = regroupe;
notifyListeners();
}
/// Déplace un domaine entier dans l'ordre des domaines.
void reorderDomains(int oldIndex, int newIndex) {
final current = [...groups];
if (oldIndex < 0 || oldIndex >= current.length) return;
if (newIndex > oldIndex) newIndex--;
final item = current.removeAt(oldIndex);
current.insert(newIndex.clamp(0, current.length), item);
_draft = current;
notifyListeners();
}
/// Classe une charge dans un domaine — ou l'en retire (`domain` vide).
///
/// Modifie le **brouillon** : rien ne part avant [save]. La charge quitte son groupe
/// et rejoint la fin du groupe cible, parce que l'insérer au milieu déciderait à la
/// place de l'installateur d'un rang qu'il n'a pas choisi.
///
/// Le domaine est une **intention déclarée**, pure métadonnée : l'arbitre ne la lit
/// pas. Elle ne peut donc jamais être déduite du mécanisme — un relais pilote un
/// chauffe-eau comme une prise commandée de recharge.
void assignDomain(String loadId, String domain) {
final current = [for (final g in groups) g.copyWith(loads: [...g.loads])];
LoadConfigEntry? moved;
for (final g in current) {
final i = g.loads.indexWhere((e) => e.id == loadId);
if (i >= 0) {
moved = g.loads.removeAt(i);
break;
}
}
if (moved == null) return;
if ((moved.domain ?? kUnclassifiedDomain) == domain) return;
// La charge utile repart de la map d'origine : seule la clé `domain` est réécrite.
final patched = LoadConfigEntry(moved.patched(domain: domain));
// La DERNIÈRE plage du domaine, pas la première : un domaine peut en occuper
// plusieurs depuis que les groupes sont des plages contiguës, et remonter la charge
// dans la première la ferait sauter des rangs que personne n'a touchés. La dernière
// est la plus proche de « à la fin de ce domaine », qui était l'intention d'origine.
final gi = current.lastIndexWhere((g) => g.domain == domain);
if (gi >= 0) {
current[gi] = current[gi].copyWith(loads: [...current[gi].loads, patched]);
} else {
current.add(DomainGroup(domain: domain, loads: [patched]));
}
current.removeWhere((g) => g.loads.isEmpty);
_draft = current;
notifyListeners();
}
/// Renomme une charge. **Brouillon** : rien ne part avant [save].
///
/// Le libellé n'est pas du matériel : le moteur ne le compare pas dans
/// `sameHardware()`, donc renommer ne reconstruit rien et ne réarme aucun verrou.
/// Conséquence mesurée sur `.75` le 2026-08-25, et qui surprend : l'adaptateur est
/// **réutilisé tel quel**, si bien que le journal de la box continue d'afficher
/// l'ANCIEN libellé jusqu'à la prochaine reconstruction. `GetLoadConfig`, lui, rend
/// le nouveau immédiatement — c'est ce que l'app montre, et c'est le bon.
void setLabel(String loadId, String label) {
_patchEntry(loadId, (e) => e.patched(label: label));
}
/// Fait entrer ou sortir une charge de l'arbitrage. **Brouillon**.
///
/// `enabled: false` n'est pas un sectionnement, et l'UI ne doit pas le présenter
/// ainsi. Ce qui met le matériel en sécurité, c'est le **retrait de l'adaptateur**
/// (ECS-413) : la charge sort d'une configuration vivante, passe par
/// `applySafeState()`, puis est détruite. Vérifié sur `.75` le 2026-08-25 — PAC en
/// état 4, les deux contacts fermés, `enabled: false` posé : **les contacts se sont
/// ouverts**. La nuance vaut quand même d'être tenue : le drapeau ne protège rien par
/// lui-même, c'est le chemin de retrait qui agit, et il n'existe pas au redémarrage
/// du service.
void setEnabled(String loadId, bool enabled) {
_patchEntry(loadId, (e) => e.patched(enabled: enabled));
}
/// Remplace la charge utile d'une charge — sortie des écrans de mécanisme.
///
/// [raw] doit venir de `LoadConfigEntry.patched()`, donc de la map reçue de la box.
void replaceEntry(String loadId, Map<String, dynamic> raw) {
_patchEntry(loadId, (_) => raw);
}
/// Applique un patch à une charge du brouillon, en préservant sa place.
void _patchEntry(String loadId, Map<String, dynamic> Function(LoadConfigEntry) f) {
final current = [for (final g in groups) g.copyWith(loads: [...g.loads])];
var touched = false;
for (final g in current) {
final i = g.loads.indexWhere((e) => e.id == loadId);
if (i < 0) continue;
g.loads[i] = LoadConfigEntry(f(g.loads[i]));
touched = true;
break;
}
if (!touched) return;
_draft = current;
notifyListeners();
}
/// Ce que l'enregistrement en brouillon va provoquer, charge par charge.
///
/// **À montrer AVANT d'écrire.** Une reconstruction ouvre les contacts et réarme les
/// verrous à froid ; sur une PAC à `minStateHoldS` de 900 s, l'installateur qui
/// corrige une ligne fige sa machine un quart d'heure. Le cas le moins intuitif est le
/// simple réordonnancement de `relays[]` — la comparaison du moteur est indexée.
List<LoadImpact> pendingImpact() {
final draft = _draft;
if (draft == null) return const [];
return impactOfSave(
avant: _entries,
apres: [for (final g in draft) ...g.loads],
);
}
/// Vrai si l'enregistrement en brouillon touche au matériel d'au moins une charge.
bool get pendingNeedsWarning => pendingImpact().any((i) => i.needsWarning);
/// Prépare une normalisation — **ne l'écrit pas**. L'installateur doit confirmer.
void prepareNormalization() {
final g = _grouped;
if (g == null) return;
_draft = applyDefaultDomainOrder(g.groups);
notifyListeners();
}
/// Abandonne le brouillon et revient à l'état de la box.
void discardDraft() {
_draft = null;
_conflict = null;
notifyListeners();
}
// ── Écriture ───────────────────────────────────────────────────────────────
/// Envoie l'ordre en brouillon. Ne fait rien s'il n'y a pas de brouillon —
/// aucune écriture ne peut donc partir d'un simple affichage.
Future<void> save() async {
final service = _service;
final draft = _draft;
if (service == null || draft == null) return;
// Miroir client de validateSet() : produire un message utile plutôt qu'un rejet sec.
// La box reste l'autorité — ceci n'est qu'un confort d'affichage.
final flat = [for (final g in draft) ...g.loads];
final conflict = validateSet(flat);
if (conflict != null) {
_conflict = conflict;
_saveState = LoadSaveState.rejected;
notifyListeners();
return;
}
final payload = flattenToPayload(draft);
_expectedSignature = _signatureOfPayload(payload);
_conflict = null;
_saveError = null;
_saveState = LoadSaveState.pending;
notifyListeners();
final err = await service.setLoadConfig(payload);
if (err != null) {
_saveState = LoadSaveState.rejected;
_saveError = err;
_expectedSignature = null;
notifyListeners();
return;
}
// Acquittement reçu — on reste en « en cours » jusqu'à LoadConfigChanged.
// Si la notification n'arrive pas, on relit plutôt que d'annoncer un succès.
_confirmTimeout?.cancel();
_confirmTimeout = Timer(const Duration(seconds: 6), () async {
if (_saveState != LoadSaveState.pending) return;
await load();
_reconcile();
});
}
/// **Supprime une charge de la configuration** — le seul geste de l'app qui DÉTRUIT.
///
/// ## Pourquoi il existe
///
/// Le plugin crée d'office une entrée par borne détectée, mais **ne la retire pas quand
/// le Thing s'en va** : l'entrée survit, occupe un rang, et se publie en `THING_MISSING`.
/// Constaté sur `.75` avec une ABB Terra AC remplacée par une V2C, dont l'entrée tenait
/// encore un rang deux jours plus tard. `ClearLoadFault` n'y peut rien — il n'y a plus
/// d'appareil à qui parler.
///
/// ## Le chemin, et pourquoi c'est celui-là
///
/// **LIRE → PATCHER → RÉÉCRIRE**, sur les charges utiles brutes de la box. Jamais une
/// reconstruction depuis le modèle typé : `SetLoadConfig` remplace **tout** l'ensemble,
/// donc supprimer une charge, c'est réécrire toutes les autres. Un champ que le modèle
/// ne connaîtrait pas — box plus récente que l'app — serait perdu pour toutes les
/// charges d'un coup, en silence.
///
/// La lecture est **fraîche**, pas l'état en mémoire : entre l'ouverture de l'écran et
/// le geste, la box a pu changer d'avis, et réécrire un état périmé effacerait ce
/// changement.
///
/// ## Rien à moitié écrit
///
/// Un refus de la box laisse la configuration locale **intacte** : aucune adoption avant
/// la confirmation, et l'écriture elle-même est atomique côté box — `validateSet()`
/// refuse l'ensemble ou l'accepte en entier, il n'y a pas d'état intermédiaire.
///
/// Refusé aussi tant qu'un **brouillon** est en cours : sinon la suppression emporterait
/// des modifications non enregistrées que personne n'a demandé d'écrire.
///
/// \return `null` si la box a accepté, sinon un code : `PendingDraft`, `NotFound`,
/// `NoService`, ou le refus de la box tel qu'elle le nomme.
Future<String?> removeLoad(String loadId) async {
final service = _service;
if (service == null) return 'NoService';
if (_draft != null) return 'PendingDraft';
_removalDiff = const [];
// 1. LIRE — la charge utile de la box, à cet instant.
final List<Map<String, dynamic>> frais;
try {
frais = await service.getLoadConfig();
} catch (e) {
_saveError = e.toString();
_saveState = LoadSaveState.rejected;
notifyListeners();
return _saveError;
}
if (frais.isEmpty) return 'NotFound';
// 2. PATCHER — règle pure, éprouvée à part (`payloadWithoutLoad`).
final reste = payloadWithoutLoad(frais, loadId);
if (reste == null) return 'NotFound';
// ⚠️ `1.15.2+etm27` refuse EN BLOC un ensemble où deux charges partagent un rang — et
// le motif ne franchit pas la frontière RPC.
//
// Ce chemin réécrit les survivantes VERBATIM, donc il transporte les rangs tels que la
// box les porte. Or c'est le plugin lui-même qui a créé des égalités avant `+etm27`
// (borne détectée au rang 1, sur une installation où le rang 1 était pris). Une
// suppression parfaitement légitime serait donc refusée, sans cause visible.
//
// On refuse ICI, avec la raison — plutôt que de renuméroter en douce : un geste de
// nettoyage qui redistribue les rangs devient un geste d'arbitrage, et c'est
// exactement ce que ce chemin s'interdit.
final apres = [for (final m in reste) LoadConfigEntry(m)];
if (tiedByPriority(apres).isNotEmpty) {
_saveError = 'La configuration porte des rangs à égalité, que la box refuse '
'depuis 1.15.2+etm27. Classez les charges avant de retirer celle-ci.';
_saveState = LoadSaveState.rejected;
notifyListeners();
return 'TiedRanks';
}
// 3. RÉÉCRIRE.
_expectedSignature = _signatureOfPayload(reste);
_conflict = null;
_saveError = null;
_saveState = LoadSaveState.pending;
notifyListeners();
final err = await service.setLoadConfig(reste);
if (err != null) {
// Rien n'a bougé : ni la box (refus en bloc), ni l'état local (aucune adoption).
_saveState = LoadSaveState.rejected;
_saveError = err;
_expectedSignature = null;
notifyListeners();
return err;
}
// 4. RELIRE ET VÉRIFIER, tout de suite — pas « la prochaine fois ».
//
// L'acquittement ne vaut pas application, et sur le SEUL geste qui détruit de la
// configuration, « la box a dit oui » ne suffit pas : il faut voir ce qu'elle porte
// ensuite. La relecture est immédiate et synchrone du point de vue de l'appelant —
// attendre `LoadConfigChanged` laisserait une fenêtre pendant laquelle un autre geste
// peut s'intercaler, et c'est exactement ce qui a rendu la vérification manuelle du
// 2026-08-28 inconcluante : deux écritures à deux minutes d'écart, impossible de dire
// laquelle avait bougé les rangs.
List<Map<String, dynamic>> relu;
try {
relu = await service.getLoadConfig();
} catch (e) {
_saveState = LoadSaveState.diverged;
_saveError = 'Suppression envoyée, mais la relecture a échoué ($e).';
_expectedSignature = null;
notifyListeners();
return 'RereadFailed';
}
_adopt(relu);
_expectedSignature = null;
_confirmTimeout?.cancel();
// Le contrôle de l'aller-retour neutre, appliqué à une écriture qui retire : les
// survivantes doivent être identiques clé par clé, rangs compris.
final ecarts = diffPayloads(reste, relu);
if (ecarts.isEmpty) {
_saveState = LoadSaveState.confirmed;
_saveError = null;
notifyListeners();
return null;
}
_saveState = LoadSaveState.diverged;
_removalDiff = ecarts;
_saveError = 'La charge est retirée, mais ${ecarts.length} autre(s) écart(s) : '
'${ecarts.take(3).join(' · ')}';
notifyListeners();
return 'Diverged';
}
/// **Adopter le rang posé d'office** — écrit `rankOrigin: "user"` sur une charge.
///
/// ## Ce que ce geste AFFIRME
///
/// « Ce rang me va, je l'assume » — l'installateur adopte un rang qu'il n'a pas choisi,
/// sans le déplacer. Ce n'est **pas** « j'ai vu le badge » : c'est un jugement porté sur
/// l'ordre de service, et c'est la seule voie pour éteindre le badge sans changer
/// l'arbitrage.
///
/// D'où trois règles, et elles sont dans le code parce qu'elles sont faciles à perdre :
///
/// 1. **Le geste est explicite, jamais un effet de bord.** Cette méthode n'est appelée
/// que par le bouton du badge. Écrire `"user"` parce que l'utilisateur a enregistré
/// autre chose affirmerait un jugement qu'il n'a pas porté — exactement ce qu'on
/// reproche à un `"auto"` fabriqué côté client.
/// 2. **Il ne s'annule pas tout seul.** Une fois posé, seul le moteur l'efface, quand un
/// rang différent arrive pour cet id. L'app ne le retire jamais.
/// 3. **Il ne remplace pas le déplacement.** Pour un autre rang, on glisse la charge ;
/// `"user"` est pour le cas où le défaut convient. Le libellé du bouton le dit.
///
/// Même chemin d'écriture que la suppression — lire → patcher → réécrire verbatim, avec
/// le garde-fou des rangs à égalité et la relecture qui vérifie — parce que c'est le même
/// danger : `SetLoadConfig` remplace tout l'ensemble.
///
/// \return `null` si la box a accepté, sinon un code.
Future<String?> adoptRank(String loadId) async {
final service = _service;
if (service == null) return 'NoService';
if (_draft != null) return 'PendingDraft';
_removalDiff = const [];
final List<Map<String, dynamic>> frais;
try {
frais = await service.getLoadConfig();
} catch (e) {
_saveError = e.toString();
_saveState = LoadSaveState.rejected;
notifyListeners();
return _saveError;
}
var trouvee = false;
final envoye = <Map<String, dynamic>>[];
for (final m in frais) {
if (m['id'] == loadId) {
trouvee = true;
// Seul `rankOrigin` change. Le rang, lui, ne bouge PAS : adopter n'est pas
// reclasser, et un geste qui déplacerait la charge en même temps rendrait
// l'affirmation fausse.
envoye.add({...m, 'rankOrigin': 'user'});
} else {
envoye.add(Map<String, dynamic>.from(m));
}
}
if (!trouvee) return 'NotFound';
// Même refus que la suppression : sur une configuration héritée d'avant `+etm27`, la
// box rejetterait l'ensemble sans motif lisible.
if (tiedByPriority([for (final m in envoye) LoadConfigEntry(m)]).isNotEmpty) {
_saveError = 'La configuration porte des rangs à égalité, que la box refuse '
'depuis 1.15.2+etm27. Classez les charges d\'abord.';
_saveState = LoadSaveState.rejected;
notifyListeners();
return 'TiedRanks';
}
_expectedSignature = _signatureOfPayload(envoye);
_conflict = null;
_saveError = null;
_saveState = LoadSaveState.pending;
notifyListeners();
final err = await service.setLoadConfig(envoye);
if (err != null) {
_saveState = LoadSaveState.rejected;
_saveError = err;
_expectedSignature = null;
notifyListeners();
return err;
}
// Relire et vérifier tout de suite : l'acquittement ne vaut pas application, et une
// adoption qui aurait déplacé autre chose serait pire que pas d'adoption du tout.
List<Map<String, dynamic>> relu;
try {
relu = await service.getLoadConfig();
} catch (e) {
_saveState = LoadSaveState.diverged;
_saveError = 'Adoption envoyée, mais la relecture a échoué ($e).';
_expectedSignature = null;
notifyListeners();
return 'RereadFailed';
}
_adopt(relu);
_expectedSignature = null;
_confirmTimeout?.cancel();
final ecarts = diffPayloads(envoye, relu);
if (ecarts.isEmpty) {
_saveState = LoadSaveState.confirmed;
_saveError = null;
notifyListeners();
return null;
}
_saveState = LoadSaveState.diverged;
_removalDiff = ecarts;
_saveError = 'Rang adopté, mais ${ecarts.length} écart(s) : '
'${ecarts.take(3).join(' · ')}';
notifyListeners();
return 'Diverged';
}
/// Écarts constatés par la dernière suppression — vide quand elle n'a touché à rien
/// d'autre. Ce n'est pas un échec de l'app : c'est ce que la box porte, et il faut le
/// montrer plutôt que de le taire.
List<String> get removalDiff => List.unmodifiable(_removalDiff);
List<String> _removalDiff = const [];
void _onBoxConfirmed(List<Map<String, dynamic>> configs) {
_adopt(configs);
_reconcile();
notifyListeners();
}
void _reconcile() {
_confirmTimeout?.cancel();
if (_expectedSignature == null) {
// Changement venu d'ailleurs (fichier édité, autre client) : on adopte, sans
// prétendre qu'il s'agit du résultat d'une action locale.
notifyListeners();
return;
}
final actual = _signatureOfEntries(_entries);
_saveState = actual == _expectedSignature
? LoadSaveState.confirmed
: LoadSaveState.diverged;
_expectedSignature = null;
notifyListeners();
}
/// Signature de ce que l'app est autorisée à écrire : `id:priority:domain:label:
/// enabled`, plus une empreinte du matériel. Comparer plus large ferait crier à la
/// divergence sur un champ que l'app n'a pas touché ; comparer plus étroit laisserait
/// passer une écriture de mécanisme silencieusement refusée.
static String _signatureOfPayload(List<Map<String, dynamic>> payload) {
final rows = [
for (final m in payload)
_rowOf(LoadConfigEntry(m)),
]..sort();
return rows.join('|');
}
/// Empreinte d'une charge : ce que l'app écrit, matériel compris.
static String _rowOf(LoadConfigEntry e) {
final relays = [for (final r in e.relays) '${normalizeThingId(r.thingId)}@${r.powerW}'];
final sg = [for (final s in e.sgReadyStates) '${s.state}:${s.estimatedPowerW}'];
return '${e.id}:${e.priority}:${e.domain ?? ''}:${e.label}:${e.enabled}'
':${e.minOnS}/${e.minOffS}:${e.maxPowerW}:${relays.join(",")}'
':${e.minStateHoldS}:${sg.join(",")}';
}
static String _signatureOfEntries(List<LoadConfigEntry> entries) {
final rows = [
for (final e in entries) _rowOf(e),
]..sort();
return rows.join('|');
}
/// Vide l'état au switch d'installation — la config appartient à la box quittée.
void clearForSwitch() {
_entries = const [];
_grouped = null;
_draft = null;
_error = null;
_conflict = null;
_saveError = null;
_saveState = LoadSaveState.idle;
_expectedSignature = null;
_confirmTimeout?.cancel();
notifyListeners();
}
@override
void dispose() {
_confirmTimeout?.cancel();
_unsubscribe?.call();
super.dispose();
}
}