From ac4927bdef505f4f63139d95fe79f56de4f7deb9 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 11 Aug 2026 13:44:03 +0200 Subject: [PATCH] =?UTF-8?q?Parcours=20:=20confirmation=20avant=20d'abandon?= =?UTF-8?q?ner=20du=20travail=20non=20enregistr=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les trois dialogues imbriqués (Parcours, Étape, Question) fermaient sur « Annuler » par un Navigator.pop sans rien demander, chacun jetant tout ce qui était saisi sous lui — le plus coûteux étant une question de quiz avec ses réponses. Confirmation ajoutée aux trois, plus interception du retour arrière du navigateur par PopScope : le clic hors fenêtre et la touche Échap étaient déjà neutralisés par barrierDismissible: false, le retour navigateur était le seul chemin de perte encore ouvert. Deux points non évidents : - l'instantané de référence est pris après la première frame, parce que ensureSimpleResponse écrit dans la question pendant la construction et ferait passer un dialog intact pour modifié ; - les deux dialogues partageant le même Navigator, popper depuis onYes fermerait la confirmation et non l'éditeur — la fermeture est différée d'une frame pour être déterministe. Réutilise showConfirmationDialog plutôt que d'ajouter un second composant. 3 clés i18n FR/EN/NL. flutter build web ✅, analyse du dossier propre. Co-Authored-By: Claude Opus 5 --- .../Parcours/showNewOrUpdateGuidedPath.dart | 44 ++++++++++++++++++- .../Parcours/showNewOrUpdateGuidedStep.dart | 37 +++++++++++++++- .../Parcours/showNewOrUpdateQuizQuestion.dart | 41 ++++++++++++++++- lib/l10n/app_en.arb | 5 ++- lib/l10n/app_fr.arb | 5 ++- lib/l10n/app_localizations.dart | 18 ++++++++ lib/l10n/app_localizations_en.dart | 12 +++++ lib/l10n/app_localizations_fr.dart | 12 +++++ lib/l10n/app_localizations_nl.dart | 12 +++++ lib/l10n/app_nl.arb | 5 ++- 10 files changed, 182 insertions(+), 9 deletions(-) diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart index 18eff17..bdd6137 100644 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart @@ -5,6 +5,7 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:manager_api_new/api.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; +import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/rounded_button.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:manager_app/Components/check_input_container.dart'; @@ -41,12 +42,42 @@ void showNewOrUpdateGuidedPath( bool isSaving = false; int stepsRevision = 0; + // Instantané de référence pour détecter les modifications non enregistrées. + // Pris après la première frame : le corps du dialog normalise certains champs + // pendant sa construction, et ces écritures ne sont pas des saisies. + String? baseline; + bool hasUnsavedChanges() => + baseline != null && jsonEncode(workingPath) != baseline; + showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return StatefulBuilder( builder: (context, setState) { + if (baseline == null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => baseline = jsonEncode(workingPath)); + } + + void closeWithConfirmation() { + if (!hasUnsavedChanges()) { + Navigator.pop(context); + return; + } + showConfirmationDialog( + AppLocalizations.of(context)!.discardPathChangesConfirm, + () {}, + // Les deux dialogs partagent le même Navigator : popper ici + // fermerait la confirmation, pas l'éditeur. On attend donc que + // showConfirmationDialog ait retiré sa propre route. + () => WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.pop(context); + }), + context, + ); + } + final double screenWidth = MediaQuery.of(context).size.width; final double screenHeight = MediaQuery.of(context).size.height; final double dialogWidth = screenWidth * 0.82; @@ -54,7 +85,15 @@ void showNewOrUpdateGuidedPath( final double contentWidth = dialogWidth - 48; final double halfWidth = (contentWidth - 20) / 2; - return Dialog( + return PopScope( + // `barrierDismissible: false` neutralise déjà le clic hors dialog et + // la touche Échap, mais pas le retour arrière du navigateur — sur une + // app web c'est le vrai chemin par lequel la saisie disparaît. + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) closeWithConfirmation(); + }, + child: Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Container( @@ -436,7 +475,7 @@ void showNewOrUpdateGuidedPath( height: 46, child: RoundedButton( text: "Annuler", - press: () => Navigator.pop(context), + press: closeWithConfirmation, color: kSecond, fontSize: 15, horizontal: 24, @@ -476,6 +515,7 @@ void showNewOrUpdateGuidedPath( ], ), ), + ), ); }, ); diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart index 97d1b69..0edd4b4 100644 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart @@ -44,6 +44,11 @@ void showNewOrUpdateGuidedStep( bool isSaving = false; int questionsRevision = 0; + // Voir showNewOrUpdateGuidedPath : instantané pris après la première frame. + String? baseline; + bool hasUnsavedChanges() => + baseline != null && jsonEncode(workingStep) != baseline; + showDialog( context: context, barrierDismissible: false, @@ -57,7 +62,34 @@ void showNewOrUpdateGuidedStep( final double halfWidth = (contentWidth - 20) / 2; final appCtx = Provider.of(context, listen: false); - return Dialog( + if (baseline == null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => baseline = jsonEncode(workingStep)); + } + + void closeWithConfirmation() { + if (!hasUnsavedChanges()) { + Navigator.pop(context); + return; + } + showConfirmationDialog( + AppLocalizations.of(context)!.discardStepChangesConfirm, + () {}, + // Voir showNewOrUpdateGuidedPath : on laisse la confirmation + // retirer sa route avant de fermer l'éditeur. + () => WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.pop(context); + }), + context, + ); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) closeWithConfirmation(); + }, + child: Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Container( @@ -464,7 +496,7 @@ void showNewOrUpdateGuidedStep( height: 46, child: RoundedButton( text: "Annuler", - press: () => Navigator.pop(context), + press: closeWithConfirmation, color: kSecond, fontSize: 15, horizontal: 24, @@ -502,6 +534,7 @@ void showNewOrUpdateGuidedStep( ], ), ), + ), ); }, ); diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart index 2d7ed23..7d3bec8 100644 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; +import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/rounded_button.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:manager_app/Components/resource_input_container.dart'; @@ -58,8 +59,16 @@ void showNewOrUpdateQuizQuestion( guidedStepId: question?.guidedStepId ?? stepId, ); + // Voir showNewOrUpdateGuidedPath. L'instantané est d'autant plus nécessaire ici + // que `ensureSimpleResponse` écrit dans workingQuestion pendant la construction : + // le prendre avant la première frame déclarerait « modifié » un dialog intact. + String? baseline; + bool hasUnsavedChanges() => + baseline != null && jsonEncode(workingQuestion) != baseline; + showDialog( context: context, + barrierDismissible: false, builder: (BuildContext context) { return StatefulBuilder( builder: (context, setState) { @@ -69,6 +78,28 @@ void showNewOrUpdateQuizQuestion( final double contentWidth = dialogWidth - 48; final double halfWidth = (contentWidth - 20) / 2; + if (baseline == null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => baseline = jsonEncode(workingQuestion)); + } + + void closeWithConfirmation() { + if (!hasUnsavedChanges()) { + Navigator.pop(context); + return; + } + showConfirmationDialog( + AppLocalizations.of(context)!.discardQuestionChangesConfirm, + () {}, + // Voir showNewOrUpdateGuidedPath : on laisse la confirmation + // retirer sa route avant de fermer l'éditeur. + () => WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.pop(context); + }), + context, + ); + } + void ensureSimpleResponse() { if (workingQuestion.responses.isEmpty) { workingQuestion.responses @@ -77,7 +108,12 @@ void showNewOrUpdateQuizQuestion( workingQuestion.responses[0].isGood = true; } - return Dialog( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) closeWithConfirmation(); + }, + child: Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Container( @@ -378,7 +414,7 @@ void showNewOrUpdateQuizQuestion( height: 46, child: RoundedButton( text: "Annuler", - press: () => Navigator.pop(context), + press: closeWithConfirmation, color: kSecond, fontSize: 15, horizontal: 24, @@ -408,6 +444,7 @@ void showNewOrUpdateQuizQuestion( ], ), ), + ), ); }, ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c781437..2a9e993 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -681,5 +681,8 @@ "resourceDeleteConfirm": "Are you sure you want to delete this resource?", "categoriesTitle": "Categories", "endTimeLabel": "End time", - "annotationsLabel": "Annotations" + "annotationsLabel": "Annotations", + "discardPathChangesConfirm": "This path has unsaved changes, along with its steps and their questions. Discard these changes?", + "discardStepChangesConfirm": "This step has unsaved changes, along with its questions. Discard these changes?", + "discardQuestionChangesConfirm": "This question has unsaved changes, along with its answers. Discard these changes?" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 1ef366c..fa6b9e4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -681,5 +681,8 @@ "resourceDeleteConfirm": "Êtes-vous sûr de vouloir supprimer cette ressource ?", "categoriesTitle": "Catégories", "endTimeLabel": "Heure de fin", - "annotationsLabel": "Annotations" + "annotationsLabel": "Annotations", + "discardPathChangesConfirm": "Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?", + "discardStepChangesConfirm": "Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?", + "discardQuestionChangesConfirm": "Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1687d55..d702094 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3297,6 +3297,24 @@ abstract class AppLocalizations { /// In fr, this message translates to: /// **'Annotations'** String get annotationsLabel; + + /// No description provided for @discardPathChangesConfirm. + /// + /// In fr, this message translates to: + /// **'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?'** + String get discardPathChangesConfirm; + + /// No description provided for @discardStepChangesConfirm. + /// + /// In fr, this message translates to: + /// **'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?'** + String get discardStepChangesConfirm; + + /// No description provided for @discardQuestionChangesConfirm. + /// + /// In fr, this message translates to: + /// **'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?'** + String get discardQuestionChangesConfirm; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 85020dc..9439e2e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1744,4 +1744,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get annotationsLabel => 'Annotations'; + + @override + String get discardPathChangesConfirm => + 'This path has unsaved changes, along with its steps and their questions. Discard these changes?'; + + @override + String get discardStepChangesConfirm => + 'This step has unsaved changes, along with its questions. Discard these changes?'; + + @override + String get discardQuestionChangesConfirm => + 'This question has unsaved changes, along with its answers. Discard these changes?'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6f0191b..dbc9813 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1782,4 +1782,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get annotationsLabel => 'Annotations'; + + @override + String get discardPathChangesConfirm => + 'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?'; + + @override + String get discardStepChangesConfirm => + 'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?'; + + @override + String get discardQuestionChangesConfirm => + 'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 336fbaf..a7d10b1 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1761,4 +1761,16 @@ class AppLocalizationsNl extends AppLocalizations { @override String get annotationsLabel => 'Annotaties'; + + @override + String get discardPathChangesConfirm => + 'Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?'; + + @override + String get discardStepChangesConfirm => + 'Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?'; + + @override + String get discardQuestionChangesConfirm => + 'Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 12c814b..0f4098e 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -681,5 +681,8 @@ "resourceDeleteConfirm": "Weet u zeker dat u deze resource wilt verwijderen?", "categoriesTitle": "Categorieën", "endTimeLabel": "Eindtijd", - "annotationsLabel": "Annotaties" + "annotationsLabel": "Annotaties", + "discardPathChangesConfirm": "Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?", + "discardStepChangesConfirm": "Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?", + "discardQuestionChangesConfirm": "Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?" }