manager-app/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart
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

454 lines
22 KiB
Dart

import 'dart:convert';
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';
import 'package:manager_app/Components/number_input_container.dart';
import 'package:manager_app/Components/check_input_container.dart';
// Conversions between TranslationDTO and TranslationAndResourceDTO
// (ResponseDTO.label uses TranslationAndResourceDTO, but we use MultiStringInputContainer
// which requires TranslationDTO — the resource field is not needed for quiz responses)
List<TranslationDTO> _toTranslationList(
List<TranslationAndResourceDTO>? list) =>
(list ?? [])
.map((t) => TranslationDTO(language: t.language, value: t.value))
.toList();
List<TranslationAndResourceDTO> _fromTranslationList(
List<TranslationDTO> list) =>
list
.map((t) =>
TranslationAndResourceDTO(language: t.language, value: t.value))
.toList();
// Creates an empty ResponseDTO; labels are populated by MultiStringInputContainer
ResponseDTO _emptyResponse({bool isGood = false, int order = 0}) =>
ResponseDTO(label: [], isGood: isGood, order: order);
void showNewOrUpdateQuizQuestion(
BuildContext context,
QuizQuestion? question,
String stepId,
bool isEscapeMode,
Function(QuizQuestion) onSave,
) {
// Use JSON cloning for a robust deep copy
QuizQuestion? clonedQuestion = question != null
? QuizQuestion.fromJson(jsonDecode(jsonEncode(question)))
: null;
List<TranslationDTO> workingLabel = _toTranslationList(
clonedQuestion != null && clonedQuestion.label.isNotEmpty
? clonedQuestion.label
: [TranslationAndResourceDTO(language: 'FR', value: '')]);
List<ResponseDTO> workingResponses = clonedQuestion?.responses ?? [];
QuizQuestion workingQuestion = clonedQuestion ??
QuizQuestion(
id: 0,
label: _fromTranslationList(workingLabel),
responses: workingResponses,
validationQuestionType: QuestionType.number0,
order: question?.order ?? 0,
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) {
final double screenWidth = MediaQuery.of(context).size.width;
final double screenHeight = MediaQuery.of(context).size.height;
final double dialogWidth = screenWidth * 0.65;
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
.add(_emptyResponse(isGood: true, order: 0));
}
workingQuestion.responses[0].isGood = true;
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) closeWithConfirmation();
},
child: Dialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.85),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
question == null
? "Nouvelle Question"
: "Modifier la Question",
style: TextStyle(
color: kPrimaryColor,
fontSize: 20,
fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- Intitulé (multi-langue via MultiStringInputContainer) ---
MultiStringInputContainer(
label: AppLocalizations.of(context)!.questionAskedLabel,
modalLabel: AppLocalizations.of(context)!.questionTitleLabel,
initialValue: workingLabel,
onGetResult: (val) => setState(() {
workingLabel = val;
workingQuestion.label = _fromTranslationList(val);
}),
maxLines: 3,
isTitle: false,
isHTML: true,
showPreview: true,
),
SizedBox(height: 16),
// --- Type ---
Text("Type de validation :",
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
DropdownButton<QuestionType>(
value: workingQuestion.validationQuestionType,
items: [
DropdownMenuItem(
value: QuestionType.number0,
child: Text("Simple (texte attendu)")),
DropdownMenuItem(
value: QuestionType.number1,
child: Text("Choix multiples (QCM)")),
DropdownMenuItem(
value: QuestionType.number2,
child: Text("Puzzle")),
],
onChanged: (val) => setState(() {
workingQuestion.validationQuestionType = val;
workingQuestion.responses = [];
}),
),
// =========================================
// Type 0 : Simple texte
// =========================================
if (workingQuestion.validationQuestionType ==
QuestionType.number0) ...[
Divider(height: 24),
Text(AppLocalizations.of(context)!.expectedAnswerLabel,
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Builder(builder: (_) {
ensureSimpleResponse();
final List<TranslationDTO> respLabel =
_toTranslationList(
workingQuestion.responses[0].label);
return MultiStringInputContainer(
label: "",
modalLabel: AppLocalizations.of(context)!.expectedAnswerModalLabel,
initialValue: respLabel,
onGetResult: (val) => setState(() {
workingQuestion.responses[0].label =
_fromTranslationList(val);
workingQuestion.responses[0].isGood = true;
}),
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
);
}),
SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.validationNote,
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: kPrimaryColor.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.dialpad, size: 18, color: kPrimaryColor),
SizedBox(width: 8),
Expanded(
child: Text(
"Si la réponse attendue ne contient que des chiffres, le visiteur "
"voit automatiquement un pavé numérique façon cadenas (digicode) "
"au lieu du champ texte. Rien à configurer.",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
),
],
),
),
],
// =========================================
// Type 1 : QCM
// =========================================
if (workingQuestion.validationQuestionType ==
QuestionType.number1) ...[
Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(AppLocalizations.of(context)!.possibleAnswersLabel,
style:
TextStyle(fontWeight: FontWeight.bold)),
TextButton.icon(
icon: Icon(Icons.add_circle_outline,
color: kSuccess),
label: Text("Ajouter",
style: TextStyle(color: kSuccess)),
onPressed: () => setState(() =>
workingQuestion.responses.add(
_emptyResponse(
order: workingQuestion
.responses.length))),
),
],
),
if (workingQuestion.responses.isEmpty)
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8),
child: Text(
AppLocalizations.of(context)!.noAnswerDefined,
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
)
else
Column(
children: List.generate(
workingQuestion.responses.length, (i) {
final resp = workingQuestion.responses[i];
final List<TranslationDTO> respLabel =
_toTranslationList(resp.label);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
// Checkbox bonne réponse
Tooltip(
message: resp.isGood == true
? AppLocalizations.of(context)!.correctAnswer
: AppLocalizations.of(context)!.wrongAnswer,
child: Checkbox(
value: resp.isGood ?? false,
activeColor: kSuccess,
onChanged: (val) => setState(
() => resp.isGood = val),
),
),
// Traductions (via MultiStringInputContainer)
Expanded(
child: MultiStringInputContainer(
label: "${AppLocalizations.of(context)!.answerLabel} ${i + 1} :",
modalLabel: "${AppLocalizations.of(context)!.answerLabel} ${i + 1}",
initialValue: respLabel,
onGetResult: (val) => setState(
() => resp.label =
_fromTranslationList(
val)),
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
),
),
// Supprimer
IconButton(
icon: Icon(Icons.delete_outline,
color: kError, size: 20),
onPressed: () => setState(() =>
workingQuestion.responses
.removeAt(i)),
),
],
),
),
);
}),
),
SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.answerNote,
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
],
// =========================================
// Type 2 : Puzzle
// =========================================
if (workingQuestion.validationQuestionType ==
QuestionType.number2) ...[
Divider(height: 24),
Text("Configuration du Puzzle",
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 12),
ResourceInputContainer(
label: "Image du puzzle :",
initialValue: workingQuestion.puzzleImageId,
onChanged: (res) => setState(() {
workingQuestion.puzzleImageId = res.id;
workingQuestion.puzzleImage =
Resource.fromJson(res.toJson());
}),
),
SizedBox(height: 12),
Row(
children: [
SizedBox(
width: halfWidth,
child: NumberInputContainer(
label: "Lignes :",
initialValue:
workingQuestion.puzzleRows ?? 3,
onChanged: (val) => setState(() =>
workingQuestion.puzzleRows =
int.tryParse(val) ?? 3),
isSmall: true,
),
),
SizedBox(width: 20),
SizedBox(
width: halfWidth,
child: NumberInputContainer(
label: "Colonnes :",
initialValue:
workingQuestion.puzzleCols ?? 3,
onChanged: (val) => setState(() =>
workingQuestion.puzzleCols =
int.tryParse(val) ?? 3),
isSmall: true,
),
),
],
),
SizedBox(height: 8),
CheckInputContainer(
label: "Puzzle glissant (Sliding) :",
isChecked:
workingQuestion.isSlidingPuzzle ?? false,
onChanged: (val) => setState(
() => workingQuestion.isSlidingPuzzle = val),
),
],
],
),
),
),
SizedBox(height: 16),
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: "Sauvegarder",
press: () {
for (int i = 0;
i < workingQuestion.responses.length;
i++) {
workingQuestion.responses[i].order = i;
}
onSave(workingQuestion);
Navigator.pop(context);
},
color: kPrimaryColor,
fontSize: 15,
horizontal: 24,
),
),
],
),
],
),
),
),
);
},
);
},
);
}