import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/load_config_entry.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 _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? _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 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 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 get interleavedDomains => _grouped?.interleavedDomains ?? const []; /// Vrai s'il y a un changement non enregistré. bool get hasPendingChanges => _draft != null; void attach(NymeaService service) { if (identical(_service, service)) return; _unsubscribe?.call(); _service = service; _unsubscribe = service.onLoadConfigChanged(_onBoxConfirmed); } Future 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> 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> raw) { _adopt(raw); notifyListeners(); } // ── Édition (brouillon, aucune écriture) ─────────────────────────────────── /// Déplace une charge à l'intérieur de son domaine. void reorderWithinDomain(String domain, int oldIndex, int newIndex) { final current = [...groups]; final gi = current.indexWhere((g) => g.domain == domain); if (gi < 0) 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(); } /// 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)); final gi = current.indexWhere((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(); } /// 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 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(); }); } void _onBoxConfirmed(List> 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 = suite `id:priority:domain` dans l'ordre de service. C'est exactement /// ce que ce lot est autorisé à modifier ; comparer plus large ferait crier à la /// divergence sur un champ que l'app n'a pas touché. static String _signatureOfPayload(List> payload) { final rows = [ for (final m in payload) '${m['id']}:${m['priority']}:${m['domain'] ?? ''}', ]..sort(); return rows.join('|'); } static String _signatureOfEntries(List entries) { final rows = [ for (final e in entries) '${e.id}:${e.priority}:${e.domain ?? ''}', ]..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(); } }