From b39854afb09712bd9cce1e50079b974d88fc860c Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 11 Aug 2026 17:15:36 +0200 Subject: [PATCH] =?UTF-8?q?DB4=20:=20les=20quatre=20fen=C3=AAtres=20empil?= =?UTF-8?q?=C3=A9es=20du=20parcours=20deviennent=20une=20seule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Parcours/Fields/etape_fields.dart | 336 ++++++++ .../Parcours/Fields/parcours_fields.dart | 248 ++++++ .../Parcours/Fields/question_fields.dart | 473 +++++++++++ .../SubSection/Parcours/guided_path_api.dart | 43 + .../Parcours/guided_path_editor.dart | 785 ++++++++++++++++++ .../SubSection/Parcours/parcours_config.dart | 124 +-- .../Parcours/showNewOrUpdateGuidedPath.dart | 524 ------------ .../Parcours/showNewOrUpdateGuidedStep.dart | 555 ------------- .../Parcours/showNewOrUpdateQuizQuestion.dart | 453 ---------- lib/l10n/app_en.arb | 27 +- lib/l10n/app_fr.arb | 27 +- lib/l10n/app_localizations.dart | 84 +- lib/l10n/app_localizations_en.dart | 46 +- lib/l10n/app_localizations_fr.dart | 46 +- lib/l10n/app_localizations_nl.dart | 47 +- lib/l10n/app_nl.arb | 27 +- 16 files changed, 2182 insertions(+), 1663 deletions(-) create mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/Fields/etape_fields.dart create mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/Fields/parcours_fields.dart create mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/Fields/question_fields.dart create mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_api.dart create mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_editor.dart delete mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart delete mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart delete mode 100644 lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/etape_fields.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/etape_fields.dart new file mode 100644 index 0000000..8dab1e9 --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/etape_fields.dart @@ -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 = [ + ...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(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, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/parcours_fields.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/parcours_fields.dart new file mode 100644 index 0000000..6cdf6ad --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/parcours_fields.dart @@ -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 _mergeResources( + List values, + List? 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( + groupValue: path.isGameMode ?? false, + onChanged: (val) { + path.isGameMode = val; + onChanged(); + }, + child: Column( + children: [ + RadioListTile( + 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( + 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( + groupValue: progressionModeOf(path), + onChanged: (val) { + applyProgressionMode(path, val!); + onChanged(); + }, + child: Column( + children: [ + for (final mode in ProgressionMode.values) + RadioListTile( + 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(); + }, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/question_fields.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/question_fields.dart new file mode 100644 index 0000000..4889fb9 --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/Fields/question_fields.dart @@ -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 _toTranslationList(List? list) => + (list ?? []) + .map((t) => TranslationDTO(language: t.language, value: t.value)) + .toList(); + +List _fromTranslationList( + List 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( + 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 _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 _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 _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 questions; + final String stepId; + final VoidCallback onChanged; + + @override + State createState() => _QuestionsBlockState(); +} + +class _QuestionsBlockState extends State { + 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(); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_api.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_api.dart new file mode 100644 index 0000000..1548fd8 --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_api.dart @@ -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 createPath(String sectionId, GuidedPathDTO path) => + isParcours + ? _parcours.sectionParcoursCreateGuidedPath(sectionId, path) + : _map.sectionMapCreateGuidedPath(sectionId, path); + + Future updatePath(GuidedPathDTO path) => isParcours + ? _parcours.sectionParcoursUpdateGuidedPath(path) + : _map.sectionMapUpdateGuidedPath(path); + + Future createStep(String pathId, GuidedStepDTO step) => + isParcours + ? _parcours.sectionParcoursCreateGuidedStep(pathId, step) + : _map.sectionMapCreateGuidedStep(pathId, step); + + Future updateStep(GuidedStepDTO step) => isParcours + ? _parcours.sectionParcoursUpdateGuidedStep(step) + : _map.sectionMapUpdateGuidedStep(step); + + Future deleteStep(String stepId) => isParcours + ? _parcours.sectionParcoursDeleteGuidedStep(stepId) + : _map.sectionMapDeleteGuidedStep(stepId); +} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_editor.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_editor.dart new file mode 100644 index 0000000..fec9715 --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/guided_path_editor.dart @@ -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 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( + 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 createState() => _GuidedPathEditorState(); +} + +class _GuidedPathEditorState extends State { + static const Duration _debounceDelay = Duration(milliseconds: 700); + + late GuidedPathDTO _path; + + /// `null` = le panneau du parcours lui-même. + GuidedStepDTO? _selected; + + Timer? _debounce; + Future? _inFlight; + bool _flushing = false; + _SaveStatus _status = _SaveStatus.idle; + + bool _pathDirty = false; + final Set _dirtySteps = Set.identity(); + final List _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 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 _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 _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 _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 _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 _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 ?? []) { + 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 _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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart index fbb6dc6..ab18a04 100644 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart +++ b/lib/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart @@ -2,7 +2,8 @@ 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/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:manager_app/app_context.dart'; import 'package:manager_app/l10n/app_localizations.dart'; @@ -65,6 +66,31 @@ class _ParcoursConfigState extends State { } } + /// 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 _openEditor(GuidedPathDTO? path) async { + final appContext = Provider.of(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 Widget build(BuildContext context) { return Column( @@ -79,58 +105,7 @@ class _ParcoursConfigState extends State { ElevatedButton.icon( icon: Icon(Icons.add), label: Text(AppLocalizations.of(context)!.addPath), - onPressed: () { - final appContext = - Provider.of(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 - } - }, - ); - }, + onPressed: () => _openEditor(null), style: ElevatedButton.styleFrom( backgroundColor: kSuccess, foregroundColor: kWhite), ), @@ -198,48 +173,7 @@ class _ParcoursConfigState extends State { children: [ IconButton( icon: Icon(Icons.edit, color: kPrimaryColor), - onPressed: () { - final appContext = Provider.of( - 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; - } - }, - ); - }, + onPressed: () => _openEditor(path), ), IconButton( icon: Icon(Icons.delete, color: kError), diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart deleted file mode 100644 index bdd6137..0000000 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart +++ /dev/null @@ -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 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( - groupValue: workingPath.isGameMode ?? false, - onChanged: (val) => setState( - () => workingPath.isGameMode = val), - child: Column( - children: [ - RadioListTile( - 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( - 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( - groupValue: progressionModeOf(workingPath), - onChanged: (val) => setState(() => - applyProgressionMode(workingPath, val!)), - child: Column( - children: [ - for (final mode in ProgressionMode.values) - RadioListTile( - 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( - 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, - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, - ); -} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart deleted file mode 100644 index 573d79d..0000000 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart +++ /dev/null @@ -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 = [ - ...kSliderContentResourceTypes, - ResourceType.Pdf, -]; - -void showNewOrUpdateGuidedStep( - BuildContext context, - GuidedStepDTO? step, - String pathId, - bool isEscapeMode, - FutureOr 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(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( - 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, - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, - ); -} diff --git a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart b/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart deleted file mode 100644 index 1365eb3..0000000 --- a/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateQuizQuestion.dart +++ /dev/null @@ -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 _toTranslationList( - List? list) => - (list ?? []) - .map((t) => TranslationDTO(language: t.language, value: t.value)) - .toList(); - -List _fromTranslationList( - List 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 workingLabel = _toTranslationList( - clonedQuestion != null && clonedQuestion.label.isNotEmpty - ? clonedQuestion.label - : [TranslationAndResourceDTO(language: 'FR', value: '')]); - - List 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( - 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 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 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, - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, - ); -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d24a172..49aab8c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -682,8 +682,29 @@ "categoriesTitle": "Categories", "endTimeLabel": "End time", "annotationsLabel": "Annotations", - "discardPathChangesConfirm": "This path has unsaved changes, along with its steps and their questions. Discard these changes?", - "discardStepChangesConfirm": "This step has unsaved changes, along with its questions. Discard these changes?", - "discardQuestionChangesConfirm": "This question has unsaved changes, along with its answers. Discard these changes?", + "addStep": "Add a step", + "addQuestion": "Add a question", + "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." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 337c170..fcb5a3c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -682,8 +682,29 @@ "categoriesTitle": "Catégories", "endTimeLabel": "Heure de fin", "annotationsLabel": "Annotations", - "discardPathChangesConfirm": "Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?", - "discardStepChangesConfirm": "Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?", - "discardQuestionChangesConfirm": "Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?", + "addStep": "Ajouter une étape", + "addQuestion": "Ajouter une question", + "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." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15844fd..1cd92ea 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3298,23 +3298,89 @@ abstract class AppLocalizations { /// **'Annotations'** String get annotationsLabel; - /// No description provided for @discardPathChangesConfirm. + /// No description provided for @addStep. /// /// In fr, this message translates to: - /// **'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?'** - String get discardPathChangesConfirm; + /// **'Ajouter une étape'** + String get addStep; - /// No description provided for @discardStepChangesConfirm. + /// No description provided for @addQuestion. /// /// In fr, this message translates to: - /// **'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?'** - String get discardStepChangesConfirm; + /// **'Ajouter une question'** + String get addQuestion; - /// No description provided for @discardQuestionChangesConfirm. + /// No description provided for @deleteStep. /// /// In fr, this message translates to: - /// **'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?'** - String get discardQuestionChangesConfirm; + /// **'Supprimer l\'étape'** + 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. /// diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4149ec5..7ea5a3d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1746,16 +1746,50 @@ class AppLocalizationsEn extends AppLocalizations { String get annotationsLabel => 'Annotations'; @override - String get discardPathChangesConfirm => - 'This path has unsaved changes, along with its steps and their questions. Discard these changes?'; + String get addStep => 'Add a step'; @override - String get discardStepChangesConfirm => - 'This step has unsaved changes, along with its questions. Discard these changes?'; + String get addQuestion => 'Add a question'; @override - String get discardQuestionChangesConfirm => - 'This question has unsaved changes, along with its answers. Discard these changes?'; + String get deleteStep => 'Delete step'; + + @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 String get mapProviderMobileOnlyNote => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4c0c6b9..8f1abfa 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1784,16 +1784,50 @@ class AppLocalizationsFr extends AppLocalizations { String get annotationsLabel => 'Annotations'; @override - String get discardPathChangesConfirm => - 'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?'; + String get addStep => 'Ajouter une étape'; @override - String get discardStepChangesConfirm => - 'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?'; + String get addQuestion => 'Ajouter une question'; @override - String get discardQuestionChangesConfirm => - 'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?'; + String get deleteStep => 'Supprimer l\'étape'; + + @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 String get mapProviderMobileOnlyNote => diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 47e65b1..1b954e7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1763,16 +1763,51 @@ class AppLocalizationsNl extends AppLocalizations { String get annotationsLabel => 'Annotaties'; @override - String get discardPathChangesConfirm => - 'Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?'; + String get addStep => 'Een stap toevoegen'; @override - String get discardStepChangesConfirm => - 'Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?'; + String get addQuestion => 'Een vraag toevoegen'; @override - String get discardQuestionChangesConfirm => - 'Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?'; + String get deleteStep => 'Stap verwijderen'; + + @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 String get mapProviderMobileOnlyNote => diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 98094be..d65a822 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -682,8 +682,29 @@ "categoriesTitle": "Categorieën", "endTimeLabel": "Eindtijd", "annotationsLabel": "Annotaties", - "discardPathChangesConfirm": "Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?", - "discardStepChangesConfirm": "Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?", - "discardQuestionChangesConfirm": "Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?", + "addStep": "Een stap toevoegen", + "addQuestion": "Een vraag toevoegen", + "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." }