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:
parent
9d9f4bc395
commit
ac4927bdef
@ -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_api_new/api.dart';
|
||||||
import 'package:manager_app/constants.dart';
|
import 'package:manager_app/constants.dart';
|
||||||
import 'package:manager_app/l10n/app_localizations.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/rounded_button.dart';
|
||||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||||
import 'package:manager_app/Components/check_input_container.dart';
|
import 'package:manager_app/Components/check_input_container.dart';
|
||||||
@ -41,12 +42,42 @@ void showNewOrUpdateGuidedPath(
|
|||||||
bool isSaving = false;
|
bool isSaving = false;
|
||||||
int stepsRevision = 0;
|
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(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
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 screenWidth = MediaQuery.of(context).size.width;
|
||||||
final double screenHeight = MediaQuery.of(context).size.height;
|
final double screenHeight = MediaQuery.of(context).size.height;
|
||||||
final double dialogWidth = screenWidth * 0.82;
|
final double dialogWidth = screenWidth * 0.82;
|
||||||
@ -54,7 +85,15 @@ void showNewOrUpdateGuidedPath(
|
|||||||
final double contentWidth = dialogWidth - 48;
|
final double contentWidth = dialogWidth - 48;
|
||||||
final double halfWidth = (contentWidth - 20) / 2;
|
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:
|
shape:
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -436,7 +475,7 @@ void showNewOrUpdateGuidedPath(
|
|||||||
height: 46,
|
height: 46,
|
||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
text: "Annuler",
|
text: "Annuler",
|
||||||
press: () => Navigator.pop(context),
|
press: closeWithConfirmation,
|
||||||
color: kSecond,
|
color: kSecond,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
@ -476,6 +515,7 @@ void showNewOrUpdateGuidedPath(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -44,6 +44,11 @@ void showNewOrUpdateGuidedStep(
|
|||||||
bool isSaving = false;
|
bool isSaving = false;
|
||||||
int questionsRevision = 0;
|
int questionsRevision = 0;
|
||||||
|
|
||||||
|
// Voir showNewOrUpdateGuidedPath : instantané pris après la première frame.
|
||||||
|
String? baseline;
|
||||||
|
bool hasUnsavedChanges() =>
|
||||||
|
baseline != null && jsonEncode(workingStep) != baseline;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -57,7 +62,34 @@ void showNewOrUpdateGuidedStep(
|
|||||||
final double halfWidth = (contentWidth - 20) / 2;
|
final double halfWidth = (contentWidth - 20) / 2;
|
||||||
final appCtx = Provider.of<AppContext>(context, listen: false);
|
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:
|
shape:
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -464,7 +496,7 @@ void showNewOrUpdateGuidedStep(
|
|||||||
height: 46,
|
height: 46,
|
||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
text: "Annuler",
|
text: "Annuler",
|
||||||
press: () => Navigator.pop(context),
|
press: closeWithConfirmation,
|
||||||
color: kSecond,
|
color: kSecond,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
@ -502,6 +534,7 @@ void showNewOrUpdateGuidedStep(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
import 'package:manager_app/constants.dart';
|
import 'package:manager_app/constants.dart';
|
||||||
import 'package:manager_app/l10n/app_localizations.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/rounded_button.dart';
|
||||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||||
import 'package:manager_app/Components/resource_input_container.dart';
|
import 'package:manager_app/Components/resource_input_container.dart';
|
||||||
@ -58,8 +59,16 @@ void showNewOrUpdateQuizQuestion(
|
|||||||
guidedStepId: question?.guidedStepId ?? stepId,
|
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(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
@ -69,6 +78,28 @@ void showNewOrUpdateQuizQuestion(
|
|||||||
final double contentWidth = dialogWidth - 48;
|
final double contentWidth = dialogWidth - 48;
|
||||||
final double halfWidth = (contentWidth - 20) / 2;
|
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() {
|
void ensureSimpleResponse() {
|
||||||
if (workingQuestion.responses.isEmpty) {
|
if (workingQuestion.responses.isEmpty) {
|
||||||
workingQuestion.responses
|
workingQuestion.responses
|
||||||
@ -77,7 +108,12 @@ void showNewOrUpdateQuizQuestion(
|
|||||||
workingQuestion.responses[0].isGood = true;
|
workingQuestion.responses[0].isGood = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Dialog(
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) closeWithConfirmation();
|
||||||
|
},
|
||||||
|
child: Dialog(
|
||||||
shape:
|
shape:
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -378,7 +414,7 @@ void showNewOrUpdateQuizQuestion(
|
|||||||
height: 46,
|
height: 46,
|
||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
text: "Annuler",
|
text: "Annuler",
|
||||||
press: () => Navigator.pop(context),
|
press: closeWithConfirmation,
|
||||||
color: kSecond,
|
color: kSecond,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
@ -408,6 +444,7 @@ void showNewOrUpdateQuizQuestion(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -681,5 +681,8 @@
|
|||||||
"resourceDeleteConfirm": "Are you sure you want to delete this resource?",
|
"resourceDeleteConfirm": "Are you sure you want to delete this resource?",
|
||||||
"categoriesTitle": "Categories",
|
"categoriesTitle": "Categories",
|
||||||
"endTimeLabel": "End time",
|
"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?"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -681,5 +681,8 @@
|
|||||||
"resourceDeleteConfirm": "Êtes-vous sûr de vouloir supprimer cette ressource ?",
|
"resourceDeleteConfirm": "Êtes-vous sûr de vouloir supprimer cette ressource ?",
|
||||||
"categoriesTitle": "Catégories",
|
"categoriesTitle": "Catégories",
|
||||||
"endTimeLabel": "Heure de fin",
|
"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 ?"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3297,6 +3297,24 @@ abstract class AppLocalizations {
|
|||||||
/// In fr, this message translates to:
|
/// In fr, this message translates to:
|
||||||
/// **'Annotations'**
|
/// **'Annotations'**
|
||||||
String get annotationsLabel;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@ -1744,4 +1744,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get annotationsLabel => 'Annotations';
|
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?';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1782,4 +1782,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get annotationsLabel => 'Annotations';
|
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 ?';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1761,4 +1761,16 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get annotationsLabel => 'Annotaties';
|
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?';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -681,5 +681,8 @@
|
|||||||
"resourceDeleteConfirm": "Weet u zeker dat u deze resource wilt verwijderen?",
|
"resourceDeleteConfirm": "Weet u zeker dat u deze resource wilt verwijderen?",
|
||||||
"categoriesTitle": "Categorieën",
|
"categoriesTitle": "Categorieën",
|
||||||
"endTimeLabel": "Eindtijd",
|
"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?"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user