fix(brouillon): « j'ai décidé de ne rien changer » est un état, et il se conclut

Le geste sans repli de cet écran — déplacer une plage — puis le geste inverse
laissaient un brouillon qui rend exactement la configuration de la box. L'écran
le traitait comme n'importe quel brouillon, et n'offrait plus pour en sortir que
des portes mal nommées. Quatre messages, tous faux au même instant :

  la barre                 « Modifications en attente »
  la sortie d'écran        « vos changements n'ont pas été envoyés »
  adopter un rang          « enregistrez ou annulez vos modifications »
  retirer une charge       idem

Il n'y avait plus rien à enregistrer, ni rien à perdre.

`draftIsNoOp` nomme l'état : un brouillon dont les charges utiles brutes, clés
triées, sont identiques à celles de la box — ce qui couvre l'ordre ET un patch
de champ. `hasSubstantiveChanges` garde désormais les trois portes ; la barre,
elle, continue de s'afficher, parce que c'est là qu'est la sortie.

Elle dit maintenant « L'ordre est revenu à celui de la box — il n'y a rien à
enregistrer » et n'offre qu'un geste, « Terminer ». Pas « Enregistrer » :
réécrire tout l'ensemble pour n'y rien changer reconstruirait les charges et
réarmerait les verrous, au prix de zéro.

⚠️ 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é ». Un état se conclut,
il ne s'évapore pas. Un test le fige.

Et la note sur le glissement est REPRISE. J'y affirmais un défaut ; le test au
doigt montre que le réordonnancement fonctionne, liste dépliée comprise. Le
mécanisme du shrinkWrap reste écrit — il est probablement juste — mais comme une
LIMITE CONNUE non reproduite, avec le protocole en trois gestes (A témoin,
B discriminant, C cause), le contournement (replier les blocs) et le remède
nommé. On ne réécrit pas un écran qui marche sur la foi d'une lecture de code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-08 07:30:12 +02:00
parent dfa327cbcd
commit e76d01160d
5 changed files with 320 additions and 17 deletions

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
@ -71,9 +72,61 @@ class LoadConfigProvider extends ChangeNotifier {
List<String> 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<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();

View File

@ -102,8 +102,19 @@ class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
// 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<ControllableLoadsSection> {
// 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<void> _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),

View File

@ -36,9 +36,23 @@ class GardeBrouillon extends StatelessWidget {
final provider = context.watch<LoadConfigProvider>();
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();

View File

@ -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.'),

View File

@ -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<String, dynamic> _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<Map<String, dynamic>> _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<LoadConfigProvider>.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<NavigatorState>(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<LoadConfigProvider>.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<NavigatorState>(find.byType(Navigator)).maybePop();
await tester.pumpAndSettle();
expect(find.text('Modifications non enregistrées'), findsOneWidget);
expect(find.text('écran des charges'), findsOneWidget);
});
});
}