diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index 167d590..e53f04d 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -71,9 +72,61 @@ class LoadConfigProvider extends ChangeNotifier { List get interleavedDomains => _grouped?.interleavedDomains ?? const []; - /// Vrai s'il y a un changement non enregistré. + /// 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. + /// + /// ⚠️ **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 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(); diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 774cb0c..2bc84c6 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -102,8 +102,19 @@ class _ControllableLoadsSectionState extends State { // 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. // - // ⚠️ **MAIS ce montage ne peut pas défiler pendant un glissement**, et c'est un - // défaut ouvert, pas un choix. Établi par construction le 2026-09-08 : + // ⚠️ **LIMITE CONNUE, non reproduite** — à lire en entier avant d'y toucher. + // + // Éprouvé au doigt le 2026-09-08 : **le réordonnancement fonctionne**, y compris + // sur la liste dépliée, et rien n'a dysfonctionné. Ce qui suit est donc un + // mécanisme lu dans le code, pas un symptôme observé — et il prédit une panne que + // l'usage ne montre pas. Le déplacement testé tenait peut-être dans une hauteur + // d'écran, auquel cas le cas long reste simplement indécidé. + // + // On ne réécrit pas un écran qui marche sur la foi d'une lecture de code. C'est + // écrit ici pour que le jour où quelqu'un ne peut pas remonter une charge sur une + // longue liste, le diagnostic soit déjà fait et le remède déjà nommé. + // + // **Le mécanisme**, établi par construction : // // `roles_devices_screen.dart:113` est un `ListView` — le parent défile. Cette // liste-ci est en `shrinkWrap: true`, donc son viewport vaut exactement la hauteur @@ -112,17 +123,30 @@ class _ControllableLoadsSectionState extends State { // parent : il n'a donc nulle part où aller, et le `ListView` extérieur n'est même // pas prévenu. // - // Conséquence : on ne peut réordonner que ce qui tient à l'écran **en même temps**. - // Or un bloc de domaine déplié fait plus d'un écran à lui seul, donc le geste qui - // compte — remonter un domaine par-dessus un autre — est précisément celui qui ne - // peut pas aboutir. Replier les blocs le rend possible : c'est un contournement, - // et c'est aussi le contrôle qui prouve le diagnostic. + // Conséquence attendue : on ne réordonnerait que ce qui tient à l'écran **en même + // temps**. Un déplacement plus long que la fenêtre n'aboutirait pas. // - // Le remède est structurel : UN seul `Scrollable` sur l'écran, en `CustomScrollView` - // + `SliverReorderableList` — le montage déjà utilisé dans `favorites_screen.dart`. - // Pas fait ici tant que le geste n'a pas été éprouvé au doigt : l'entrée synthétique - // ne reproduit pas un appui-glissé (cf. `tools/rpc/README.md`), donc aucun test de - // ce dépôt ne peut ni montrer le défaut ni prouver sa correction. + // **Le protocole en trois gestes**, si la plainte arrive un jour : + // + // A — replier tous les blocs (le chevron), puis remonter une plage par-dessus + // une autre. Attendu : ça marche. C'est le témoin. + // B — tout déplier, revenir en haut, refaire le MÊME déplacement en devant + // traverser plus d'un écran. Attendu si le défaut est réel : la plage bute + // contre le bord et retombe. + // C — pendant B, tenir la poignée immobile contre le bord HAUT trois secondes. + // Attendu : rien ne défile. **Si ça défile, cette analyse est fausse.** + // + // A seul ne conclut rien : il réussit dans les deux mondes. C'est B qui sépare, et + // C qui nomme la cause. + // + // **Contournement** : replier les blocs — tout tient alors à l'écran. + // + // **Remède**, s'il faut y venir : UN seul `Scrollable`, en `CustomScrollView` + + // `SliverReorderableList` — le montage déjà en place dans `favorites_screen.dart`. + // + // Et pourquoi aucun test ne tranchera : l'entrée synthétique ne reproduit pas un + // appui-glissé (cf. `tools/rpc/README.md`). Aucun test de ce dépôt ne peut montrer + // ce défaut ni prouver sa correction — seul un doigt le peut. ReorderableListView.builder( shrinkWrap: true, primary: false, @@ -303,7 +327,10 @@ class _BadgeRangDOfficeState extends State<_BadgeRangDOffice> { /// 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) { + // `hasSubstantiveChanges`, pas `hasPendingChanges` : un brouillon revenu à son point + // de départ n'a rien à enregistrer ni à annuler, et le refuser au nom de + // « modifications en cours » nomme quelque chose qui n'existe pas. + if (widget.provider.hasSubstantiveChanges) { messenger.showSnackBar(const SnackBar( content: Text('Enregistrez ou annulez vos modifications en cours : adopter un ' 'rang réécrit toute la configuration.'), @@ -1021,6 +1048,37 @@ class ImpactSaveBar extends StatelessWidget { final warnings = impacts.where((i) => i.needsWarning).toList(); final pending = provider.saveState == LoadSaveState.pending; + // **Le brouillon revenu à son point de départ se CONCLUT, il ne s'annule pas.** + // + // « Modifications en attente » y était faux, et « Enregistrer » aurait réécrit tout + // l'ensemble pour n'y rien changer — une reconstruction de charges, verrous réarmés + // compris, au prix de zéro. La barre reste (c'est la porte de sortie), elle dit ce + // qui est, et elle n'offre qu'un geste. + if (provider.draftIsNoOp) { + 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: EtmTokens.lineOf(context)), + ), + child: Row(children: [ + Expanded( + child: Text( + 'L\'ordre est revenu à celui de la box — il n\'y a rien à enregistrer.', + style: TextStyle( + fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3), + ), + ), + FilledButton( + onPressed: provider.discardDraft, + child: const Text('Terminer'), + ), + ]), + ); + } + return Container( width: double.infinity, padding: const EdgeInsets.all(12), diff --git a/lib/widgets/garde_brouillon.dart b/lib/widgets/garde_brouillon.dart index dea704d..d14abed 100644 --- a/lib/widgets/garde_brouillon.dart +++ b/lib/widgets/garde_brouillon.dart @@ -36,9 +36,23 @@ class GardeBrouillon extends StatelessWidget { final provider = context.watch(); return PopScope( - canPop: !provider.hasPendingChanges, + // **`hasSubstantiveChanges`, pas `hasPendingChanges`.** + // + // Un brouillon revenu à l'ordre de la box ne fait rien perdre : bloquer la sortie + // pour annoncer que « vos changements n'ont pas été envoyés » nommait une perte + // inexistante, et laissait l'écran sans porte honnête. On sort librement, et on + // ramasse le brouillon vide au passage pour qu'il ne ressorte pas ailleurs. + canPop: !provider.hasSubstantiveChanges, onPopInvokedWithResult: (didPop, _) async { - if (didPop) return; + if (didPop) { + if (provider.hasPendingChanges) { + // Après la frame : on est en train d'être démonté, et notifier pendant + // un build ferait tomber l'arbre au lieu de fermer proprement. + WidgetsBinding.instance + .addPostFrameCallback((_) => provider.discardDraft()); + } + return; + } final partir = await _demander(context); if (partir != true || !context.mounted) return; provider.discardDraft(); diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index ce7fe15..44cc6d5 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -415,7 +415,7 @@ class _BoutonRetirerState extends State<_BoutonRetirer> { // Un brouillon en cours emporterait des modifications que personne n'a demandé // d'écrire : on le dit AVANT d'ouvrir la confirmation, pas après. - if (provider.hasPendingChanges) { + if (provider.hasSubstantiveChanges) { messenger.showSnackBar(const SnackBar( content: Text('Enregistrez ou annulez vos modifications en cours avant de ' 'retirer une charge : la suppression réécrit toute la configuration.'), diff --git a/test/brouillon_sans_issue_test.dart b/test/brouillon_sans_issue_test.dart new file mode 100644 index 0000000..b75fc57 --- /dev/null +++ b/test/brouillon_sans_issue_test.dart @@ -0,0 +1,178 @@ +/// **Un brouillon revenu à son point de départ doit pouvoir se CONCLURE.** +/// +/// « Je n'ai rien changé » et « j'ai décidé de ne rien changer » sont deux états +/// différents. Le second s'atteint par le seul geste de l'écran des charges qui n'a pas de +/// repli — déplacer une plage — puis en le défaisant. +/// +/// 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. +/// +/// ⚠️ Le remède N'EST PAS d'effacer le brouillon dès qu'il devient sans effet : la barre +/// disparaîtrait sous le doigt à l'instant où l'utilisateur repose la plage, et +/// « j'ai décidé » redeviendrait « il ne s'est rien passé ». +library; + +import 'package:etm_powersync_app/providers/load_config_provider.dart'; +import 'package:etm_powersync_app/widgets/controllable_loads_section.dart'; +import 'package:etm_powersync_app/widgets/garde_brouillon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Map _charge(String id, String label, String domain, int rang) => { + 'id': id, + 'label': label, + 'domain': domain, + 'priority': rang, + 'adapter': 'relay-router', + 'mode': 'fixed', + 'enabled': true, + }; + +/// Trois domaines distincts : trois plages, donc trois éléments déplaçables. +List> _config() => [ + _charge('a', 'V2C Trydan', 'ev', 1), + _charge('b', 'chauffe-eau', 'ecs', 2), + _charge('c', 'PAC banc', 'heating', 3), + ]; + +LoadConfigProvider _provider() => LoadConfigProvider()..adoptForTest(_config()); + +void main() { + group('l\'état « j\'ai décidé de ne rien changer »', () { + test('sans brouillon, il n\'existe pas', () { + final p = _provider(); + expect(p.hasPendingChanges, isFalse); + expect(p.draftIsNoOp, isFalse, + reason: 'pas de brouillon n\'est pas un brouillon sans effet'); + expect(p.hasSubstantiveChanges, isFalse); + }); + + test('un déplacement réel change vraiment quelque chose', () { + final p = _provider()..reorderDomains(2, 0); + expect(p.hasPendingChanges, isTrue); + expect(p.draftIsNoOp, isFalse); + expect(p.hasSubstantiveChanges, isTrue); + }); + + test('déplacer puis remettre laisse un brouillon SANS EFFET', () { + final p = _provider() + ..reorderDomains(2, 0) // C remonte en tête + ..reorderDomains(0, 3); // et redescend en queue + + expect(p.hasPendingChanges, isTrue, + reason: 'le brouillon SURVIT — il ne s\'évapore pas sous le doigt'); + expect(p.draftIsNoOp, isTrue); + expect(p.hasSubstantiveChanges, isFalse); + // Et l'ordre affiché est bien celui du départ. + expect([for (final g in p.groups) ...g.loads.map((e) => e.id)], + ['a', 'b', 'c']); + }); + }); + + group('les portes de sortie disent la vérité', () { + testWidgets('la barre ne propose plus d\'enregistrer, elle propose de TERMINER', + (tester) async { + final p = _provider() + ..reorderDomains(2, 0) + ..reorderDomains(0, 3); + + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: ImpactSaveBar(provider: p)), + )); + + expect(find.textContaining('rien à enregistrer'), findsOneWidget); + expect(find.text('Terminer'), findsOneWidget); + // Les contrôles qui MORDENT : les deux portes mal nommées ont disparu. + expect(find.text('Enregistrer'), findsNothing, + reason: 'réécrire tout l\'ensemble pour n\'y rien changer ' + 'reconstruirait les charges pour rien'); + expect(find.textContaining('Modifications en attente'), findsNothing); + + await tester.tap(find.text('Terminer')); + await tester.pump(); + expect(p.hasPendingChanges, isFalse, reason: 'l\'état s\'est CONCLU'); + }); + + testWidgets('un brouillon réel garde sa barre d\'enregistrement', (tester) async { + final p = _provider()..reorderDomains(2, 0); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: ImpactSaveBar(provider: p)), + )); + expect(find.text('Enregistrer'), findsOneWidget); + expect(find.text('Terminer'), findsNothing); + }); + + testWidgets('la sortie d\'écran ne menace plus d\'une perte inexistante', + (tester) async { + final p = _provider() + ..reorderDomains(2, 0) + ..reorderDomains(0, 3); + + await tester.pumpWidget(ChangeNotifierProvider.value( + value: p, + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const GardeBrouillon( + child: Scaffold(body: Text('écran des charges')), + ), + )), + child: const Text('ouvrir'), + ), + ), + ), + ), + )); + + await tester.tap(find.text('ouvrir')); + await tester.pumpAndSettle(); + expect(find.text('écran des charges'), findsOneWidget); + + // Retour arrière : aucune alerte, l'écran se ferme. + final nav = tester.state(find.byType(Navigator)); + nav.maybePop(); + await tester.pumpAndSettle(); + + expect(find.text('Modifications non enregistrées'), findsNothing, + reason: 'il n\'y avait rien à perdre'); + expect(find.text('écran des charges'), findsNothing); + expect(p.hasPendingChanges, isFalse, + reason: 'le brouillon vide est ramassé, pas laissé traîner'); + }); + + testWidgets('un brouillon RÉEL fait toujours barrage', (tester) async { + final p = _provider()..reorderDomains(2, 0); + + await tester.pumpWidget(ChangeNotifierProvider.value( + value: p, + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const GardeBrouillon( + child: Scaffold(body: Text('écran des charges')), + ), + )), + child: const Text('ouvrir'), + ), + ), + ), + ), + )); + + await tester.tap(find.text('ouvrir')); + await tester.pumpAndSettle(); + tester.state(find.byType(Navigator)).maybePop(); + await tester.pumpAndSettle(); + + expect(find.text('Modifications non enregistrées'), findsOneWidget); + expect(find.text('écran des charges'), findsOneWidget); + }); + }); +}