etm-powersync-app/lib/widgets/garde_brouillon.dart
Patrick Schurig e76d01160d 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
2026-09-08 07:30:12 +02:00

88 lines
3.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/load_config_provider.dart';
import '../theme/etm_tokens.dart';
/// **Quitter un écran avec un brouillon en attente** — demander, jamais décider seul.
///
/// ## Pourquoi une alerte, et pas un abandon silencieux
///
/// Le brouillon vit dans `LoadConfigProvider`, pas dans l'écran : il **survit** à la
/// navigation. Quitter sans rien dire laissait donc une configuration modifiée en attente,
/// invisible, qui réapparaissait au retour — ou disparaissait à la première relecture de la
/// box (`LoadConfigChanged`, reconnexion), sans que personne ait choisi.
///
/// Les deux issues sont mauvaises pour la même raison : **l'utilisateur ne sait pas ce
/// qu'il laisse derrière lui.** L'alerte rend le choix explicite, et le fait porter à qui
/// de droit.
///
/// ## Ce que chaque bouton fait vraiment
///
/// - **Rester** : rien ne bouge, le brouillon reste, l'écran non plus.
/// - **Quitter sans enregistrer** : le brouillon est **abandonné** (`discardDraft`), et
/// l'écran se ferme. C'est délibérément destructeur — un brouillon fantôme qui ressort
/// trois écrans plus loin est pire qu'une modification perdue qu'on a acceptée de perdre.
///
/// Aucune écriture n'est proposée ici : enregistrer depuis une boîte de dialogue de sortie
/// ferait partir une configuration que l'utilisateur n'a pas relue.
class GardeBrouillon extends StatelessWidget {
final Widget child;
const GardeBrouillon({super.key, required this.child});
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
return PopScope(
// **`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) {
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();
Navigator.of(context).pop();
},
child: child,
);
}
Future<bool?> _demander(BuildContext context) => showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Modifications non enregistrées'),
content: const Text(
'Vos changements n\'ont pas été envoyés à la box : elle porte toujours la '
'configuration d\'avant.\n\n'
'Quitter maintenant les abandonne.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Rester'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: EtmTokens.danger),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Quitter sans enregistrer'),
),
],
),
);
}