DB4 : les quatre fenêtres empilées du parcours deviennent une seule

Option A. Il fallait cinq surfaces empilées pour poser une question sur une
étape et l'écrire en néerlandais ; la profondeur maximale retombe à 2 —
l'éditeur, puis la traduction.

Les trois showNewOrUpdate… sont supprimés au profit d'un rail d'étapes
toujours visible à gauche, d'un panneau de détail à droite, et d'une question
qui se déplie sur place au lieu d'ouvrir une quatrième fenêtre.

Les champs sont d'abord sortis en trois widgets autonomes (ParcoursFields,
EtapeFields, QuestionFields) : c'est ce qui a rendu la refonte possible sans
tout réécrire — 1663 lignes retirées, l'écran n'a pas réécrit les champs, il
les a réagencés.

SAUVEGARDE AU FIL DE L'EAU (héritée de DB2) — plus de bouton Sauvegarder,
tout part à la saisie avec un débounce de 700 ms ; ajouts, suppressions et
réordonnancements partent sans attendre. GuidedPathApi masque à l'éditeur le
fait qu'un parcours vive sous une SectionParcours ou sous une SectionMap :
mêmes opérations, seule la classe générée change.

Deux pièges du backend, trouvés en lisant le contrôleur plutôt qu'en le
supposant :
- UpdateGuidedPath supprime les étapes absentes du DTO, donc le payload
  n'emporte que celles qui ont déjà un id — les autres attendent leur
  CreateGuidedStep et seraient dupliquées.
- Les questions n'ont pas d'endpoint propre (c'est voulu : elles partent avec
  leur étape, que GuidedStep.FromDTO synchronise). Leur id entier est donc
  récupéré après coup par `order`, seul repère stable entre la liste locale
  et celle du serveur.

Le parcours est créé à la première modification, pas à l'ouverture : une
fenêtre neuve refermée intacte ne laisse rien en base. Si une écriture
échoue, la fenêtre refuse de se fermer et le pied de page porte un
« Réessayer ».

Le garde-fou de DB2 est retiré, comme prévu : un avertissement de perte de
travail n'a plus d'objet quand il n'y a plus rien à perdre. 14 clés i18n
FR/EN/NL ajoutées, 3 retirées.

flutter build web , analyse du dossier sans erreur. Reste la vérification à
l'œil (DB5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-11 17:15:36 +02:00
parent 5066b6c5e3
commit b39854afb0
16 changed files with 2182 additions and 1663 deletions

View File

@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:manager_app/Components/geometry_input_container.dart';
import 'package:manager_app/Components/multi_string_input_container.dart';
import 'package:manager_app/Components/number_stepper_field.dart';
import 'package:manager_app/Components/section_card.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/listView_card_image.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/new_update_image_slider.dart';
import 'package:provider/provider.dart';
import 'question_fields.dart';
/// Médias acceptés sur une étape de parcours. Le PDF s'ajoute aux types du
/// slider : une étape peut porter un plan, une partition, un fac-similé.
/// Le rendu visiteur correspondant vit dans `CachedCustomResource`
/// (mymuseum-visitapp) et `ResourceViewer.tsx` (visitapp-web).
const kGuidedStepResourceTypes = <ResourceType>[
...kSliderContentResourceTypes,
ResourceType.Pdf,
];
/// Les champs d'une étape, sans son contenant : ils s'affichent dans le
/// panneau de droite de l'éditeur de parcours, à côté du rail.
///
/// Le DTO est modifié en place ; `onChanged` prévient l'hôte.
class EtapeFields extends StatelessWidget {
const EtapeFields({
Key? key,
required this.step,
required this.onChanged,
required this.isEscapeMode,
this.isGeolocated = true,
}) : super(key: key);
final GuidedStepDTO step;
final VoidCallback onChanged;
final bool isEscapeMode;
final bool isGeolocated;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final appCtx = Provider.of<AppContext>(context, listen: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: MultiStringInputContainer(
label: "Titre :",
modalLabel: l10n.stepTitleLabel,
initialValue: step.title ?? [],
onGetResult: (val) {
step.title = val;
onChanged();
},
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
),
),
SizedBox(width: kSpace7),
Expanded(
child: MultiStringInputContainer(
label: "Description :",
modalLabel: l10n.stepDescriptionLabel,
initialValue: step.description ?? [],
onGetResult: (val) {
step.description = val;
onChanged();
},
maxLines: 1,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
SizedBox(height: kSpace5),
// Masqué pour un parcours en salle (ShowMap = false) : sans carte, une
// position et une zone de déclenchement n'ont aucun effet.
if (isGeolocated) ...[
SectionCard(
icon: Icons.location_on,
title: "Emplacement",
subtitle: "Position et déclenchement géolocalisé de l'étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GeometryInputContainer(
label: l10n.stepLocationLabel,
initialGeometry: step.geometry,
initialColor: null,
// GuidedStepDTO n'a pas de champ color (contrairement à
// MapAnnotationDTO.polyColor) : bouton masqué plutôt que
// supprimé, ne pas retirer comme du code mort.
showColorButton: false,
onSave: (geometry, color) {
step.geometry = geometry;
onChanged();
},
),
SizedBox(height: kSpace3),
Row(
children: [
Expanded(
child: Row(
children: [
Switch(
value: step.isGeoTriggered ?? false,
onChanged: (val) {
step.isGeoTriggered = val;
if (val != true) step.zoneRadiusMeters = null;
onChanged();
},
activeThumbColor: kPrimaryColor,
),
SizedBox(width: kSpace3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text("Déclenchement géolocalisé",
style: kTextBody),
Text(
"L'étape se débloque automatiquement quand le visiteur entre dans la zone.",
style: kTextHint,
),
],
),
),
],
),
),
if (step.isGeoTriggered == true)
Padding(
padding: const EdgeInsets.only(left: kSpace4),
child: NumberStepperField(
label: "Rayon",
value: step.zoneRadiusMeters,
min: 1,
max: 500,
unit: "m",
onChanged: (val) {
step.zoneRadiusMeters = val.toDouble();
onChanged();
},
),
),
],
),
],
),
),
SizedBox(height: kSpace5),
],
SectionCard(
icon: Icons.perm_media,
title: "Contenu riche",
subtitle:
"Audio guide, images et vidéos affichés sur la fiche de l'étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MultiStringInputContainer(
label: "Audio :",
resourceTypes: [ResourceType.Audio],
modalLabel: "Audio du guide",
initialValue: step.audioIds ?? [],
onGetResult: (val) {
step.audioIds = val.isEmpty ? null : val;
onChanged();
},
maxLines: 1,
isTitle: false,
isHTML: false,
),
SizedBox(height: kSpace4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Médias :", style: kLabelField),
IconButton(
icon: Icon(Icons.add_circle_outline, color: kSuccess),
onPressed: () async {
final result = await showNewOrUpdateContentSlider(
null,
appCtx,
context,
true,
false,
resourceTypes: kGuidedStepResourceTypes,
);
if (result == null) return;
result.order = step.contents?.length ?? 0;
step.contents = [...(step.contents ?? []), result];
onChanged();
},
),
],
),
if (step.contents?.isEmpty ?? true)
Padding(
padding: const EdgeInsets.only(bottom: kSpace3),
child: Text(
"Aucun média — cliquez + pour ajouter une image, une vidéo ou un audio",
style: kTextHint.copyWith(fontStyle: FontStyle.italic),
),
)
else
SizedBox(
height: 250,
child: ReorderableListView(
scrollDirection: Axis.horizontal,
onReorderItem: (oldIndex, newIndex) {
final item = step.contents!.removeAt(oldIndex);
step.contents!.insert(newIndex, item);
for (var i = 0; i < step.contents!.length; i++) {
step.contents![i].order = i;
}
onChanged();
},
children: List.generate(
step.contents!.length,
(i) => ListViewCardContent(
step.contents!,
i,
Key('content_$i'),
appCtx,
(updated) {
step.contents = List.from(updated);
onChanged();
},
true,
false,
),
),
),
),
],
),
),
SizedBox(height: kSpace5),
// Questions disponibles pour tous les parcours (guidés et escape game)
SectionCard(
icon: Icons.quiz,
title: l10n.questionsChallengesLabel,
subtitle: isEscapeMode
? "Énigme à résoudre pour progresser"
: "Quiz optionnel pour cette étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
children: [
Switch(
value: step.isStepTimer ?? false,
onChanged: (val) {
step.isStepTimer = val;
onChanged();
},
activeThumbColor: kPrimaryColor,
),
SizedBox(width: kSpace3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text("Timer", style: kTextBody),
Text(
"Chronomètre la réponse ; passé le délai, le message d'expiration s'affiche.",
style: kTextHint,
),
],
),
),
],
),
),
if (step.isStepTimer == true)
Padding(
padding: const EdgeInsets.only(left: kSpace4),
child: NumberStepperField(
label: l10n.durationSecondsLabel,
value: step.timerSeconds,
min: 0,
max: 3600,
unit: "sec",
onChanged: (val) {
step.timerSeconds = val.toInt();
onChanged();
},
),
),
],
),
if (step.isStepTimer == true) ...[
SizedBox(height: kSpace3),
MultiStringInputContainer(
label: "Message d'expiration :",
modalLabel: "Message d'expiration du timer",
initialValue: step.timerExpiredMessage ?? [],
onGetResult: (val) {
step.timerExpiredMessage = val.isEmpty ? null : val;
onChanged();
},
maxLines: 2,
isTitle: false,
isHTML: true,
showPreview: true,
),
SizedBox(height: kSpace3),
],
QuestionsBlock(
questions: step.quizQuestions ??= [],
stepId: step.id ?? "temp",
onChanged: onChanged,
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/Components/check_input_container.dart';
import 'package:manager_app/Components/multi_string_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/section_card.dart';
import '../progression_mode.dart';
/// Les champs propres au parcours, sans son contenant ni sa liste d'étapes.
///
/// Le DTO est modifié en place ; `onChanged` prévient l'hôte, à charge pour lui
/// de se reconstruire et de déclencher l'enregistrement.
class ParcoursFields extends StatelessWidget {
const ParcoursFields({
Key? key,
required this.path,
required this.onChanged,
}) : super(key: key);
final GuidedPathDTO path;
final VoidCallback onChanged;
/// Réécrit une liste de traductions en conservant la ressource déjà associée
/// à chaque langue : `MultiStringInputContainer` ne manipule que le texte.
List<TranslationAndResourceDTO> _mergeResources(
List<TranslationDTO> values,
List<TranslationAndResourceDTO>? previous,
) {
return values.map((t) {
final prev = (previous ?? []).firstWhere(
(e) => e.language == t.language,
orElse: () => TranslationAndResourceDTO(),
);
return TranslationAndResourceDTO(
language: t.language,
value: t.value,
resourceId: prev.resourceId,
resource: prev.resource,
);
}).toList();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ResourceInputContainer(
label: "Image :",
color: kPrimaryColor,
initialValue: path.imageResourceId,
onChanged: (resourceDTO) {
path.imageResourceId = resourceDTO.id;
onChanged();
},
),
SizedBox(height: kSpace5),
Row(
children: [
Expanded(
child: MultiStringInputContainer(
label: "Titre :",
modalLabel: "Titre du parcours",
initialValue: path.title ?? [],
onGetResult: (val) {
path.title = val;
onChanged();
},
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
),
),
SizedBox(width: kSpace7),
Expanded(
child: MultiStringInputContainer(
label: "Description :",
modalLabel: "Description du parcours",
initialValue: path.description ?? [],
onGetResult: (val) {
path.description = val;
onChanged();
},
maxLines: 1,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
SizedBox(height: kSpace5),
SectionCard(
icon: Icons.sports_esports,
title: "Ambiance",
subtitle: "Visite classique ou jeu",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Quelle ambiance ?", style: kLabelField),
RadioGroup<bool>(
groupValue: path.isGameMode ?? false,
onChanged: (val) {
path.isGameMode = val;
onChanged();
},
child: Column(
children: [
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: false,
title: Text("Visite", style: kTextBody),
subtitle: Text(
"Parcours de découverte : le visiteur avance à son rythme.",
style: kTextHint,
),
),
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: true,
title: Text("Jeu", style: kTextBody),
subtitle: Text(
"Escape game ou chasse au trésor : messages de début et de fin, vocabulaire de jeu sur les étapes.",
style: kTextHint,
),
),
],
),
),
if (path.isGameMode == true) ...[
SizedBox(height: kSpace3),
Row(
children: [
Expanded(
child: MultiStringInputContainer(
label: "Message de début :",
modalLabel: "Message d'introduction du jeu",
color: kPrimaryColor,
initialValue: (path.gameMessageDebut ?? [])
.map((t) => TranslationDTO(
language: t.language, value: t.value))
.toList(),
onGetResult: (val) {
path.gameMessageDebut =
_mergeResources(val, path.gameMessageDebut);
onChanged();
},
maxLines: 2,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
SizedBox(width: kSpace7),
Expanded(
child: MultiStringInputContainer(
label: "Message de fin :",
modalLabel: "Message de félicitations du jeu",
color: kPrimaryColor,
initialValue: (path.gameMessageFin ?? [])
.map((t) => TranslationDTO(
language: t.language, value: t.value))
.toList(),
onGetResult: (val) {
path.gameMessageFin =
_mergeResources(val, path.gameMessageFin);
onChanged();
},
maxLines: 2,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
],
],
),
),
SizedBox(height: kSpace5),
SectionCard(
icon: Icons.settings,
title: "Options du parcours",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Comment le visiteur progresse-t-il ?", style: kLabelField),
RadioGroup<ProgressionMode>(
groupValue: progressionModeOf(path),
onChanged: (val) {
applyProgressionMode(path, val!);
onChanged();
},
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: kTextBody),
subtitle: Text(mode.description, style: kTextHint),
),
],
),
),
if (progressionModeOf(path) == ProgressionMode.stepByStep)
Padding(
padding: const EdgeInsets.only(left: kSpace5),
child: CheckInputContainer(
label: "Masquer les étapes pas encore atteintes",
subtitle:
"Le visiteur ne découvre les étapes suivantes qu'au fur et à mesure.",
isChecked: path.hideNextStepsUntilComplete ?? false,
onChanged: (val) {
path.hideNextStepsUntilComplete = val;
onChanged();
},
),
),
SizedBox(height: kSpace3),
NumberStepperField(
label: "Durée estimée",
value: path.estimatedDurationMinutes,
min: 0,
max: 600,
unit: "min",
onChanged: (val) {
path.estimatedDurationMinutes = val.toInt();
onChanged();
},
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,473 @@
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/check_input_container.dart';
import 'package:manager_app/Components/confirmation_dialog.dart';
import 'package:manager_app/Components/multi_string_input_container.dart';
import 'package:manager_app/Components/number_input_container.dart';
import 'package:manager_app/Components/resource_input_container.dart';
// `ResponseDTO.label` est en TranslationAndResourceDTO alors que
// MultiStringInputContainer parle TranslationDTO : la ressource n'a pas de sens
// sur une réponse de quiz, seul le texte circule.
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();
ResponseDTO _emptyResponse({bool isGood = false, int order = 0}) =>
ResponseDTO(label: [], isGood: isGood, order: order);
/// Une question neuve, encore sans identifiant serveur (`id: 0`) : c'est le
/// backend qui l'attribue quand l'étape qui la porte est enregistrée.
QuizQuestion newQuizQuestion({required String stepId, required int order}) =>
QuizQuestion(
id: 0,
label: [TranslationAndResourceDTO(language: 'FR', value: '')],
responses: [],
validationQuestionType: QuestionType.simple,
order: order,
guidedStepId: stepId,
);
String questionTypeLabel(QuestionType? type) {
switch (type) {
case QuestionType.multipleChoice:
return "QCM";
case QuestionType.puzzle:
return "Puzzle";
default:
return "Réponse attendue";
}
}
String questionPreview(BuildContext context, QuizQuestion question, int index) {
if (question.label.isEmpty) return "Question ${index + 1}";
final fr = question.label.firstWhere(
(t) => t.language == 'FR',
orElse: () => question.label.first,
);
final value = fr.value ?? "";
return value.trim().isEmpty ? "Question ${index + 1}" : value;
}
/// Les champs d'une question, sans son contenant : ils s'affichent dépliés dans
/// le panneau de l'étape, plus dans une fenêtre à part.
class QuestionFields extends StatelessWidget {
const QuestionFields({
Key? key,
required this.question,
required this.onChanged,
}) : super(key: key);
final QuizQuestion question;
final VoidCallback onChanged;
/// Le type « réponse attendue » se joue sur une réponse unique, toujours
/// marquée bonne. Elle est créée à la volée si elle manque.
void _ensureSimpleResponse() {
if (question.responses.isEmpty) {
question.responses.add(_emptyResponse(isGood: true, order: 0));
}
question.responses[0].isGood = true;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MultiStringInputContainer(
label: l10n.questionAskedLabel,
modalLabel: l10n.questionTitleLabel,
initialValue: _toTranslationList(question.label),
onGetResult: (val) {
question.label = _fromTranslationList(val);
onChanged();
},
maxLines: 3,
isTitle: false,
isHTML: true,
showPreview: true,
),
SizedBox(height: kSpace4),
Text("Type de validation :", style: kLabelField),
SizedBox(height: kSpace2),
DropdownButton<QuestionType>(
value: question.validationQuestionType,
items: [
DropdownMenuItem(
value: QuestionType.simple,
child: Text("Simple (texte attendu)")),
DropdownMenuItem(
value: QuestionType.multipleChoice,
child: Text("Choix multiples (QCM)")),
DropdownMenuItem(value: QuestionType.puzzle, child: Text("Puzzle")),
],
onChanged: (val) {
question.validationQuestionType = val;
question.responses = [];
onChanged();
},
),
if (question.validationQuestionType == QuestionType.simple)
..._simpleFields(context, l10n),
if (question.validationQuestionType == QuestionType.multipleChoice)
..._multipleChoiceFields(context, l10n),
if (question.validationQuestionType == QuestionType.puzzle)
..._puzzleFields(context),
],
);
}
List<Widget> _simpleFields(BuildContext context, AppLocalizations l10n) => [
Divider(height: kSpace7, color: kLineSoft),
Text(l10n.expectedAnswerLabel, style: kLabelField),
SizedBox(height: kSpace3),
Builder(builder: (_) {
_ensureSimpleResponse();
return MultiStringInputContainer(
label: "",
modalLabel: l10n.expectedAnswerModalLabel,
initialValue: _toTranslationList(question.responses[0].label),
onGetResult: (val) {
question.responses[0].label = _fromTranslationList(val);
question.responses[0].isGood = true;
onChanged();
},
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
);
}),
SizedBox(height: kSpace1),
Text(l10n.validationNote,
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
SizedBox(height: kSpace3),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadiusCard),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.dialpad, size: 18, color: kPrimaryColor),
SizedBox(width: kSpace3),
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: kTextHint,
),
),
],
),
),
];
List<Widget> _multipleChoiceFields(
BuildContext context, AppLocalizations l10n) =>
[
Divider(height: kSpace7, color: kLineSoft),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.possibleAnswersLabel, style: kLabelField),
TextButton.icon(
icon: Icon(Icons.add_circle_outline, color: kSuccess),
label: Text("Ajouter", style: TextStyle(color: kSuccess)),
onPressed: () {
question.responses
.add(_emptyResponse(order: question.responses.length));
onChanged();
},
),
],
),
if (question.responses.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace3),
child: Text(l10n.noAnswerDefined,
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
)
else
Column(
children: List.generate(question.responses.length, (i) {
final resp = question.responses[i];
return Card(
margin: const EdgeInsets.only(bottom: kSpace3),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace3, vertical: kSpace1),
child: Row(
children: [
Tooltip(
message: resp.isGood == true
? l10n.correctAnswer
: l10n.wrongAnswer,
child: Checkbox(
value: resp.isGood ?? false,
activeColor: kSuccess,
onChanged: (val) {
resp.isGood = val;
onChanged();
},
),
),
Expanded(
child: MultiStringInputContainer(
label: "${l10n.answerLabel} ${i + 1} :",
modalLabel: "${l10n.answerLabel} ${i + 1}",
initialValue: _toTranslationList(resp.label),
onGetResult: (val) {
resp.label = _fromTranslationList(val);
onChanged();
},
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
),
),
IconButton(
icon: Icon(Icons.delete_outline, color: kError, size: 20),
onPressed: () {
question.responses.removeAt(i);
for (var j = 0; j < question.responses.length; j++) {
question.responses[j].order = j;
}
onChanged();
},
),
],
),
),
);
}),
),
SizedBox(height: kSpace1),
Text(l10n.answerNote,
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
];
List<Widget> _puzzleFields(BuildContext context) => [
Divider(height: kSpace7, color: kLineSoft),
Text("Configuration du Puzzle", style: kLabelField),
SizedBox(height: kSpace4),
ResourceInputContainer(
label: "Image du puzzle :",
initialValue: question.puzzleImageId,
onChanged: (res) {
question.puzzleImageId = res.id;
question.puzzleImage = Resource.fromJson(res.toJson());
onChanged();
},
),
SizedBox(height: kSpace4),
Row(
children: [
Expanded(
child: NumberInputContainer(
label: "Lignes :",
initialValue: question.puzzleRows ?? 3,
onChanged: (val) {
question.puzzleRows = int.tryParse(val) ?? 3;
onChanged();
},
isSmall: true,
),
),
SizedBox(width: kSpace7),
Expanded(
child: NumberInputContainer(
label: "Colonnes :",
initialValue: question.puzzleCols ?? 3,
onChanged: (val) {
question.puzzleCols = int.tryParse(val) ?? 3;
onChanged();
},
isSmall: true,
),
),
],
),
SizedBox(height: kSpace3),
CheckInputContainer(
label: "Puzzle glissant (Sliding) :",
isChecked: question.isSlidingPuzzle ?? false,
onChanged: (val) {
question.isSlidingPuzzle = val;
onChanged();
},
),
];
}
/// La liste des questions d'une étape : chaque ligne se déplie sur place au
/// lieu d'ouvrir une fenêtre — c'est ce qui ramène la profondeur à 2.
class QuestionsBlock extends StatefulWidget {
const QuestionsBlock({
Key? key,
required this.questions,
required this.stepId,
required this.onChanged,
}) : super(key: key);
final List<QuizQuestion> questions;
final String stepId;
final VoidCallback onChanged;
@override
State<QuestionsBlock> createState() => _QuestionsBlockState();
}
class _QuestionsBlockState extends State<QuestionsBlock> {
QuizQuestion? _expanded;
void _add() {
final question = newQuizQuestion(
stepId: widget.stepId,
order: widget.questions.length,
);
setState(() {
widget.questions.add(question);
_expanded = question;
});
widget.onChanged();
}
void _delete(QuizQuestion question) {
showConfirmationDialog(
"Supprimer cette question ?",
() {},
() {
setState(() {
widget.questions.remove(question);
for (var i = 0; i < widget.questions.length; i++) {
widget.questions[i].order = i;
}
if (_expanded == question) _expanded = null;
});
widget.onChanged();
},
context,
);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.questions.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: kSpace3),
child: Text(AppLocalizations.of(context)!.noQuestionsConfigured,
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
)
else
for (var i = 0; i < widget.questions.length; i++)
_row(context, i, widget.questions[i]),
SizedBox(height: kSpace3),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: Icon(Icons.add, size: 18, color: kPrimaryColor),
label: Text(AppLocalizations.of(context)!.addQuestion,
style: TextStyle(color: kPrimaryColor)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: kLine),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusCard)),
),
onPressed: _add,
),
),
],
);
}
Widget _row(BuildContext context, int index, QuizQuestion question) {
final isOpen = _expanded == question;
return Container(
margin: const EdgeInsets.only(bottom: kSpace2),
decoration: BoxDecoration(
color: isOpen ? kSurface2 : kSurface,
border: Border.all(color: isOpen ? kPrimaryColor : kLineSoft),
borderRadius: BorderRadius.circular(kRadiusCard),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () =>
setState(() => _expanded = isOpen ? null : question),
child: Padding(
padding: const EdgeInsets.fromLTRB(11, 9, 5, 9),
child: Row(
children: [
SizedBox(
width: 24,
child: Text(
(index + 1).toString().padLeft(2, '0'),
style: kOverlineMono,
),
),
Expanded(
child: HtmlWidget(
questionPreview(context, question, index),
textStyle: kTextBody,
),
),
SizedBox(width: kSpace3),
Text(questionTypeLabel(question.validationQuestionType),
style: kTextHint),
IconButton(
icon: Icon(Icons.delete_outline, size: 18, color: kError),
tooltip: "Supprimer",
onPressed: () => _delete(question),
),
Icon(isOpen ? Icons.expand_less : Icons.expand_more,
size: 20, color: kInk3),
],
),
),
),
if (isOpen)
Padding(
padding: const EdgeInsets.fromLTRB(11, 0, 11, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(height: kSpace4, color: kLine),
QuestionFields(
key: ObjectKey(question),
question: question,
onChanged: () {
setState(() {});
widget.onChanged();
},
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,43 @@
import 'package:manager_api_new/api.dart';
import 'package:manager_app/client.dart';
/// Façade unique sur les deux jeux d'endpoints de parcours.
///
/// Un parcours vit sous une `SectionParcours` ou sous une `SectionMap` (les
/// sections d'événement passent aussi par la seconde). Les opérations sont
/// identiques des deux côtés, seule la classe générée change : l'éditeur n'a
/// pas à connaître ce détail.
///
/// Il n'y a volontairement pas d'endpoint de question : les questions partent
/// avec leur étape, `GuidedStep.FromDTO` les synchronise côté serveur.
class GuidedPathApi {
GuidedPathApi(this._client, {required this.isParcours});
final Client _client;
final bool isParcours;
SectionParcoursApi get _parcours => _client.sectionParcoursApi!;
SectionMapApi get _map => _client.sectionMapApi!;
Future<GuidedPathDTO?> createPath(String sectionId, GuidedPathDTO path) =>
isParcours
? _parcours.sectionParcoursCreateGuidedPath(sectionId, path)
: _map.sectionMapCreateGuidedPath(sectionId, path);
Future<GuidedPathDTO?> updatePath(GuidedPathDTO path) => isParcours
? _parcours.sectionParcoursUpdateGuidedPath(path)
: _map.sectionMapUpdateGuidedPath(path);
Future<GuidedStepDTO?> createStep(String pathId, GuidedStepDTO step) =>
isParcours
? _parcours.sectionParcoursCreateGuidedStep(pathId, step)
: _map.sectionMapCreateGuidedStep(pathId, step);
Future<GuidedStepDTO?> updateStep(GuidedStepDTO step) => isParcours
? _parcours.sectionParcoursUpdateGuidedStep(step)
: _map.sectionMapUpdateGuidedStep(step);
Future<void> deleteStep(String stepId) => isParcours
? _parcours.sectionParcoursDeleteGuidedStep(stepId)
: _map.sectionMapDeleteGuidedStep(stepId);
}

View File

@ -0,0 +1,785 @@
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/message_notification.dart';
import 'package:manager_app/Components/rounded_button.dart';
import 'Fields/etape_fields.dart';
import 'Fields/parcours_fields.dart';
import 'guided_path_api.dart';
/// Éditeur de parcours une seule fenêtre, un rail d'étapes toujours visible.
///
/// Remplace l'empilement « popup parcours → popup étape → popup question » :
/// le rail à gauche liste le parcours et ses étapes, le panneau de droite
/// affiche celui qu'on a choisi, et une question se déplie dans le panneau.
/// La profondeur maximale retombe à 2 cette fenêtre, puis la traduction.
///
/// Rien n'attend de bouton « Sauvegarder » : chaque modification part au
/// serveur (parcours, étape, questions avec leur étape). C'est ce qui rend le
/// garde-fou d'abandon inutile — il n'y a plus de travail à perdre.
Future<GuidedPathDTO?> showGuidedPathEditor(
BuildContext context, {
required GuidedPathDTO? path,
required String sectionId,
required String? instanceId,
required bool isEvent,
required bool isParcours,
required bool isGeolocated,
required GuidedPathApi api,
required int newPathOrder,
}) {
return showDialog<GuidedPathDTO>(
context: context,
barrierDismissible: false,
builder: (_) => GuidedPathEditor(
initialPath: path,
sectionId: sectionId,
instanceId: instanceId,
isEvent: isEvent,
isParcours: isParcours,
isGeolocated: isGeolocated,
api: api,
newPathOrder: newPathOrder,
),
);
}
enum _SaveStatus { idle, pending, saving, saved, failed }
class GuidedPathEditor extends StatefulWidget {
const GuidedPathEditor({
Key? key,
required this.initialPath,
required this.sectionId,
required this.instanceId,
required this.isEvent,
required this.isParcours,
required this.isGeolocated,
required this.api,
required this.newPathOrder,
}) : super(key: key);
final GuidedPathDTO? initialPath;
final String sectionId;
final String? instanceId;
final bool isEvent;
final bool isParcours;
final bool isGeolocated;
final GuidedPathApi api;
final int newPathOrder;
@override
State<GuidedPathEditor> createState() => _GuidedPathEditorState();
}
class _GuidedPathEditorState extends State<GuidedPathEditor> {
static const Duration _debounceDelay = Duration(milliseconds: 700);
late GuidedPathDTO _path;
/// `null` = le panneau du parcours lui-même.
GuidedStepDTO? _selected;
Timer? _debounce;
Future<void>? _inFlight;
bool _flushing = false;
_SaveStatus _status = _SaveStatus.idle;
bool _pathDirty = false;
final Set<GuidedStepDTO> _dirtySteps = Set.identity();
final List<String> _pendingDeletes = [];
@override
void initState() {
super.initState();
// Copie de travail : l'appelant relit la liste depuis l'API à la fermeture,
// ce qui évite d'afficher un état local qu'un enregistrement aurait raté.
_path = widget.initialPath != null
? GuidedPathDTO.fromJson(jsonDecode(jsonEncode(widget.initialPath)))!
: GuidedPathDTO(
title: [],
description: [],
steps: [],
order: widget.newPathOrder,
// 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,
);
_path.steps ??= [];
_path.steps!.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
List<GuidedStepDTO> get _steps => _path.steps!;
// ------------------------------------------------------------------
// Enregistrement au fil de l'eau
// ------------------------------------------------------------------
void _schedule({bool immediate = false}) {
_debounce?.cancel();
setState(() => _status = _SaveStatus.pending);
if (immediate) {
_flush();
} else {
_debounce = Timer(_debounceDelay, _flush);
}
}
/// Un seul enregistrement à la fois : tant qu'un est parti, on rend sa propre
/// promesse plutôt qu'une promesse déjà tenue — sinon la fermeture passerait
/// devant lui sans savoir s'il a abouti.
Future<void> _flush() {
if (_flushing) return _inFlight ?? Future.value();
_inFlight = _writePending();
return _inFlight!;
}
/// Purge complète : le premier passage laisse finir ce qui était parti, le
/// second écrit ce qui est arrivé pendant ce temps.
Future<void> _flushNow() async {
_debounce?.cancel();
await _flush();
await _flush();
}
void _markPathDirty({bool immediate = false}) {
_pathDirty = true;
setState(() {});
_schedule(immediate: immediate);
}
void _markStepDirty(GuidedStepDTO step, {bool immediate = false}) {
_dirtySteps.add(step);
setState(() {});
_schedule(immediate: immediate);
}
bool get _hasWork =>
_pathDirty || _dirtySteps.isNotEmpty || _pendingDeletes.isNotEmpty;
Future<void> _writePending() async {
if (!_hasWork) return;
_flushing = true;
if (mounted) setState(() => _status = _SaveStatus.saving);
try {
while (_hasWork) {
await _ensurePathExists();
while (_pendingDeletes.isNotEmpty) {
final id = _pendingDeletes.first;
await widget.api.deleteStep(id);
_pendingDeletes.remove(id);
}
while (_dirtySteps.isNotEmpty) {
final step = _dirtySteps.first;
_dirtySteps.remove(step);
try {
await _persistStep(step);
} catch (_) {
_dirtySteps.add(step);
rethrow;
}
}
if (_pathDirty) {
_pathDirty = false;
try {
await widget.api.updatePath(_pathPayload());
} catch (_) {
_pathDirty = true;
rethrow;
}
}
}
if (mounted) setState(() => _status = _SaveStatus.saved);
} catch (_) {
if (mounted) setState(() => _status = _SaveStatus.failed);
} finally {
_flushing = false;
}
}
Future<void> _ensurePathExists() async {
if (_path.id != null) return;
_path.instanceId = widget.instanceId;
if (widget.isParcours) {
_path.sectionParcoursId = widget.sectionId;
} else if (widget.isEvent) {
_path.sectionEventId = widget.sectionId;
} else {
_path.sectionMapId = widget.sectionId;
}
_path.isLinear ??= false;
_path.requireSuccessToAdvance ??= false;
_path.hideNextStepsUntilComplete ??= false;
final created = await widget.api.createPath(widget.sectionId, _pathPayload());
if (created?.id == null) {
throw StateError("Le serveur n'a pas renvoyé d'identifiant de parcours");
}
_path.id = created!.id;
_path.order = created.order ?? _path.order;
_pathDirty = false;
}
/// Le payload n'emporte que les étapes déjà connues du serveur : `UpdateGuidedPath`
/// supprime celles qui manquent, et créerait un doublon de celles qui n'ont pas
/// encore reçu leur identifiant par `CreateGuidedStep`.
GuidedPathDTO _pathPayload() {
final payload = GuidedPathDTO.fromJson(jsonDecode(jsonEncode(_path)))!;
payload.steps =
(payload.steps ?? []).where((s) => s.id != null).toList();
for (final s in payload.steps!) {
s.isStepTimer ??= false;
s.isGeoTriggered ??= false;
}
return payload;
}
Future<void> _persistStep(GuidedStepDTO step) async {
step.guidedPathId = _path.id;
step.isStepTimer ??= false;
step.isGeoTriggered ??= false;
if (step.isGeoTriggered != true) step.zoneRadiusMeters = null;
final saved = step.id == null
? await widget.api.createStep(_path.id!, step)
: await widget.api.updateStep(step);
if (saved == null) return;
step.id ??= saved.id;
step.order = saved.order ?? step.order;
_adoptQuestionIds(step, saved);
}
/// Les questions n'ont pas d'endpoint propre : elles partent avec leur étape
/// et le serveur leur attribue un entier. On le récupère par `order`, seul
/// repère stable entre les deux listes.
void _adoptQuestionIds(GuidedStepDTO local, GuidedStepDTO saved) {
final savedQuestions = saved.quizQuestions ?? [];
for (final question in local.quizQuestions ?? <QuizQuestion>[]) {
if (question.id != 0) continue;
final match = savedQuestions.where((q) => q.order == question.order);
if (match.isNotEmpty) {
question.id = match.first.id;
question.guidedStepId = local.id;
}
}
}
// ------------------------------------------------------------------
// Étapes
// ------------------------------------------------------------------
void _addStep() {
final step = GuidedStepDTO(
title: [],
description: [],
quizQuestions: [],
audioIds: [],
contents: [],
order: _steps.length,
isStepTimer: false,
isGeoTriggered: false,
);
setState(() {
_steps.add(step);
_selected = step;
});
_markStepDirty(step, immediate: true);
}
void _deleteStep(GuidedStepDTO step) {
showConfirmationDialog(
AppLocalizations.of(context)!.deleteStepConfirm,
() {},
() {
final index = _steps.indexOf(step);
setState(() {
_steps.remove(step);
_dirtySteps.remove(step);
if (_selected == step) _selected = null;
});
if (step.id != null) _pendingDeletes.add(step.id!);
_reindexSteps(from: index);
_schedule(immediate: true);
},
context,
);
}
/// `onReorderItem` livre déjà un index cible corrigé du retrait.
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
final step = _steps.removeAt(oldIndex);
_steps.insert(newIndex, step);
});
_reindexSteps(from: 0);
_schedule(immediate: true);
}
/// Renumérote les étapes et marque celles dont l'ordre a bougé.
void _reindexSteps({required int from}) {
for (var i = from; i < _steps.length; i++) {
if (_steps[i].order != i) {
_steps[i].order = i;
_dirtySteps.add(_steps[i]);
}
}
}
// ------------------------------------------------------------------
// Fermeture
// ------------------------------------------------------------------
Future<void> _close() async {
await _flushNow();
if (!mounted) return;
if (_status == _SaveStatus.failed) {
// Fermer maintenant jetterait ce qui n'est pas encore parti : la fenêtre
// reste ouverte, le pied de page porte le « Réessayer ».
showNotification(kError, kWhite,
AppLocalizations.of(context)!.saveStatusFailed, context, null);
return;
}
Navigator.pop(context, _path.id == null ? null : _path);
}
// ------------------------------------------------------------------
// Rendu
// ------------------------------------------------------------------
String _pathTitle(BuildContext context) {
final title = _path.title ?? [];
if (title.isEmpty) return AppLocalizations.of(context)!.pathNoTitle;
final fr =
title.firstWhere((t) => t.language == 'FR', orElse: () => title.first);
final value = (fr.value ?? "").trim();
return value.isEmpty ? AppLocalizations.of(context)!.pathNoTitle : value;
}
String _stepTitle(BuildContext context, GuidedStepDTO step, int index) {
final title = step.title ?? [];
if (title.isNotEmpty) {
final fr =
title.firstWhere((t) => t.language == 'FR', orElse: () => title.first);
final value = (fr.value ?? "").trim();
if (value.isNotEmpty) return value;
}
return "${AppLocalizations.of(context)!.stepFallback} ${index + 1}";
}
String _stepSummary(BuildContext context, GuidedStepDTO step) {
final l10n = AppLocalizations.of(context)!;
final medias = step.contents?.length ?? 0;
final questions = step.quizQuestions?.length ?? 0;
final parts = [
if (medias > 0) l10n.mediaCount(medias),
if (questions > 0) l10n.questionCount(questions),
];
return parts.isEmpty ? l10n.stepSummaryEmpty : parts.join(" · ");
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final double dialogWidth =
size.width * 0.92 > 1180 ? 1180 : size.width * 0.92;
return PopScope(
// `barrierDismissible: false` neutralise le clic hors dialog et Échap,
// mais pas le retour arrière du navigateur : il doit passer par la même
// fermeture, qui purge d'abord ce qui reste à écrire.
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _close();
},
child: Dialog(
insetPadding: const EdgeInsets.all(kSpace7),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusShell)),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: dialogWidth,
height: size.height * 0.9,
child: Column(
children: [
_breadcrumbBar(context),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_rail(context),
Expanded(child: _panel(context)),
],
),
),
_footer(context),
],
),
),
),
);
}
Widget _breadcrumbBar(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final index = _selected == null ? -1 : _steps.indexOf(_selected!);
final tail = _selected == null
? l10n.pathPanelTitle
: "${l10n.stepFallback} ${index + 1} · ${_stepTitle(context, _selected!, index)}";
return Container(
padding: const EdgeInsets.symmetric(
horizontal: kSpace6, vertical: kSpace4),
decoration: BoxDecoration(
color: kSurface2,
border: Border(bottom: BorderSide(color: kLineSoft)),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
Flexible(
child: HtmlWidget(_pathTitle(context), textStyle: kTitleCard),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: kSpace2),
child: Icon(Icons.chevron_right, size: 16, color: kInk3),
),
Flexible(child: HtmlWidget(tail, textStyle: kTextSmall)),
],
),
),
IconButton(
icon: Icon(Icons.close, color: kInk3),
tooltip: l10n.close,
onPressed: _close,
),
],
),
);
}
Widget _rail(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
width: 280,
decoration: BoxDecoration(
color: kSurface2,
border: Border(right: BorderSide(color: kLineSoft)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace6, kSpace5, kSpace4, kSpace4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.guidedPathsLabel.toUpperCase(), style: kOverlineMono),
SizedBox(height: kSpace1),
HtmlWidget(_pathTitle(context), textStyle: kTitleCard),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
children: [
_railTile(
context,
selected: _selected == null,
pin: Icon(Icons.tune,
size: 14,
color: _selected == null ? kOnBrand : kInk3),
title: l10n.pathPanelTitle,
subtitle: l10n.pathPanelSubtitle,
onTap: () => setState(() => _selected = null),
),
SizedBox(height: kSpace2),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _steps.length,
onReorderItem: _reorderStep,
itemBuilder: (context, index) {
final step = _steps[index];
final selected = identical(_selected, step);
return Padding(
key: ObjectKey(step),
padding: const EdgeInsets.only(bottom: kSpace2),
child: _railTile(
context,
selected: selected,
pin: Text(
"${index + 1}",
style: kOverlineMono.copyWith(
color: selected ? kOnBrand : kInk3),
),
title: _stepTitle(context, step, index),
titleIsHtml: true,
subtitle: _stepSummary(context, step),
onTap: () => setState(() => _selected = step),
trailing: ReorderableDragStartListener(
index: index,
child: Icon(Icons.drag_indicator,
size: 16, color: kInk3),
),
),
);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(36, kSpace2, 0, kSpace5),
child: OutlinedButton.icon(
icon: Icon(Icons.add, size: 16, color: kInk3),
label: Text(l10n.addStep,
style: kTextSmall.copyWith(color: kInk2)),
style: OutlinedButton.styleFrom(
alignment: Alignment.centerLeft,
side: BorderSide(color: kLine),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusCard)),
),
onPressed: _addStep,
),
),
],
),
),
],
),
);
}
Widget _railTile(
BuildContext context, {
required bool selected,
required Widget pin,
required String title,
required String subtitle,
required VoidCallback onTap,
bool titleIsHtml = false,
Widget? trailing,
}) {
return Material(
color: selected ? kSurface : Colors.transparent,
borderRadius: BorderRadius.circular(kRadiusCard),
child: InkWell(
borderRadius: BorderRadius.circular(kRadiusCard),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: kSpace3, vertical: kSpace3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(kRadiusCard),
border: Border.all(color: selected ? kLine : Colors.transparent),
),
child: Row(
children: [
Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? kPrimaryColor : kSurface,
border: Border.all(color: selected ? kPrimaryColor : kLine),
),
child: pin,
),
SizedBox(width: kSpace4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
titleIsHtml
? HtmlWidget(title,
textStyle: kTextSmall.copyWith(
color: selected ? kInk : kInk2,
fontWeight: FontWeight.w600))
: Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: kTextSmall.copyWith(
color: selected ? kInk : kInk2,
fontWeight: FontWeight.w600)),
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: kTextHint),
],
),
),
if (trailing != null) trailing,
],
),
),
),
);
}
Widget _panel(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final step = _selected;
final index = step == null ? -1 : _steps.indexOf(step);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(kSpace7, kSpace6, kSpace7, kSpace8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step == null
? l10n.pathPanelTitle
: "${l10n.stepFallback} ${index + 1}",
style: kTitleScreen,
),
SizedBox(height: kSpace1),
Text(
step == null
? "Ce que le visiteur voit avant de commencer."
: _stepSummary(context, step),
style: kSubtitleCard,
),
],
),
),
if (step != null)
TextButton.icon(
icon: Icon(Icons.delete_outline, size: 18, color: kError),
label: Text(l10n.deleteStep, style: TextStyle(color: kError)),
onPressed: () => _deleteStep(step),
),
],
),
SizedBox(height: kSpace6),
if (step == null)
ParcoursFields(
path: _path,
onChanged: _markPathDirty,
)
else
EtapeFields(
key: ObjectKey(step),
step: step,
isEscapeMode: _path.isGameMode ?? false,
isGeolocated: widget.isGeolocated,
onChanged: () => _markStepDirty(step),
),
],
),
);
}
Widget _footer(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
late final Widget indicator;
switch (_status) {
case _SaveStatus.idle:
indicator = Text(l10n.saveStatusIdle, style: kTextHint);
break;
case _SaveStatus.pending:
case _SaveStatus.saving:
indicator = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: kSpace3),
Text(l10n.saveStatusSaving, style: kTextSmall),
],
);
break;
case _SaveStatus.saved:
indicator = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, size: 14, color: kSuccess),
SizedBox(width: kSpace3),
Text(l10n.saveStatusSaved,
style: kTextSmall.copyWith(
color: kSuccess, fontWeight: FontWeight.w600)),
],
);
break;
case _SaveStatus.failed:
indicator = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 14, color: kError),
SizedBox(width: kSpace3),
Text(l10n.saveStatusFailed,
style: kTextSmall.copyWith(
color: kError, fontWeight: FontWeight.w600)),
SizedBox(width: kSpace3),
TextButton(
onPressed: () => _schedule(immediate: true),
child: Text(l10n.retry),
),
],
);
break;
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: kSpace6, vertical: kSpace4),
decoration: BoxDecoration(
color: kSurface2,
border: Border(top: BorderSide(color: kLineSoft)),
),
child: Row(
children: [
indicator,
Spacer(),
SizedBox(
height: 40,
child: RoundedButton(
text: l10n.close,
press: _close,
color: kPrimaryColor,
fontSize: 14,
horizontal: kSpace7,
),
),
],
),
);
}
}

View File

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; 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/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart'; import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/guided_path_api.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/guided_path_editor.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:manager_app/app_context.dart'; import 'package:manager_app/app_context.dart';
import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/l10n/app_localizations.dart';
@ -65,6 +66,31 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
} }
} }
/// Ouvre l'éditeur à fenêtre unique. Il enregistre au fil de la saisie : il
/// n'y a donc rien à récupérer d'un callback, seulement la liste à relire à
/// la fermeture pour repartir de l'état réel du serveur.
Future<void> _openEditor(GuidedPathDTO? path) async {
final appContext = Provider.of<AppContext>(context, listen: false);
final managerContext = appContext.getContext() as ManagerAppContext;
await showGuidedPathEditor(
context,
path: path,
sectionId: widget.parentId,
instanceId: managerContext.instanceId,
isEvent: widget.isEvent,
isParcours: widget.isParcours,
isGeolocated: widget.isGeolocated,
api: GuidedPathApi(managerContext.clientAPI!,
isParcours: widget.isParcours),
newPathOrder: paths.length,
);
if (!mounted) return;
await _loadFromApi();
if (mounted) widget.onChanged(paths);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@ -79,58 +105,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
ElevatedButton.icon( ElevatedButton.icon(
icon: Icon(Icons.add), icon: Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.addPath), label: Text(AppLocalizations.of(context)!.addPath),
onPressed: () { onPressed: () => _openEditor(null),
final appContext =
Provider.of<AppContext>(context, listen: false);
showNewOrUpdateGuidedPath(
context,
null,
widget.parentId,
widget.isEvent,
isGeolocated: widget.isGeolocated,
(newPath) async {
try {
newPath.order = paths.length;
newPath.instanceId =
(appContext.getContext() as ManagerAppContext)
.instanceId;
if (widget.isParcours) {
newPath.sectionParcoursId = widget.parentId;
} else if (widget.isEvent) {
newPath.sectionEventId = widget.parentId;
} else {
newPath.sectionMapId = widget.parentId;
}
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;
final createdPath = widget.isParcours
? await clientAPI.sectionParcoursApi!
.sectionParcoursCreateGuidedPath(widget.parentId, newPath)
: await clientAPI.sectionMapApi!
.sectionMapCreateGuidedPath(widget.parentId, newPath);
if (createdPath != null) {
if (mounted) {
setState(() {
paths.add(createdPath);
widget.onChanged(paths);
});
}
showNotification(kSuccess, kWhite,
AppLocalizations.of(context)!.pathCreatedSuccess, context, null);
}
} catch (e) {
showNotification(
kError,
kWhite,
AppLocalizations.of(context)!.pathCreateError,
context,
null);
rethrow; // Important so showNewOrUpdateGuidedPath knows it failed
}
},
);
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: kSuccess, foregroundColor: kWhite), backgroundColor: kSuccess, foregroundColor: kWhite),
), ),
@ -198,48 +173,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
children: [ children: [
IconButton( IconButton(
icon: Icon(Icons.edit, color: kPrimaryColor), icon: Icon(Icons.edit, color: kPrimaryColor),
onPressed: () { onPressed: () => _openEditor(path),
final appContext = Provider.of<AppContext>(
context,
listen: false);
showNewOrUpdateGuidedPath(
context,
path,
widget.parentId,
widget.isEvent,
isGeolocated: widget.isGeolocated,
(updatedPath) async {
try {
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;
final result = widget.isParcours
? await clientAPI.sectionParcoursApi!.sectionParcoursUpdateGuidedPath(updatedPath)
: await clientAPI.sectionMapApi!.sectionMapUpdateGuidedPath(updatedPath);
if (result != null) {
if (mounted) {
setState(() {
paths[index] = result;
widget.onChanged(paths);
});
}
showNotification(
kSuccess,
kWhite,
AppLocalizations.of(context)!.pathUpdatedSuccess,
context,
null);
}
} catch (e) {
showNotification(
kError,
kWhite,
AppLocalizations.of(context)!.pathUpdateError,
context,
null);
rethrow;
}
},
);
},
), ),
IconButton( IconButton(
icon: Icon(Icons.delete, color: kError), icon: Icon(Icons.delete, color: kError),

View File

@ -1,524 +0,0 @@
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,
),
),
],
),
],
),
),
),
);
},
);
},
);
}

View File

@ -1,555 +0,0 @@
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/Components/confirmation_dialog.dart';
import 'package:manager_app/Components/geometry_input_container.dart';
import 'package:manager_app/Components/number_stepper_field.dart';
import 'package:manager_app/Components/reorderable_custom_list.dart';
import 'package:manager_app/Components/multi_string_input_container.dart';
import 'package:manager_app/Components/rounded_button.dart';
import 'package:manager_app/Components/section_card.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/listView_card_image.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/new_update_image_slider.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'showNewOrUpdateQuizQuestion.dart';
/// Médias acceptés sur une étape de parcours. Le PDF s'ajoute aux types du
/// slider : une étape peut porter un plan, une partition, un fac-similé.
/// Le rendu visiteur correspondant vit dans `CachedCustomResource`
/// (mymuseum-visitapp) et `ResourceViewer.tsx` (visitapp-web).
const kGuidedStepResourceTypes = <ResourceType>[
...kSliderContentResourceTypes,
ResourceType.Pdf,
];
void showNewOrUpdateGuidedStep(
BuildContext context,
GuidedStepDTO? step,
String pathId,
bool isEscapeMode,
FutureOr<void> Function(GuidedStepDTO) onSave, {
bool isGeolocated = true,
}) {
// Use jsonEncode/jsonDecode for a robust deep copy that handles nested DTOs correctly
GuidedStepDTO workingStep = step != null
? GuidedStepDTO.fromJson(jsonDecode(jsonEncode(step)))!
: GuidedStepDTO(
title: [],
description: [],
quizQuestions: [],
audioIds: [],
contents: [],
order: 0,
);
workingStep.audioIds = List.from(workingStep.audioIds ?? []);
workingStep.contents = List.from(workingStep.contents ?? []);
workingStep.quizQuestions = List.from(workingStep.quizQuestions ?? []);
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,
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.75;
final double contentWidth = dialogWidth - 48;
final double halfWidth = (contentWidth - 20) / 2;
final appCtx = Provider.of<AppContext>(context, listen: false);
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(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.85),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step == null ? AppLocalizations.of(context)!.newStepTitle : AppLocalizations.of(context)!.editStepTitle,
style: TextStyle(
color: kPrimaryColor,
fontSize: 20,
fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre + Description côte à côte
Row(
children: [
SizedBox(
width: halfWidth,
child: MultiStringInputContainer(
label: "Titre :",
modalLabel: AppLocalizations.of(context)!.stepTitleLabel,
initialValue: workingStep.title ?? [],
onGetResult: (val) =>
setState(() => workingStep.title = val),
maxLines: 1,
isTitle: true,
isHTML: true,
showPreview: true,
),
),
SizedBox(width: 20),
SizedBox(
width: halfWidth,
child: MultiStringInputContainer(
label: "Description :",
modalLabel: AppLocalizations.of(context)!.stepDescriptionLabel,
initialValue: workingStep.description ?? [],
onGetResult: (val) => setState(
() => workingStep.description = val),
maxLines: 1,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
SizedBox(height: 16),
// Emplacement Directement avec GeometryDTO.
// Masqué pour un parcours en salle (ShowMap = false) : sans carte,
// une position et une zone de déclenchement n'ont aucun effet.
if (isGeolocated)
SectionCard(
icon: Icons.location_on,
title: "Emplacement",
subtitle: "Position et déclenchement géolocalisé de l'étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GeometryInputContainer(
label: AppLocalizations.of(context)!.stepLocationLabel,
initialGeometry: workingStep.geometry,
initialColor: null,
// GuidedStepDTO n'a pas de champ color (contrairement à
// MapAnnotationDTO.polyColor) : bouton masqué plutôt que
// supprimé, ne pas retirer comme du code mort.
showColorButton: false,
onSave: (geometry, color) {
setState(() {
workingStep.geometry = geometry;
});
},
),
SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
Switch(
value: workingStep.isGeoTriggered ?? false,
onChanged: (val) => setState(() => workingStep.isGeoTriggered = val),
activeThumbColor: kPrimaryColor,
),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text("Déclenchement géolocalisé", style: TextStyle(fontSize: 14)),
Text(
"L'étape se débloque automatiquement quand le visiteur entre dans la zone.",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
),
],
),
),
if (workingStep.isGeoTriggered == true)
Padding(
padding: const EdgeInsets.only(left: 12),
child: NumberStepperField(
label: "Rayon",
value: workingStep.zoneRadiusMeters,
min: 1,
max: 500,
unit: "m",
onChanged: (val) => setState(() =>
workingStep.zoneRadiusMeters = val.toDouble()),
),
),
],
),
],
),
),
if (isGeolocated) SizedBox(height: 16),
SectionCard(
icon: Icons.perm_media,
title: "Contenu riche",
subtitle: "Audio guide, images et vidéos affichés sur la fiche de l'étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audio
MultiStringInputContainer(
label: "Audio :",
resourceTypes: [ResourceType.Audio],
modalLabel: "Audio du guide",
initialValue: workingStep.audioIds!,
onGetResult: (val) => setState(() => workingStep.audioIds = val.isEmpty ? null : val),
maxLines: 1,
isTitle: false,
isHTML: false,
),
SizedBox(height: 12),
// Images
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Médias :", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
IconButton(
icon: Icon(Icons.add_circle_outline, color: kSuccess),
onPressed: () async {
final result = await showNewOrUpdateContentSlider(
null, appCtx, context, true, false,
resourceTypes: kGuidedStepResourceTypes,
);
if (result != null) {
setState(() {
result.order = workingStep.contents!.length;
workingStep.contents = [...workingStep.contents!, result];
});
}
},
),
],
),
if (workingStep.contents!.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
"Aucun média — cliquez + pour ajouter une image, une vidéo ou un audio",
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey[600], fontSize: 13),
),
)
else
SizedBox(
height: 250,
child: ReorderableListView(
scrollDirection: Axis.horizontal,
onReorderItem: (oldIndex, newIndex) {
setState(() {
final item = workingStep.contents!.removeAt(oldIndex);
workingStep.contents!.insert(newIndex, item);
for (var i = 0; i < workingStep.contents!.length; i++) {
workingStep.contents![i].order = i;
}
});
},
children: List.generate(workingStep.contents!.length, (i) => ListViewCardContent(
workingStep.contents!,
i,
Key('content_$i'),
appCtx,
(updated) => setState(() => workingStep.contents = List.from(updated)),
true,
false,
)),
),
),
],
),
),
SizedBox(height: 16),
// Questions disponibles pour tous les parcours (guidés et escape game)
SectionCard(
icon: Icons.quiz,
title: AppLocalizations.of(context)!.questionsChallengesLabel,
subtitle: isEscapeMode
? "Énigme à résoudre pour progresser"
: "Quiz optionnel pour cette étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: Icon(Icons.add_circle_outline,
color: kSuccess),
onPressed: () {
showNewOrUpdateQuizQuestion(
context,
null,
workingStep.id ?? "temp",
isEscapeMode,
(newQuestion) {
setState(() {
newQuestion.order =
workingStep.quizQuestions?.length ?? 0;
workingStep.quizQuestions = [
...(workingStep.quizQuestions ??
[]),
newQuestion
];
questionsRevision++;
});
},
);
},
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
Switch(
value: workingStep.isStepTimer ?? false,
onChanged: (val) => setState(() => workingStep.isStepTimer = val),
activeThumbColor: kPrimaryColor,
),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text("Timer", style: TextStyle(fontSize: 14)),
Text(
"Chronomètre la réponse ; passé le délai, le message d'expiration s'affiche.",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
),
],
),
),
if (workingStep.isStepTimer == true)
Padding(
padding: const EdgeInsets.only(left: 12),
child: NumberStepperField(
label: AppLocalizations.of(context)!.durationSecondsLabel,
value: workingStep.timerSeconds,
min: 0,
max: 3600,
unit: "sec",
onChanged: (val) => setState(() => workingStep.timerSeconds = val.toInt()),
),
),
],
),
if (workingStep.isStepTimer == true) ...[
SizedBox(height: 8),
Row(
children: [
Expanded(
child: MultiStringInputContainer(
label: "Message d'expiration :",
modalLabel: "Message d'expiration du timer",
initialValue: workingStep.timerExpiredMessage ?? [],
onGetResult: (val) => setState(() =>
workingStep.timerExpiredMessage = val.isEmpty ? null : val),
maxLines: 2,
isTitle: false,
isHTML: true,
showPreview: true,
),
),
],
),
SizedBox(height: 8),
],
if (workingStep.quizQuestions == null ||
workingStep.quizQuestions!.isEmpty)
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8),
child: Text(
AppLocalizations.of(context)!.noQuestionsConfigured,
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
)
else
ReorderableCustomList<QuizQuestion>(
key: ValueKey(questionsRevision),
items: workingStep.quizQuestions!,
shrinkWrap: true,
onChanged: (updatedList) {
setState(() {
for (var i = 0; i < updatedList.length; i++) {
updatedList[i].order = i;
}
workingStep.quizQuestions =
List.from(updatedList);
});
},
itemBuilder: (context, qIndex, question) {
return ListTile(
dense: true,
title: HtmlWidget(
question.label.isNotEmpty
? question.label
.firstWhere(
(t) => t.language == 'FR',
orElse: () =>
question.label[0])
.value ??
"Question $qIndex"
: "Question $qIndex",
textStyle: TextStyle(fontSize: 14),
),
subtitle: Text(
"Type: ${question.validationQuestionType == QuestionType.puzzle ? 'Puzzle' : question.validationQuestionType == QuestionType.multipleChoice ? 'QCM' : 'Texte'}"),
);
},
actions: [
(context, qIndex, question) => IconButton(
icon: Icon(Icons.edit,
size: 18, color: kPrimaryColor),
onPressed: () {
showNewOrUpdateQuizQuestion(
context,
question,
workingStep.id ?? "temp",
isEscapeMode,
(updatedQuestion) {
setState(() {
updatedQuestion.order =
question.order;
workingStep.quizQuestions![
qIndex] = updatedQuestion;
questionsRevision++;
});
},
);
},
),
(context, qIndex, question) => IconButton(
icon: Icon(Icons.delete,
size: 18, color: kError),
onPressed: () {
showConfirmationDialog(
"Supprimer cette question ?",
() {},
() => setState(() {
workingStep.quizQuestions!
.removeAt(qIndex);
questionsRevision++;
}),
context,
);
},
),
],
),
],
),
),
],
),
),
),
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: isSaving ? "Sauvegarde..." : "Sauvegarder",
icon: isSaving ? Icons.hourglass_empty : null,
press: () async {
if (isSaving) return;
setState(() => isSaving = true);
// Initialise les booleans null false
workingStep.isStepTimer ??= false;
workingStep.isGeoTriggered ??= false;
if (workingStep.isGeoTriggered != true) {
workingStep.zoneRadiusMeters = null;
}
try {
await onSave(workingStep);
if (context.mounted) Navigator.pop(context);
} catch (e) {
setState(() => isSaving = false);
}
},
color: kPrimaryColor,
fontSize: 15,
horizontal: 24,
),
),
],
),
],
),
),
),
);
},
);
},
);
}

View File

@ -1,453 +0,0 @@
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.simple,
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.simple,
child: Text("Simple (texte attendu)")),
DropdownMenuItem(
value: QuestionType.multipleChoice,
child: Text("Choix multiples (QCM)")),
DropdownMenuItem(
value: QuestionType.puzzle,
child: Text("Puzzle")),
],
onChanged: (val) => setState(() {
workingQuestion.validationQuestionType = val;
workingQuestion.responses = [];
}),
),
// =========================================
// Type 0 : Simple texte
// =========================================
if (workingQuestion.validationQuestionType ==
QuestionType.simple) ...[
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.multipleChoice) ...[
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.puzzle) ...[
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,
),
),
],
),
],
),
),
),
);
},
);
},
);
}

View File

@ -682,8 +682,29 @@
"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?", "addStep": "Add a step",
"discardStepChangesConfirm": "This step has unsaved changes, along with its questions. Discard these changes?", "addQuestion": "Add a question",
"discardQuestionChangesConfirm": "This question has unsaved changes, along with its answers. Discard these changes?", "deleteStep": "Delete step",
"deleteStepConfirm": "Delete this step and its questions?",
"pathPanelTitle": "The path",
"pathPanelSubtitle": "Title, mood, progression",
"stepSummaryEmpty": "No content",
"mediaCount": "{count} media",
"@mediaCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"questionCount": "{count} question(s)",
"@questionCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"saveStatusIdle": "Saved automatically",
"saveStatusSaving": "Saving…",
"saveStatusSaved": "Saved",
"saveStatusFailed": "Could not save",
"retry": "Retry",
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map." "mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map."
} }

View File

@ -682,8 +682,29 @@
"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 ?", "addStep": "Ajouter une étape",
"discardStepChangesConfirm": "Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?", "addQuestion": "Ajouter une question",
"discardQuestionChangesConfirm": "Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?", "deleteStep": "Supprimer l'étape",
"deleteStepConfirm": "Supprimer cette étape et ses questions ?",
"pathPanelTitle": "Le parcours",
"pathPanelSubtitle": "Titre, ambiance, progression",
"stepSummaryEmpty": "Aucun contenu",
"mediaCount": "{count} média(s)",
"@mediaCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"questionCount": "{count} question(s)",
"@questionCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"saveStatusIdle": "Enregistrement automatique",
"saveStatusSaving": "Enregistrement…",
"saveStatusSaved": "Enregistré",
"saveStatusFailed": "Échec de l'enregistrement",
"retry": "Réessayer",
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte." "mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte."
} }

View File

@ -3298,23 +3298,89 @@ abstract class AppLocalizations {
/// **'Annotations'** /// **'Annotations'**
String get annotationsLabel; String get annotationsLabel;
/// No description provided for @discardPathChangesConfirm. /// No description provided for @addStep.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?'** /// **'Ajouter une étape'**
String get discardPathChangesConfirm; String get addStep;
/// No description provided for @discardStepChangesConfirm. /// No description provided for @addQuestion.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?'** /// **'Ajouter une question'**
String get discardStepChangesConfirm; String get addQuestion;
/// No description provided for @discardQuestionChangesConfirm. /// No description provided for @deleteStep.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?'** /// **'Supprimer l\'étape'**
String get discardQuestionChangesConfirm; String get deleteStep;
/// No description provided for @deleteStepConfirm.
///
/// In fr, this message translates to:
/// **'Supprimer cette étape et ses questions ?'**
String get deleteStepConfirm;
/// No description provided for @pathPanelTitle.
///
/// In fr, this message translates to:
/// **'Le parcours'**
String get pathPanelTitle;
/// No description provided for @pathPanelSubtitle.
///
/// In fr, this message translates to:
/// **'Titre, ambiance, progression'**
String get pathPanelSubtitle;
/// No description provided for @stepSummaryEmpty.
///
/// In fr, this message translates to:
/// **'Aucun contenu'**
String get stepSummaryEmpty;
/// No description provided for @mediaCount.
///
/// In fr, this message translates to:
/// **'{count} média(s)'**
String mediaCount(int count);
/// No description provided for @questionCount.
///
/// In fr, this message translates to:
/// **'{count} question(s)'**
String questionCount(int count);
/// No description provided for @saveStatusIdle.
///
/// In fr, this message translates to:
/// **'Enregistrement automatique'**
String get saveStatusIdle;
/// No description provided for @saveStatusSaving.
///
/// In fr, this message translates to:
/// **'Enregistrement…'**
String get saveStatusSaving;
/// No description provided for @saveStatusSaved.
///
/// In fr, this message translates to:
/// **'Enregistré'**
String get saveStatusSaved;
/// No description provided for @saveStatusFailed.
///
/// In fr, this message translates to:
/// **'Échec de l\'enregistrement'**
String get saveStatusFailed;
/// No description provided for @retry.
///
/// In fr, this message translates to:
/// **'Réessayer'**
String get retry;
/// No description provided for @mapProviderMobileOnlyNote. /// No description provided for @mapProviderMobileOnlyNote.
/// ///

View File

@ -1746,16 +1746,50 @@ class AppLocalizationsEn extends AppLocalizations {
String get annotationsLabel => 'Annotations'; String get annotationsLabel => 'Annotations';
@override @override
String get discardPathChangesConfirm => String get addStep => 'Add a step';
'This path has unsaved changes, along with its steps and their questions. Discard these changes?';
@override @override
String get discardStepChangesConfirm => String get addQuestion => 'Add a question';
'This step has unsaved changes, along with its questions. Discard these changes?';
@override @override
String get discardQuestionChangesConfirm => String get deleteStep => 'Delete step';
'This question has unsaved changes, along with its answers. Discard these changes?';
@override
String get deleteStepConfirm => 'Delete this step and its questions?';
@override
String get pathPanelTitle => 'The path';
@override
String get pathPanelSubtitle => 'Title, mood, progression';
@override
String get stepSummaryEmpty => 'No content';
@override
String mediaCount(int count) {
return '$count media';
}
@override
String questionCount(int count) {
return '$count question(s)';
}
@override
String get saveStatusIdle => 'Saved automatically';
@override
String get saveStatusSaving => 'Saving…';
@override
String get saveStatusSaved => 'Saved';
@override
String get saveStatusFailed => 'Could not save';
@override
String get retry => 'Retry';
@override @override
String get mapProviderMobileOnlyNote => String get mapProviderMobileOnlyNote =>

View File

@ -1784,16 +1784,50 @@ class AppLocalizationsFr extends AppLocalizations {
String get annotationsLabel => 'Annotations'; String get annotationsLabel => 'Annotations';
@override @override
String get discardPathChangesConfirm => String get addStep => 'Ajouter une étape';
'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?';
@override @override
String get discardStepChangesConfirm => String get addQuestion => 'Ajouter une question';
'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?';
@override @override
String get discardQuestionChangesConfirm => String get deleteStep => 'Supprimer l\'étape';
'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?';
@override
String get deleteStepConfirm => 'Supprimer cette étape et ses questions ?';
@override
String get pathPanelTitle => 'Le parcours';
@override
String get pathPanelSubtitle => 'Titre, ambiance, progression';
@override
String get stepSummaryEmpty => 'Aucun contenu';
@override
String mediaCount(int count) {
return '$count média(s)';
}
@override
String questionCount(int count) {
return '$count question(s)';
}
@override
String get saveStatusIdle => 'Enregistrement automatique';
@override
String get saveStatusSaving => 'Enregistrement…';
@override
String get saveStatusSaved => 'Enregistré';
@override
String get saveStatusFailed => 'Échec de l\'enregistrement';
@override
String get retry => 'Réessayer';
@override @override
String get mapProviderMobileOnlyNote => String get mapProviderMobileOnlyNote =>

View File

@ -1763,16 +1763,51 @@ class AppLocalizationsNl extends AppLocalizations {
String get annotationsLabel => 'Annotaties'; String get annotationsLabel => 'Annotaties';
@override @override
String get discardPathChangesConfirm => String get addStep => 'Een stap toevoegen';
'Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?';
@override @override
String get discardStepChangesConfirm => String get addQuestion => 'Een vraag toevoegen';
'Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?';
@override @override
String get discardQuestionChangesConfirm => String get deleteStep => 'Stap verwijderen';
'Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?';
@override
String get deleteStepConfirm =>
'Deze stap en de bijbehorende vragen verwijderen?';
@override
String get pathPanelTitle => 'De route';
@override
String get pathPanelSubtitle => 'Titel, sfeer, voortgang';
@override
String get stepSummaryEmpty => 'Geen inhoud';
@override
String mediaCount(int count) {
return '$count media';
}
@override
String questionCount(int count) {
return '$count vraag/vragen';
}
@override
String get saveStatusIdle => 'Automatisch opslaan';
@override
String get saveStatusSaving => 'Opslaan…';
@override
String get saveStatusSaved => 'Opgeslagen';
@override
String get saveStatusFailed => 'Opslaan mislukt';
@override
String get retry => 'Opnieuw proberen';
@override @override
String get mapProviderMobileOnlyNote => String get mapProviderMobileOnlyNote =>

View File

@ -682,8 +682,29 @@
"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?", "addStep": "Een stap toevoegen",
"discardStepChangesConfirm": "Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?", "addQuestion": "Een vraag toevoegen",
"discardQuestionChangesConfirm": "Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?", "deleteStep": "Stap verwijderen",
"deleteStepConfirm": "Deze stap en de bijbehorende vragen verwijderen?",
"pathPanelTitle": "De route",
"pathPanelSubtitle": "Titel, sfeer, voortgang",
"stepSummaryEmpty": "Geen inhoud",
"mediaCount": "{count} media",
"@mediaCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"questionCount": "{count} vraag/vragen",
"@questionCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"saveStatusIdle": "Automatisch opslaan",
"saveStatusSaving": "Opslaan…",
"saveStatusSaved": "Opgeslagen",
"saveStatusFailed": "Opslaan mislukt",
"retry": "Opnieuw proberen",
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart." "mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart."
} }