Thomas Fransolet ac4927bdef 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>
2026-08-11 13:44:03 +02:00

525 lines
27 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
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';
import 'package:manager_app/Components/number_stepper_field.dart';
import 'package:manager_app/Components/resource_input_container.dart';
import 'package:manager_app/Components/reorderable_custom_list.dart';
import 'package:manager_app/Components/section_card.dart';
import 'progression_mode.dart';
import 'showNewOrUpdateGuidedStep.dart';
void showNewOrUpdateGuidedPath(
BuildContext context,
GuidedPathDTO? path,
String parentId,
bool isEvent,
FutureOr<void> Function(GuidedPathDTO) onSave, {
bool isGeolocated = true,
}) {
GuidedPathDTO workingPath = path != null
? GuidedPathDTO.fromJson(jsonDecode(jsonEncode(path)))!
: GuidedPathDTO(
title: [],
description: [],
steps: [],
order: 0,
// Doivent correspondre au mode que `progressionModeOf` affiche par
// défaut (Dans l'ordre) : laissés nuls, la radio montrait « Dans
// l'ordre » et la sauvegarde enregistrait « Libre ».
isLinear: true,
requireSuccessToAdvance: false,
hideNextStepsUntilComplete: false,
);
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;
// contentWidth = dialogWidth minus the 24px padding on each side
final double contentWidth = dialogWidth - 48;
final double halfWidth = (contentWidth - 20) / 2;
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(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.88),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// -- Titre du dialog --
Text(
path == null ? "Nouveau Parcours" : "Modifier le Parcours",
style: TextStyle(
color: kPrimaryColor,
fontSize: 20,
fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
// -- Corps scrollable --
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image du parcours
ResourceInputContainer(
label: "Image :",
color: kPrimaryColor,
initialValue: workingPath.imageResourceId,
onChanged: (resourceDTO) => setState(
() => workingPath.imageResourceId = resourceDTO.id),
),
SizedBox(height: 16),
// Titre + Description côte à côte
Row(
children: [
SizedBox(
width: halfWidth,
child: MultiStringInputContainer(
label: "Titre :",
modalLabel: "Titre du parcours",
initialValue: workingPath.title ?? [],
onGetResult: (val) =>
setState(() => workingPath.title = val),
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
),
),
SizedBox(width: 20),
SizedBox(
width: halfWidth,
child: MultiStringInputContainer(
label: "Description :",
modalLabel: "Description du parcours",
initialValue: workingPath.description ?? [],
onGetResult: (val) => setState(
() => workingPath.description = val),
maxLines: 1,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
SizedBox(height: 16),
// Mode jeu
SectionCard(
icon: Icons.sports_esports,
title: "Ambiance",
subtitle: "Visite classique ou jeu",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Quelle ambiance ?",
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600),
),
RadioGroup<bool>(
groupValue: workingPath.isGameMode ?? false,
onChanged: (val) => setState(
() => workingPath.isGameMode = val),
child: Column(
children: [
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: false,
title: Text("Visite",
style: TextStyle(fontSize: 14)),
subtitle: Text(
"Parcours de découverte : le visiteur avance à son rythme.",
style: TextStyle(
fontSize: 12,
color: Colors.grey[600]),
),
),
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: true,
title: Text("Jeu",
style: TextStyle(fontSize: 14)),
subtitle: Text(
"Escape game ou chasse au trésor : messages de début et de fin, vocabulaire de jeu sur les étapes.",
style: TextStyle(
fontSize: 12,
color: Colors.grey[600]),
),
),
],
),
),
if (workingPath.isGameMode == true) ...[
SizedBox(height: 8),
Row(
children: [
Expanded(
child: MultiStringInputContainer(
label: "Message de début :",
modalLabel: "Message d'introduction du jeu",
color: kPrimaryColor,
initialValue: (workingPath.gameMessageDebut ?? [])
.map((t) => TranslationDTO(language: t.language, value: t.value))
.toList(),
onGetResult: (val) {
setState(() {
workingPath.gameMessageDebut = val.map((t) {
final prev = (workingPath.gameMessageDebut ?? []).firstWhere(
(e) => e.language == t.language,
orElse: () => TranslationAndResourceDTO(),
);
return TranslationAndResourceDTO(
language: t.language,
value: t.value,
resourceId: prev.resourceId,
resource: prev.resource,
);
}).toList();
});
},
maxLines: 2,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
SizedBox(width: 20),
Expanded(
child: MultiStringInputContainer(
label: "Message de fin :",
modalLabel: "Message de félicitations du jeu",
color: kPrimaryColor,
initialValue: (workingPath.gameMessageFin ?? [])
.map((t) => TranslationDTO(language: t.language, value: t.value))
.toList(),
onGetResult: (val) {
setState(() {
workingPath.gameMessageFin = val.map((t) {
final prev = (workingPath.gameMessageFin ?? []).firstWhere(
(e) => e.language == t.language,
orElse: () => TranslationAndResourceDTO(),
);
return TranslationAndResourceDTO(
language: t.language,
value: t.value,
resourceId: prev.resourceId,
resource: prev.resource,
);
}).toList();
});
},
maxLines: 2,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
],
],
),
),
SizedBox(height: 16),
// Options
SectionCard(
icon: Icons.settings,
title: "Options du parcours",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Comment le visiteur progresse-t-il ?",
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600),
),
RadioGroup<ProgressionMode>(
groupValue: progressionModeOf(workingPath),
onChanged: (val) => setState(() =>
applyProgressionMode(workingPath, val!)),
child: Column(
children: [
for (final mode in ProgressionMode.values)
RadioListTile<ProgressionMode>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: mode,
title: Text(mode.label,
style: TextStyle(fontSize: 14)),
subtitle: Text(mode.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600])),
),
],
),
),
if (progressionModeOf(workingPath) ==
ProgressionMode.stepByStep)
Padding(
padding: const EdgeInsets.only(left: 16),
child: CheckInputContainer(
label: "Masquer les étapes pas encore atteintes",
subtitle:
"Le visiteur ne découvre les étapes suivantes qu'au fur et à mesure.",
isChecked:
workingPath.hideNextStepsUntilComplete ??
false,
onChanged: (val) => setState(() => workingPath
.hideNextStepsUntilComplete = val),
),
),
SizedBox(height: 8),
NumberStepperField(
label: "Durée estimée",
value: workingPath.estimatedDurationMinutes,
min: 0,
max: 600,
unit: "min",
onChanged: (val) => setState(() =>
workingPath.estimatedDurationMinutes = val.toInt()),
),
],
),
),
SizedBox(height: 16),
// Étapes
SectionCard(
icon: Icons.list_alt,
title: AppLocalizations.of(context)!.pathStepsLabel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: Icon(Icons.add_circle_outline,
color: kSuccess),
onPressed: () {
showNewOrUpdateGuidedStep(
context,
null,
workingPath.id ?? "temp",
workingPath.isGameMode ?? false,
isGeolocated: isGeolocated,
(newStep) async {
setState(() {
newStep.order =
workingPath.steps?.length ?? 0;
workingPath.steps = [
...(workingPath.steps ?? []),
newStep
];
stepsRevision++;
});
},
);
},
),
),
if (workingPath.steps?.isEmpty ?? true)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
AppLocalizations.of(context)!.noStepConfigured,
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
)
else
ReorderableCustomList<GuidedStepDTO>(
key: ValueKey(stepsRevision),
items: workingPath.steps!,
shrinkWrap: true,
onChanged: (updatedList) {
setState(() {
for (var i = 0; i < updatedList.length; i++) {
updatedList[i].order = i;
}
workingPath.steps = List.from(updatedList);
});
},
itemBuilder: (context, index, step) {
return ListTile(
leading:
CircleAvatar(child: Text("${index + 1}")),
title: HtmlWidget(
step.title != null && step.title!.isNotEmpty
? step.title!
.firstWhere(
(t) => t.language == 'FR',
orElse: () =>
step.title![0])
.value ??
"${AppLocalizations.of(context)!.stepFallback} $index"
: "${AppLocalizations.of(context)!.stepFallback} $index",
),
subtitle: workingPath.isGameMode ?? false
? Text(
"${step.quizQuestions?.length ?? 0} question(s)")
: null,
);
},
actions: [
(context, index, step) => IconButton(
icon: Icon(Icons.edit,
color: kPrimaryColor),
onPressed: () {
showNewOrUpdateGuidedStep(
context,
step,
workingPath.id ?? "temp",
workingPath.isGameMode ?? false,
isGeolocated: isGeolocated,
(updatedStep) async {
setState(() {
updatedStep.order = step.order;
workingPath.steps![index] =
updatedStep;
stepsRevision++;
});
},
);
},
),
(context, index, step) => IconButton(
icon: Icon(Icons.delete, color: kError),
onPressed: () {
setState(() {
workingPath.steps!.removeAt(index);
stepsRevision++;
});
},
),
],
),
],
),
),
],
),
),
),
SizedBox(height: 16),
// -- Boutons --
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SizedBox(
height: 46,
child: RoundedButton(
text: "Annuler",
press: closeWithConfirmation,
color: kSecond,
fontSize: 15,
horizontal: 24,
),
),
SizedBox(width: 12),
SizedBox(
height: 46,
child: RoundedButton(
text: isSaving ? "Sauvegarde..." : "Sauvegarder",
icon: isSaving ? Icons.hourglass_empty : null,
press: () async {
if (isSaving) return;
setState(() => isSaving = true);
// Initialise les booleans null → false
workingPath.isLinear ??= false;
workingPath.requireSuccessToAdvance ??= false;
workingPath.hideNextStepsUntilComplete ??= false;
// Initialise les booleans nuls dans chaque étape
for (final s in workingPath.steps ?? []) {
s.isStepTimer ??= false;
}
try {
await onSave(workingPath);
if (context.mounted) Navigator.pop(context);
} catch (e) {
setState(() => isSaving = false);
}
},
color: kPrimaryColor,
fontSize: 15,
horizontal: 24,
),
),
],
),
],
),
),
),
);
},
);
},
);
}