Parcours : confirmation avant d'abandonner du travail non enregistré

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-11 13:44:03 +02:00
parent 9d9f4bc395
commit ac4927bdef
10 changed files with 182 additions and 9 deletions

View File

@ -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(
],
),
),
),
);
},
);

View File

@ -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<AppContext>(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(
],
),
),
),
);
},
);

View File

@ -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(
],
),
),
),
);
},
);

View File

@ -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?"
}

View File

@ -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 ?"
}

View File

@ -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

View File

@ -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?';
}

View File

@ -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 ?';
}

View File

@ -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?';
}

View File

@ -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?"
}