feat(config): quitter avec un brouillon demande, et « Placer en dernier » rejoint « Renommer »
Deux retours d'usage, tous deux sur la même carte. 1. « Placer en dernier » poussait « Configurer » hors de sa place. Il rejoint « Renommer » sur une ligne à part : ce sont les deux gestes qui ne touchent pas au mécanisme, et un bouton d'action principale ne se fait pas déplacer par un geste secondaire. 2. Quitter l'écran avec des modifications non enregistrées ne demandait rien — et ce silence cachait pire qu'une perte : le brouillon vit dans LoadConfigProvider, pas dans l'écran, donc il SURVIT à la navigation. On repartait en laissant une configuration modifiée invisible, qui réapparaissait au retour sans contexte, 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. GardeBrouillon (PopScope) s'interpose sur les DEUX écrans qui partagent ce brouillon — la liste des charges et l'écran de mécanisme. L'alerte dit l'état de la BOX, pas seulement celui de l'écran : « vos changements n'ont pas été envoyés, elle porte toujours la configuration d'avant ». Deux issues, toutes deux explicites : rester, ou quitter en abandonnant — un brouillon fantôme qui ressort trois écrans plus loin est pire qu'une modification perdue qu'on a accepté de perdre. Aucune écriture n'est proposée depuis la boîte de dialogue : enregistrer depuis une sortie ferait partir une configuration que personne n'a relue. Quatre tests, dont le contrôle négatif — sans brouillon, on sort sans être interrogé. Une alerte qui se déclenche pour rien apprend à passer outre sans lire. 235 tests (+4), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
parent
462ca4a2fe
commit
438f261de7
@ -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<LoadMechanismScreen> {
|
||||
final telemetry = context.watch<LoadTelemetryProvider>();
|
||||
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<LoadMechanismScreen> {
|
||||
entry: e, provider: provider, service: service),
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
void _majSgReady(LoadConfigProvider p, LoadConfigEntry e, {required int hold}) {
|
||||
|
||||
@ -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<RolesDevicesScreen> {
|
||||
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<RolesDevicesScreen> {
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
73
lib/widgets/garde_brouillon.dart
Normal file
73
lib/widgets/garde_brouillon.dart
Normal file
@ -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<LoadConfigProvider>();
|
||||
|
||||
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<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
111
test/garde_brouillon_test.dart
Normal file
111
test/garde_brouillon_test.dart
Normal file
@ -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<Map<String, dynamic>> _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<void> _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');
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user