diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 43e0be4..95943e1 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -17,6 +17,7 @@ import '../../services/rpc_schema.dart'; import '../../theme/etm_tokens.dart'; import '../../widgets/load_cards.dart'; import '../../widgets/ev_mode_buttons.dart'; +import '../../widgets/garde_brouillon.dart'; import '../../widgets/schema_gated.dart'; import '../../widgets/thing_picker.dart'; @@ -67,7 +68,10 @@ class _LoadMechanismScreenState extends State { final telemetry = context.watch(); final e = _entree(provider); - return Scaffold( + // Cet écran modifie le MÊME brouillon que la liste des charges : quitter sans + // enregistrer y laisse la même modification invisible. + return GardeBrouillon( + child: Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: Text(e == null ? 'Charge' : 'Configurer · ${e.label}'), @@ -166,7 +170,7 @@ class _LoadMechanismScreenState extends State { entry: e, provider: provider, service: service), ], ), - ); + )); } void _majSgReady(LoadConfigProvider p, LoadConfigEntry e, {required int hold}) { diff --git a/lib/screens/energy/roles_devices_screen.dart b/lib/screens/energy/roles_devices_screen.dart index 93f1a5d..40e9171 100644 --- a/lib/screens/energy/roles_devices_screen.dart +++ b/lib/screens/energy/roles_devices_screen.dart @@ -11,6 +11,7 @@ import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; import '../../widgets/battery_reserve_card.dart'; import '../../widgets/controllable_loads_section.dart'; +import '../../widgets/garde_brouillon.dart'; import '../../widgets/schema_gated.dart'; import 'load_mechanism_screen.dart'; @@ -98,7 +99,10 @@ class _RolesDevicesScreenState extends State { final todo = setup.toConfigureRoles; final absent = setup.absentRoles; - return Scaffold( + // Le brouillon de configuration des charges survit à la navigation : sans ce garde, + // on quitte l'écran en laissant derrière soi une modification invisible. + return GardeBrouillon( + child: Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Rôles & appareils'), @@ -166,7 +170,7 @@ class _RolesDevicesScreenState extends State { ], ], ), - ); + )); } } diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 008ebc6..2fd201d 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -777,29 +777,6 @@ class LoadConfigCard extends StatelessWidget { entry: entry, provider: provider), const Spacer(), - // « Placer en dernier » — le seul geste qui SORT une charge de sa plage. - // - // Les flèches la déplacent dans son bloc, le glissement déplace le bloc - // entier : sans lui, une charge regroupée était prisonnière de son groupe. - // Une fois seule en queue, le glissement de plage la porte partout ailleurs. - // - // Masqué quand elle y est déjà : un bouton qui ne fait rien use la confiance - // dans ceux qui font quelque chose. - if (!estDejaEnQueue) - TextButton.icon( - onPressed: () => provider.moveToTail(entry.id), - icon: const Icon(Icons.vertical_align_bottom_rounded, size: 16), - // ⚠️ Le style va sur le LIBELLÉ, pas dans `styleFrom(textStyle:)` : celui-ci - // porte `inherit: true` là où le thème porte `inherit: false`, et Flutter - // refuse d'interpoler les deux — l'assertion tombe pendant l'animation du - // glissement, très loin de sa cause. - label: const Text('Placer en dernier', - style: TextStyle(fontSize: 11.5)), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - foregroundColor: EtmTokens.mutedOf(context), - ), - ), TextButton.icon( onPressed: () => onConfigure(context, entry), icon: const Icon(Icons.tune_rounded, size: 17), @@ -810,7 +787,12 @@ class LoadConfigCard extends StatelessWidget { ), ), ]), - _RenameRow(entry: entry, provider: provider), + // « Renommer » et « Placer en dernier » sur la MÊME ligne : ce sont les deux + // gestes qui ne touchent pas au mécanisme, et les garder ensemble rend à + // « Configurer » sa place — un bouton d'action principale ne se fait pas + // pousser par un geste secondaire. + _LigneGestes( + entry: entry, provider: provider, dejaEnQueue: estDejaEnQueue), const SizedBox(height: 8), Divider(height: 1, color: EtmTokens.lineOf(context)), const SizedBox(height: 8), @@ -878,6 +860,48 @@ class _EnabledSwitch extends StatelessWidget { } /// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé. +/// Les deux gestes qui ne touchent pas au mécanisme : renommer, et sortir de sa plage. +class _LigneGestes extends StatelessWidget { + final LoadConfigEntry entry; + final LoadConfigProvider provider; + final bool dejaEnQueue; + + const _LigneGestes({ + required this.entry, + required this.provider, + required this.dejaEnQueue, + }); + + @override + Widget build(BuildContext context) { + return Row(children: [ + Flexible(child: _RenameRow(entry: entry, provider: provider)), + // « Placer en dernier » — le seul geste qui SORT une charge de sa plage. + // + // Les flèches la déplacent dans son bloc, le glissement déplace le bloc entier : + // sans lui, une charge regroupée est prisonnière de son groupe. Une fois seule en + // queue, le glissement de plage la porte partout ailleurs. + // + // Masqué quand elle y est déjà : un bouton qui ne fait rien use la confiance dans + // ceux qui font quelque chose. + if (!dejaEnQueue) + TextButton.icon( + onPressed: () => provider.moveToTail(entry.id), + icon: const Icon(Icons.vertical_align_bottom_rounded, size: 15), + // ⚠️ Le style va sur le LIBELLÉ, pas dans `styleFrom(textStyle:)` : celui-ci + // porte `inherit: true` là où le thème porte `inherit: false`, et Flutter refuse + // d'interpoler les deux — l'assertion tombe pendant l'animation du glissement, + // très loin de sa cause. + label: const Text('Placer en dernier', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: EtmTokens.mutedOf(context), + ), + ), + ]); + } +} + class _RenameRow extends StatefulWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; diff --git a/lib/widgets/garde_brouillon.dart b/lib/widgets/garde_brouillon.dart new file mode 100644 index 0000000..dea704d --- /dev/null +++ b/lib/widgets/garde_brouillon.dart @@ -0,0 +1,73 @@ +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(); + + return PopScope( + canPop: !provider.hasPendingChanges, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + final partir = await _demander(context); + if (partir != true || !context.mounted) return; + provider.discardDraft(); + Navigator.of(context).pop(); + }, + child: child, + ); + } + + Future _demander(BuildContext context) => showDialog( + 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'), + ), + ], + ), + ); +} diff --git a/test/garde_brouillon_test.dart b/test/garde_brouillon_test.dart new file mode 100644 index 0000000..6b3b157 --- /dev/null +++ b/test/garde_brouillon_test.dart @@ -0,0 +1,111 @@ +/// **Quitter avec un brouillon en attente** — l'alerte, et ce qu'elle laisse derrière. +/// +/// Le brouillon vit dans `LoadConfigProvider`, pas dans l'écran : il survit à la +/// navigation. Sans garde, on quittait en laissant une configuration modifiée invisible, +/// qui réapparaissait au retour — ou disparaissait à la première relecture de la box, sans +/// que personne ait choisi. +library; + +import 'package:etm_powersync_app/providers/load_config_provider.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'; + +List> _config() => [ + {'id': 'a', 'label': 'A', 'priority': 1, 'domain': 'ecs'}, + {'id': 'b', 'label': 'B', 'priority': 2, 'domain': 'ev'}, + ]; + +/// Un écran poussé sur une pile, pour que « quitter » veuille dire quelque chose. +Future _ouvrir(WidgetTester tester, LoadConfigProvider config) async { + await tester.pumpWidget(ChangeNotifierProvider.value( + value: config, + child: MaterialApp( + home: Builder( + builder: (accueil) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(accueil).push(MaterialPageRoute( + builder: (_) => const GardeBrouillon( + child: Scaffold(body: Center(child: Text('écran de config'))), + ), + )), + child: const Text('ouvrir'), + ), + ), + ), + ), + ), + )); + await tester.tap(find.text('ouvrir')); + await tester.pumpAndSettle(); + expect(find.text('écran de config'), findsOneWidget); +} + +void main() { + testWidgets('sans brouillon, on sort sans être interrogé', (tester) async { + // Une alerte qui se déclenche pour rien apprend à passer outre sans lire. + final config = LoadConfigProvider()..adoptForTest(_config()); + await _ouvrir(tester, config); + + final ctx = tester.element(find.text('écran de config')); + Navigator.of(ctx).maybePop(); + await tester.pumpAndSettle(); + + expect(find.text('Modifications non enregistrées'), findsNothing); + expect(find.text('écran de config'), findsNothing, reason: 'sorti directement'); + }); + + testWidgets('avec un brouillon, l\'alerte s\'interpose', (tester) async { + final config = LoadConfigProvider()..adoptForTest(_config()); + config.setLabel('a', 'A modifié'); + expect(config.hasPendingChanges, isTrue); + + await _ouvrir(tester, config); + final ctx = tester.element(find.text('écran de config')); + Navigator.of(ctx).maybePop(); + await tester.pumpAndSettle(); + + expect(find.text('Modifications non enregistrées'), findsOneWidget); + // Elle dit l'état de la BOX, pas seulement celui de l'écran : c'est ce qui permet de + // décider sans avoir à s'en souvenir. + expect(find.textContaining('n\'ont pas été envoyés à la box'), findsOneWidget); + // Et on est toujours sur l'écran tant qu'on n'a pas tranché. + expect(find.text('écran de config'), findsOneWidget); + }); + + testWidgets('« Rester » ne quitte pas, et ne touche pas au brouillon', + (tester) async { + final config = LoadConfigProvider()..adoptForTest(_config()); + config.setLabel('a', 'A modifié'); + await _ouvrir(tester, config); + + Navigator.of(tester.element(find.text('écran de config'))).maybePop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Rester')); + await tester.pumpAndSettle(); + + expect(find.text('écran de config'), findsOneWidget); + expect(config.hasPendingChanges, isTrue); + }); + + testWidgets('« Quitter sans enregistrer » sort ET abandonne le brouillon', + (tester) async { + // 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é de perdre. + final config = LoadConfigProvider()..adoptForTest(_config()); + config.setLabel('a', 'A modifié'); + await _ouvrir(tester, config); + + Navigator.of(tester.element(find.text('écran de config'))).maybePop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Quitter sans enregistrer')); + await tester.pumpAndSettle(); + + expect(find.text('écran de config'), findsNothing); + expect(config.hasPendingChanges, isFalse); + // La configuration adoptée n'a pas bougé : rien n'est parti à la box. + expect(config.entries.firstWhere((e) => e.id == 'a').label, 'A'); + }); +}