DB4 : les quatre fenêtres empilées du parcours deviennent une seule
Option A. Il fallait cinq surfaces empilées pour poser une question sur une
étape et l'écrire en néerlandais ; la profondeur maximale retombe à 2 —
l'éditeur, puis la traduction.
Les trois showNewOrUpdate… sont supprimés au profit d'un rail d'étapes
toujours visible à gauche, d'un panneau de détail à droite, et d'une question
qui se déplie sur place au lieu d'ouvrir une quatrième fenêtre.
Les champs sont d'abord sortis en trois widgets autonomes (ParcoursFields,
EtapeFields, QuestionFields) : c'est ce qui a rendu la refonte possible sans
tout réécrire — 1663 lignes retirées, l'écran n'a pas réécrit les champs, il
les a réagencés.
SAUVEGARDE AU FIL DE L'EAU (héritée de DB2) — plus de bouton Sauvegarder,
tout part à la saisie avec un débounce de 700 ms ; ajouts, suppressions et
réordonnancements partent sans attendre. GuidedPathApi masque à l'éditeur le
fait qu'un parcours vive sous une SectionParcours ou sous une SectionMap :
mêmes opérations, seule la classe générée change.
Deux pièges du backend, trouvés en lisant le contrôleur plutôt qu'en le
supposant :
- UpdateGuidedPath supprime les étapes absentes du DTO, donc le payload
n'emporte que celles qui ont déjà un id — les autres attendent leur
CreateGuidedStep et seraient dupliquées.
- Les questions n'ont pas d'endpoint propre (c'est voulu : elles partent avec
leur étape, que GuidedStep.FromDTO synchronise). Leur id entier est donc
récupéré après coup par `order`, seul repère stable entre la liste locale
et celle du serveur.
Le parcours est créé à la première modification, pas à l'ouverture : une
fenêtre neuve refermée intacte ne laisse rien en base. Si une écriture
échoue, la fenêtre refuse de se fermer et le pied de page porte un
« Réessayer ».
Le garde-fou de DB2 est retiré, comme prévu : un avertissement de perte de
travail n'a plus d'objet quand il n'y a plus rien à perdre. 14 clés i18n
FR/EN/NL ajoutées, 3 retirées.
flutter build web ✅, analyse du dossier sans erreur. Reste la vérification à
l'œil (DB5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5066b6c5e3
commit
b39854afb0
@ -0,0 +1,336 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/app_context.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/l10n/app_localizations.dart';
|
||||
import 'package:manager_app/Components/geometry_input_container.dart';
|
||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||
import 'package:manager_app/Components/number_stepper_field.dart';
|
||||
import 'package:manager_app/Components/section_card.dart';
|
||||
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/listView_card_image.dart';
|
||||
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/new_update_image_slider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'question_fields.dart';
|
||||
|
||||
/// Médias acceptés sur une étape de parcours. Le PDF s'ajoute aux types du
|
||||
/// slider : une étape peut porter un plan, une partition, un fac-similé.
|
||||
/// Le rendu visiteur correspondant vit dans `CachedCustomResource`
|
||||
/// (mymuseum-visitapp) et `ResourceViewer.tsx` (visitapp-web).
|
||||
const kGuidedStepResourceTypes = <ResourceType>[
|
||||
...kSliderContentResourceTypes,
|
||||
ResourceType.Pdf,
|
||||
];
|
||||
|
||||
/// Les champs d'une étape, sans son contenant : ils s'affichent dans le
|
||||
/// panneau de droite de l'éditeur de parcours, à côté du rail.
|
||||
///
|
||||
/// Le DTO est modifié en place ; `onChanged` prévient l'hôte.
|
||||
class EtapeFields extends StatelessWidget {
|
||||
const EtapeFields({
|
||||
Key? key,
|
||||
required this.step,
|
||||
required this.onChanged,
|
||||
required this.isEscapeMode,
|
||||
this.isGeolocated = true,
|
||||
}) : super(key: key);
|
||||
|
||||
final GuidedStepDTO step;
|
||||
final VoidCallback onChanged;
|
||||
final bool isEscapeMode;
|
||||
final bool isGeolocated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final appCtx = Provider.of<AppContext>(context, listen: false);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Titre :",
|
||||
modalLabel: l10n.stepTitleLabel,
|
||||
initialValue: step.title ?? [],
|
||||
onGetResult: (val) {
|
||||
step.title = val;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: kSpace7),
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Description :",
|
||||
modalLabel: l10n.stepDescriptionLabel,
|
||||
initialValue: step.description ?? [],
|
||||
onGetResult: (val) {
|
||||
step.description = val;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: kSpace5),
|
||||
// Masqué pour un parcours en salle (ShowMap = false) : sans carte, une
|
||||
// position et une zone de déclenchement n'ont aucun effet.
|
||||
if (isGeolocated) ...[
|
||||
SectionCard(
|
||||
icon: Icons.location_on,
|
||||
title: "Emplacement",
|
||||
subtitle: "Position et déclenchement géolocalisé de l'étape",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GeometryInputContainer(
|
||||
label: l10n.stepLocationLabel,
|
||||
initialGeometry: step.geometry,
|
||||
initialColor: null,
|
||||
// GuidedStepDTO n'a pas de champ color (contrairement à
|
||||
// MapAnnotationDTO.polyColor) : bouton masqué plutôt que
|
||||
// supprimé, ne pas retirer comme du code mort.
|
||||
showColorButton: false,
|
||||
onSave: (geometry, color) {
|
||||
step.geometry = geometry;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
SizedBox(height: kSpace3),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: step.isGeoTriggered ?? false,
|
||||
onChanged: (val) {
|
||||
step.isGeoTriggered = val;
|
||||
if (val != true) step.zoneRadiusMeters = null;
|
||||
onChanged();
|
||||
},
|
||||
activeThumbColor: kPrimaryColor,
|
||||
),
|
||||
SizedBox(width: kSpace3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Déclenchement géolocalisé",
|
||||
style: kTextBody),
|
||||
Text(
|
||||
"L'étape se débloque automatiquement quand le visiteur entre dans la zone.",
|
||||
style: kTextHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (step.isGeoTriggered == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: kSpace4),
|
||||
child: NumberStepperField(
|
||||
label: "Rayon",
|
||||
value: step.zoneRadiusMeters,
|
||||
min: 1,
|
||||
max: 500,
|
||||
unit: "m",
|
||||
onChanged: (val) {
|
||||
step.zoneRadiusMeters = val.toDouble();
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: kSpace5),
|
||||
],
|
||||
SectionCard(
|
||||
icon: Icons.perm_media,
|
||||
title: "Contenu riche",
|
||||
subtitle:
|
||||
"Audio guide, images et vidéos affichés sur la fiche de l'étape",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MultiStringInputContainer(
|
||||
label: "Audio :",
|
||||
resourceTypes: [ResourceType.Audio],
|
||||
modalLabel: "Audio du guide",
|
||||
initialValue: step.audioIds ?? [],
|
||||
onGetResult: (val) {
|
||||
step.audioIds = val.isEmpty ? null : val;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: false,
|
||||
isHTML: false,
|
||||
),
|
||||
SizedBox(height: kSpace4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("Médias :", style: kLabelField),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add_circle_outline, color: kSuccess),
|
||||
onPressed: () async {
|
||||
final result = await showNewOrUpdateContentSlider(
|
||||
null,
|
||||
appCtx,
|
||||
context,
|
||||
true,
|
||||
false,
|
||||
resourceTypes: kGuidedStepResourceTypes,
|
||||
);
|
||||
if (result == null) return;
|
||||
result.order = step.contents?.length ?? 0;
|
||||
step.contents = [...(step.contents ?? []), result];
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (step.contents?.isEmpty ?? true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: kSpace3),
|
||||
child: Text(
|
||||
"Aucun média — cliquez + pour ajouter une image, une vidéo ou un audio",
|
||||
style: kTextHint.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: ReorderableListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
final item = step.contents!.removeAt(oldIndex);
|
||||
step.contents!.insert(newIndex, item);
|
||||
for (var i = 0; i < step.contents!.length; i++) {
|
||||
step.contents![i].order = i;
|
||||
}
|
||||
onChanged();
|
||||
},
|
||||
children: List.generate(
|
||||
step.contents!.length,
|
||||
(i) => ListViewCardContent(
|
||||
step.contents!,
|
||||
i,
|
||||
Key('content_$i'),
|
||||
appCtx,
|
||||
(updated) {
|
||||
step.contents = List.from(updated);
|
||||
onChanged();
|
||||
},
|
||||
true,
|
||||
false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: kSpace5),
|
||||
// Questions — disponibles pour tous les parcours (guidés et escape game)
|
||||
SectionCard(
|
||||
icon: Icons.quiz,
|
||||
title: l10n.questionsChallengesLabel,
|
||||
subtitle: isEscapeMode
|
||||
? "Énigme à résoudre pour progresser"
|
||||
: "Quiz optionnel pour cette étape",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: step.isStepTimer ?? false,
|
||||
onChanged: (val) {
|
||||
step.isStepTimer = val;
|
||||
onChanged();
|
||||
},
|
||||
activeThumbColor: kPrimaryColor,
|
||||
),
|
||||
SizedBox(width: kSpace3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Timer", style: kTextBody),
|
||||
Text(
|
||||
"Chronomètre la réponse ; passé le délai, le message d'expiration s'affiche.",
|
||||
style: kTextHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (step.isStepTimer == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: kSpace4),
|
||||
child: NumberStepperField(
|
||||
label: l10n.durationSecondsLabel,
|
||||
value: step.timerSeconds,
|
||||
min: 0,
|
||||
max: 3600,
|
||||
unit: "sec",
|
||||
onChanged: (val) {
|
||||
step.timerSeconds = val.toInt();
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (step.isStepTimer == true) ...[
|
||||
SizedBox(height: kSpace3),
|
||||
MultiStringInputContainer(
|
||||
label: "Message d'expiration :",
|
||||
modalLabel: "Message d'expiration du timer",
|
||||
initialValue: step.timerExpiredMessage ?? [],
|
||||
onGetResult: (val) {
|
||||
step.timerExpiredMessage = val.isEmpty ? null : val;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 2,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
SizedBox(height: kSpace3),
|
||||
],
|
||||
QuestionsBlock(
|
||||
questions: step.quizQuestions ??= [],
|
||||
stepId: step.id ?? "temp",
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/Components/check_input_container.dart';
|
||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||
import 'package:manager_app/Components/number_stepper_field.dart';
|
||||
import 'package:manager_app/Components/resource_input_container.dart';
|
||||
import 'package:manager_app/Components/section_card.dart';
|
||||
import '../progression_mode.dart';
|
||||
|
||||
/// Les champs propres au parcours, sans son contenant ni sa liste d'étapes.
|
||||
///
|
||||
/// Le DTO est modifié en place ; `onChanged` prévient l'hôte, à charge pour lui
|
||||
/// de se reconstruire et de déclencher l'enregistrement.
|
||||
class ParcoursFields extends StatelessWidget {
|
||||
const ParcoursFields({
|
||||
Key? key,
|
||||
required this.path,
|
||||
required this.onChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final GuidedPathDTO path;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
/// Réécrit une liste de traductions en conservant la ressource déjà associée
|
||||
/// à chaque langue : `MultiStringInputContainer` ne manipule que le texte.
|
||||
List<TranslationAndResourceDTO> _mergeResources(
|
||||
List<TranslationDTO> values,
|
||||
List<TranslationAndResourceDTO>? previous,
|
||||
) {
|
||||
return values.map((t) {
|
||||
final prev = (previous ?? []).firstWhere(
|
||||
(e) => e.language == t.language,
|
||||
orElse: () => TranslationAndResourceDTO(),
|
||||
);
|
||||
return TranslationAndResourceDTO(
|
||||
language: t.language,
|
||||
value: t.value,
|
||||
resourceId: prev.resourceId,
|
||||
resource: prev.resource,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ResourceInputContainer(
|
||||
label: "Image :",
|
||||
color: kPrimaryColor,
|
||||
initialValue: path.imageResourceId,
|
||||
onChanged: (resourceDTO) {
|
||||
path.imageResourceId = resourceDTO.id;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
SizedBox(height: kSpace5),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Titre :",
|
||||
modalLabel: "Titre du parcours",
|
||||
initialValue: path.title ?? [],
|
||||
onGetResult: (val) {
|
||||
path.title = val;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: kSpace7),
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Description :",
|
||||
modalLabel: "Description du parcours",
|
||||
initialValue: path.description ?? [],
|
||||
onGetResult: (val) {
|
||||
path.description = val;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: kSpace5),
|
||||
SectionCard(
|
||||
icon: Icons.sports_esports,
|
||||
title: "Ambiance",
|
||||
subtitle: "Visite classique ou jeu",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Quelle ambiance ?", style: kLabelField),
|
||||
RadioGroup<bool>(
|
||||
groupValue: path.isGameMode ?? false,
|
||||
onChanged: (val) {
|
||||
path.isGameMode = val;
|
||||
onChanged();
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<bool>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kPrimaryColor,
|
||||
value: false,
|
||||
title: Text("Visite", style: kTextBody),
|
||||
subtitle: Text(
|
||||
"Parcours de découverte : le visiteur avance à son rythme.",
|
||||
style: kTextHint,
|
||||
),
|
||||
),
|
||||
RadioListTile<bool>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kPrimaryColor,
|
||||
value: true,
|
||||
title: Text("Jeu", style: kTextBody),
|
||||
subtitle: Text(
|
||||
"Escape game ou chasse au trésor : messages de début et de fin, vocabulaire de jeu sur les étapes.",
|
||||
style: kTextHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (path.isGameMode == true) ...[
|
||||
SizedBox(height: kSpace3),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Message de début :",
|
||||
modalLabel: "Message d'introduction du jeu",
|
||||
color: kPrimaryColor,
|
||||
initialValue: (path.gameMessageDebut ?? [])
|
||||
.map((t) => TranslationDTO(
|
||||
language: t.language, value: t.value))
|
||||
.toList(),
|
||||
onGetResult: (val) {
|
||||
path.gameMessageDebut =
|
||||
_mergeResources(val, path.gameMessageDebut);
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 2,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: kSpace7),
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Message de fin :",
|
||||
modalLabel: "Message de félicitations du jeu",
|
||||
color: kPrimaryColor,
|
||||
initialValue: (path.gameMessageFin ?? [])
|
||||
.map((t) => TranslationDTO(
|
||||
language: t.language, value: t.value))
|
||||
.toList(),
|
||||
onGetResult: (val) {
|
||||
path.gameMessageFin =
|
||||
_mergeResources(val, path.gameMessageFin);
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 2,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: kSpace5),
|
||||
SectionCard(
|
||||
icon: Icons.settings,
|
||||
title: "Options du parcours",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Comment le visiteur progresse-t-il ?", style: kLabelField),
|
||||
RadioGroup<ProgressionMode>(
|
||||
groupValue: progressionModeOf(path),
|
||||
onChanged: (val) {
|
||||
applyProgressionMode(path, val!);
|
||||
onChanged();
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
for (final mode in ProgressionMode.values)
|
||||
RadioListTile<ProgressionMode>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kPrimaryColor,
|
||||
value: mode,
|
||||
title: Text(mode.label, style: kTextBody),
|
||||
subtitle: Text(mode.description, style: kTextHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (progressionModeOf(path) == ProgressionMode.stepByStep)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: kSpace5),
|
||||
child: CheckInputContainer(
|
||||
label: "Masquer les étapes pas encore atteintes",
|
||||
subtitle:
|
||||
"Le visiteur ne découvre les étapes suivantes qu'au fur et à mesure.",
|
||||
isChecked: path.hideNextStepsUntilComplete ?? false,
|
||||
onChanged: (val) {
|
||||
path.hideNextStepsUntilComplete = val;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: kSpace3),
|
||||
NumberStepperField(
|
||||
label: "Durée estimée",
|
||||
value: path.estimatedDurationMinutes,
|
||||
min: 0,
|
||||
max: 600,
|
||||
unit: "min",
|
||||
onChanged: (val) {
|
||||
path.estimatedDurationMinutes = val.toInt();
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,473 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/l10n/app_localizations.dart';
|
||||
import 'package:manager_app/Components/check_input_container.dart';
|
||||
import 'package:manager_app/Components/confirmation_dialog.dart';
|
||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||
import 'package:manager_app/Components/number_input_container.dart';
|
||||
import 'package:manager_app/Components/resource_input_container.dart';
|
||||
|
||||
// `ResponseDTO.label` est en TranslationAndResourceDTO alors que
|
||||
// MultiStringInputContainer parle TranslationDTO : la ressource n'a pas de sens
|
||||
// sur une réponse de quiz, seul le texte circule.
|
||||
List<TranslationDTO> _toTranslationList(List<TranslationAndResourceDTO>? list) =>
|
||||
(list ?? [])
|
||||
.map((t) => TranslationDTO(language: t.language, value: t.value))
|
||||
.toList();
|
||||
|
||||
List<TranslationAndResourceDTO> _fromTranslationList(
|
||||
List<TranslationDTO> list) =>
|
||||
list
|
||||
.map((t) =>
|
||||
TranslationAndResourceDTO(language: t.language, value: t.value))
|
||||
.toList();
|
||||
|
||||
ResponseDTO _emptyResponse({bool isGood = false, int order = 0}) =>
|
||||
ResponseDTO(label: [], isGood: isGood, order: order);
|
||||
|
||||
/// Une question neuve, encore sans identifiant serveur (`id: 0`) : c'est le
|
||||
/// backend qui l'attribue quand l'étape qui la porte est enregistrée.
|
||||
QuizQuestion newQuizQuestion({required String stepId, required int order}) =>
|
||||
QuizQuestion(
|
||||
id: 0,
|
||||
label: [TranslationAndResourceDTO(language: 'FR', value: '')],
|
||||
responses: [],
|
||||
validationQuestionType: QuestionType.simple,
|
||||
order: order,
|
||||
guidedStepId: stepId,
|
||||
);
|
||||
|
||||
String questionTypeLabel(QuestionType? type) {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return "QCM";
|
||||
case QuestionType.puzzle:
|
||||
return "Puzzle";
|
||||
default:
|
||||
return "Réponse attendue";
|
||||
}
|
||||
}
|
||||
|
||||
String questionPreview(BuildContext context, QuizQuestion question, int index) {
|
||||
if (question.label.isEmpty) return "Question ${index + 1}";
|
||||
final fr = question.label.firstWhere(
|
||||
(t) => t.language == 'FR',
|
||||
orElse: () => question.label.first,
|
||||
);
|
||||
final value = fr.value ?? "";
|
||||
return value.trim().isEmpty ? "Question ${index + 1}" : value;
|
||||
}
|
||||
|
||||
/// Les champs d'une question, sans son contenant : ils s'affichent dépliés dans
|
||||
/// le panneau de l'étape, plus dans une fenêtre à part.
|
||||
class QuestionFields extends StatelessWidget {
|
||||
const QuestionFields({
|
||||
Key? key,
|
||||
required this.question,
|
||||
required this.onChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final QuizQuestion question;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
/// Le type « réponse attendue » se joue sur une réponse unique, toujours
|
||||
/// marquée bonne. Elle est créée à la volée si elle manque.
|
||||
void _ensureSimpleResponse() {
|
||||
if (question.responses.isEmpty) {
|
||||
question.responses.add(_emptyResponse(isGood: true, order: 0));
|
||||
}
|
||||
question.responses[0].isGood = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MultiStringInputContainer(
|
||||
label: l10n.questionAskedLabel,
|
||||
modalLabel: l10n.questionTitleLabel,
|
||||
initialValue: _toTranslationList(question.label),
|
||||
onGetResult: (val) {
|
||||
question.label = _fromTranslationList(val);
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 3,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
SizedBox(height: kSpace4),
|
||||
Text("Type de validation :", style: kLabelField),
|
||||
SizedBox(height: kSpace2),
|
||||
DropdownButton<QuestionType>(
|
||||
value: question.validationQuestionType,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: QuestionType.simple,
|
||||
child: Text("Simple (texte attendu)")),
|
||||
DropdownMenuItem(
|
||||
value: QuestionType.multipleChoice,
|
||||
child: Text("Choix multiples (QCM)")),
|
||||
DropdownMenuItem(value: QuestionType.puzzle, child: Text("Puzzle")),
|
||||
],
|
||||
onChanged: (val) {
|
||||
question.validationQuestionType = val;
|
||||
question.responses = [];
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
if (question.validationQuestionType == QuestionType.simple)
|
||||
..._simpleFields(context, l10n),
|
||||
if (question.validationQuestionType == QuestionType.multipleChoice)
|
||||
..._multipleChoiceFields(context, l10n),
|
||||
if (question.validationQuestionType == QuestionType.puzzle)
|
||||
..._puzzleFields(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _simpleFields(BuildContext context, AppLocalizations l10n) => [
|
||||
Divider(height: kSpace7, color: kLineSoft),
|
||||
Text(l10n.expectedAnswerLabel, style: kLabelField),
|
||||
SizedBox(height: kSpace3),
|
||||
Builder(builder: (_) {
|
||||
_ensureSimpleResponse();
|
||||
return MultiStringInputContainer(
|
||||
label: "",
|
||||
modalLabel: l10n.expectedAnswerModalLabel,
|
||||
initialValue: _toTranslationList(question.responses[0].label),
|
||||
onGetResult: (val) {
|
||||
question.responses[0].label = _fromTranslationList(val);
|
||||
question.responses[0].isGood = true;
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
);
|
||||
}),
|
||||
SizedBox(height: kSpace1),
|
||||
Text(l10n.validationNote,
|
||||
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
|
||||
SizedBox(height: kSpace3),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: kSurface2,
|
||||
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.dialpad, size: 18, color: kPrimaryColor),
|
||||
SizedBox(width: kSpace3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Si la réponse attendue ne contient que des chiffres, le visiteur "
|
||||
"voit automatiquement un pavé numérique façon cadenas (digicode) "
|
||||
"au lieu du champ texte. Rien à configurer.",
|
||||
style: kTextHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
List<Widget> _multipleChoiceFields(
|
||||
BuildContext context, AppLocalizations l10n) =>
|
||||
[
|
||||
Divider(height: kSpace7, color: kLineSoft),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(l10n.possibleAnswersLabel, style: kLabelField),
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.add_circle_outline, color: kSuccess),
|
||||
label: Text("Ajouter", style: TextStyle(color: kSuccess)),
|
||||
onPressed: () {
|
||||
question.responses
|
||||
.add(_emptyResponse(order: question.responses.length));
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (question.responses.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kSpace3),
|
||||
child: Text(l10n.noAnswerDefined,
|
||||
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: List.generate(question.responses.length, (i) {
|
||||
final resp = question.responses[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: kSpace3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace3, vertical: kSpace1),
|
||||
child: Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
message: resp.isGood == true
|
||||
? l10n.correctAnswer
|
||||
: l10n.wrongAnswer,
|
||||
child: Checkbox(
|
||||
value: resp.isGood ?? false,
|
||||
activeColor: kSuccess,
|
||||
onChanged: (val) {
|
||||
resp.isGood = val;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "${l10n.answerLabel} ${i + 1} :",
|
||||
modalLabel: "${l10n.answerLabel} ${i + 1}",
|
||||
initialValue: _toTranslationList(resp.label),
|
||||
onGetResult: (val) {
|
||||
resp.label = _fromTranslationList(val);
|
||||
onChanged();
|
||||
},
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: kError, size: 20),
|
||||
onPressed: () {
|
||||
question.responses.removeAt(i);
|
||||
for (var j = 0; j < question.responses.length; j++) {
|
||||
question.responses[j].order = j;
|
||||
}
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
SizedBox(height: kSpace1),
|
||||
Text(l10n.answerNote,
|
||||
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
|
||||
];
|
||||
|
||||
List<Widget> _puzzleFields(BuildContext context) => [
|
||||
Divider(height: kSpace7, color: kLineSoft),
|
||||
Text("Configuration du Puzzle", style: kLabelField),
|
||||
SizedBox(height: kSpace4),
|
||||
ResourceInputContainer(
|
||||
label: "Image du puzzle :",
|
||||
initialValue: question.puzzleImageId,
|
||||
onChanged: (res) {
|
||||
question.puzzleImageId = res.id;
|
||||
question.puzzleImage = Resource.fromJson(res.toJson());
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
SizedBox(height: kSpace4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NumberInputContainer(
|
||||
label: "Lignes :",
|
||||
initialValue: question.puzzleRows ?? 3,
|
||||
onChanged: (val) {
|
||||
question.puzzleRows = int.tryParse(val) ?? 3;
|
||||
onChanged();
|
||||
},
|
||||
isSmall: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: kSpace7),
|
||||
Expanded(
|
||||
child: NumberInputContainer(
|
||||
label: "Colonnes :",
|
||||
initialValue: question.puzzleCols ?? 3,
|
||||
onChanged: (val) {
|
||||
question.puzzleCols = int.tryParse(val) ?? 3;
|
||||
onChanged();
|
||||
},
|
||||
isSmall: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: kSpace3),
|
||||
CheckInputContainer(
|
||||
label: "Puzzle glissant (Sliding) :",
|
||||
isChecked: question.isSlidingPuzzle ?? false,
|
||||
onChanged: (val) {
|
||||
question.isSlidingPuzzle = val;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// La liste des questions d'une étape : chaque ligne se déplie sur place au
|
||||
/// lieu d'ouvrir une fenêtre — c'est ce qui ramène la profondeur à 2.
|
||||
class QuestionsBlock extends StatefulWidget {
|
||||
const QuestionsBlock({
|
||||
Key? key,
|
||||
required this.questions,
|
||||
required this.stepId,
|
||||
required this.onChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final List<QuizQuestion> questions;
|
||||
final String stepId;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
State<QuestionsBlock> createState() => _QuestionsBlockState();
|
||||
}
|
||||
|
||||
class _QuestionsBlockState extends State<QuestionsBlock> {
|
||||
QuizQuestion? _expanded;
|
||||
|
||||
void _add() {
|
||||
final question = newQuizQuestion(
|
||||
stepId: widget.stepId,
|
||||
order: widget.questions.length,
|
||||
);
|
||||
setState(() {
|
||||
widget.questions.add(question);
|
||||
_expanded = question;
|
||||
});
|
||||
widget.onChanged();
|
||||
}
|
||||
|
||||
void _delete(QuizQuestion question) {
|
||||
showConfirmationDialog(
|
||||
"Supprimer cette question ?",
|
||||
() {},
|
||||
() {
|
||||
setState(() {
|
||||
widget.questions.remove(question);
|
||||
for (var i = 0; i < widget.questions.length; i++) {
|
||||
widget.questions[i].order = i;
|
||||
}
|
||||
if (_expanded == question) _expanded = null;
|
||||
});
|
||||
widget.onChanged();
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.questions.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: kSpace3),
|
||||
child: Text(AppLocalizations.of(context)!.noQuestionsConfigured,
|
||||
style: kTextHint.copyWith(fontStyle: FontStyle.italic)),
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < widget.questions.length; i++)
|
||||
_row(context, i, widget.questions[i]),
|
||||
SizedBox(height: kSpace3),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: Icon(Icons.add, size: 18, color: kPrimaryColor),
|
||||
label: Text(AppLocalizations.of(context)!.addQuestion,
|
||||
style: TextStyle(color: kPrimaryColor)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: kLine),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kRadiusCard)),
|
||||
),
|
||||
onPressed: _add,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(BuildContext context, int index, QuizQuestion question) {
|
||||
final isOpen = _expanded == question;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: kSpace2),
|
||||
decoration: BoxDecoration(
|
||||
color: isOpen ? kSurface2 : kSurface,
|
||||
border: Border.all(color: isOpen ? kPrimaryColor : kLineSoft),
|
||||
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _expanded = isOpen ? null : question),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(11, 9, 5, 9),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
(index + 1).toString().padLeft(2, '0'),
|
||||
style: kOverlineMono,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: HtmlWidget(
|
||||
questionPreview(context, question, index),
|
||||
textStyle: kTextBody,
|
||||
),
|
||||
),
|
||||
SizedBox(width: kSpace3),
|
||||
Text(questionTypeLabel(question.validationQuestionType),
|
||||
style: kTextHint),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 18, color: kError),
|
||||
tooltip: "Supprimer",
|
||||
onPressed: () => _delete(question),
|
||||
),
|
||||
Icon(isOpen ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20, color: kInk3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOpen)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(11, 0, 11, 13),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(height: kSpace4, color: kLine),
|
||||
QuestionFields(
|
||||
key: ObjectKey(question),
|
||||
question: question,
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
widget.onChanged();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/client.dart';
|
||||
|
||||
/// Façade unique sur les deux jeux d'endpoints de parcours.
|
||||
///
|
||||
/// Un parcours vit sous une `SectionParcours` ou sous une `SectionMap` (les
|
||||
/// sections d'événement passent aussi par la seconde). Les opérations sont
|
||||
/// identiques des deux côtés, seule la classe générée change : l'éditeur n'a
|
||||
/// pas à connaître ce détail.
|
||||
///
|
||||
/// Il n'y a volontairement pas d'endpoint de question : les questions partent
|
||||
/// avec leur étape, `GuidedStep.FromDTO` les synchronise côté serveur.
|
||||
class GuidedPathApi {
|
||||
GuidedPathApi(this._client, {required this.isParcours});
|
||||
|
||||
final Client _client;
|
||||
final bool isParcours;
|
||||
|
||||
SectionParcoursApi get _parcours => _client.sectionParcoursApi!;
|
||||
SectionMapApi get _map => _client.sectionMapApi!;
|
||||
|
||||
Future<GuidedPathDTO?> createPath(String sectionId, GuidedPathDTO path) =>
|
||||
isParcours
|
||||
? _parcours.sectionParcoursCreateGuidedPath(sectionId, path)
|
||||
: _map.sectionMapCreateGuidedPath(sectionId, path);
|
||||
|
||||
Future<GuidedPathDTO?> updatePath(GuidedPathDTO path) => isParcours
|
||||
? _parcours.sectionParcoursUpdateGuidedPath(path)
|
||||
: _map.sectionMapUpdateGuidedPath(path);
|
||||
|
||||
Future<GuidedStepDTO?> createStep(String pathId, GuidedStepDTO step) =>
|
||||
isParcours
|
||||
? _parcours.sectionParcoursCreateGuidedStep(pathId, step)
|
||||
: _map.sectionMapCreateGuidedStep(pathId, step);
|
||||
|
||||
Future<GuidedStepDTO?> updateStep(GuidedStepDTO step) => isParcours
|
||||
? _parcours.sectionParcoursUpdateGuidedStep(step)
|
||||
: _map.sectionMapUpdateGuidedStep(step);
|
||||
|
||||
Future<void> deleteStep(String stepId) => isParcours
|
||||
? _parcours.sectionParcoursDeleteGuidedStep(stepId)
|
||||
: _map.sectionMapDeleteGuidedStep(stepId);
|
||||
}
|
||||
@ -0,0 +1,785 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/l10n/app_localizations.dart';
|
||||
import 'package:manager_app/Components/confirmation_dialog.dart';
|
||||
import 'package:manager_app/Components/message_notification.dart';
|
||||
import 'package:manager_app/Components/rounded_button.dart';
|
||||
import 'Fields/etape_fields.dart';
|
||||
import 'Fields/parcours_fields.dart';
|
||||
import 'guided_path_api.dart';
|
||||
|
||||
/// Éditeur de parcours — une seule fenêtre, un rail d'étapes toujours visible.
|
||||
///
|
||||
/// Remplace l'empilement « popup parcours → popup étape → popup question » :
|
||||
/// le rail à gauche liste le parcours et ses étapes, le panneau de droite
|
||||
/// affiche celui qu'on a choisi, et une question se déplie dans le panneau.
|
||||
/// La profondeur maximale retombe à 2 — cette fenêtre, puis la traduction.
|
||||
///
|
||||
/// Rien n'attend de bouton « Sauvegarder » : chaque modification part au
|
||||
/// serveur (parcours, étape, questions avec leur étape). C'est ce qui rend le
|
||||
/// garde-fou d'abandon inutile — il n'y a plus de travail à perdre.
|
||||
Future<GuidedPathDTO?> showGuidedPathEditor(
|
||||
BuildContext context, {
|
||||
required GuidedPathDTO? path,
|
||||
required String sectionId,
|
||||
required String? instanceId,
|
||||
required bool isEvent,
|
||||
required bool isParcours,
|
||||
required bool isGeolocated,
|
||||
required GuidedPathApi api,
|
||||
required int newPathOrder,
|
||||
}) {
|
||||
return showDialog<GuidedPathDTO>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => GuidedPathEditor(
|
||||
initialPath: path,
|
||||
sectionId: sectionId,
|
||||
instanceId: instanceId,
|
||||
isEvent: isEvent,
|
||||
isParcours: isParcours,
|
||||
isGeolocated: isGeolocated,
|
||||
api: api,
|
||||
newPathOrder: newPathOrder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
enum _SaveStatus { idle, pending, saving, saved, failed }
|
||||
|
||||
class GuidedPathEditor extends StatefulWidget {
|
||||
const GuidedPathEditor({
|
||||
Key? key,
|
||||
required this.initialPath,
|
||||
required this.sectionId,
|
||||
required this.instanceId,
|
||||
required this.isEvent,
|
||||
required this.isParcours,
|
||||
required this.isGeolocated,
|
||||
required this.api,
|
||||
required this.newPathOrder,
|
||||
}) : super(key: key);
|
||||
|
||||
final GuidedPathDTO? initialPath;
|
||||
final String sectionId;
|
||||
final String? instanceId;
|
||||
final bool isEvent;
|
||||
final bool isParcours;
|
||||
final bool isGeolocated;
|
||||
final GuidedPathApi api;
|
||||
final int newPathOrder;
|
||||
|
||||
@override
|
||||
State<GuidedPathEditor> createState() => _GuidedPathEditorState();
|
||||
}
|
||||
|
||||
class _GuidedPathEditorState extends State<GuidedPathEditor> {
|
||||
static const Duration _debounceDelay = Duration(milliseconds: 700);
|
||||
|
||||
late GuidedPathDTO _path;
|
||||
|
||||
/// `null` = le panneau du parcours lui-même.
|
||||
GuidedStepDTO? _selected;
|
||||
|
||||
Timer? _debounce;
|
||||
Future<void>? _inFlight;
|
||||
bool _flushing = false;
|
||||
_SaveStatus _status = _SaveStatus.idle;
|
||||
|
||||
bool _pathDirty = false;
|
||||
final Set<GuidedStepDTO> _dirtySteps = Set.identity();
|
||||
final List<String> _pendingDeletes = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Copie de travail : l'appelant relit la liste depuis l'API à la fermeture,
|
||||
// ce qui évite d'afficher un état local qu'un enregistrement aurait raté.
|
||||
_path = widget.initialPath != null
|
||||
? GuidedPathDTO.fromJson(jsonDecode(jsonEncode(widget.initialPath)))!
|
||||
: GuidedPathDTO(
|
||||
title: [],
|
||||
description: [],
|
||||
steps: [],
|
||||
order: widget.newPathOrder,
|
||||
// Doivent correspondre au mode que `progressionModeOf` affiche par
|
||||
// défaut (Dans l'ordre) : laissés nuls, la radio montrait « Dans
|
||||
// l'ordre » et la sauvegarde enregistrait « Libre ».
|
||||
isLinear: true,
|
||||
requireSuccessToAdvance: false,
|
||||
hideNextStepsUntilComplete: false,
|
||||
);
|
||||
_path.steps ??= [];
|
||||
_path.steps!.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<GuidedStepDTO> get _steps => _path.steps!;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Enregistrement au fil de l'eau
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
void _schedule({bool immediate = false}) {
|
||||
_debounce?.cancel();
|
||||
setState(() => _status = _SaveStatus.pending);
|
||||
if (immediate) {
|
||||
_flush();
|
||||
} else {
|
||||
_debounce = Timer(_debounceDelay, _flush);
|
||||
}
|
||||
}
|
||||
|
||||
/// Un seul enregistrement à la fois : tant qu'un est parti, on rend sa propre
|
||||
/// promesse plutôt qu'une promesse déjà tenue — sinon la fermeture passerait
|
||||
/// devant lui sans savoir s'il a abouti.
|
||||
Future<void> _flush() {
|
||||
if (_flushing) return _inFlight ?? Future.value();
|
||||
_inFlight = _writePending();
|
||||
return _inFlight!;
|
||||
}
|
||||
|
||||
/// Purge complète : le premier passage laisse finir ce qui était parti, le
|
||||
/// second écrit ce qui est arrivé pendant ce temps.
|
||||
Future<void> _flushNow() async {
|
||||
_debounce?.cancel();
|
||||
await _flush();
|
||||
await _flush();
|
||||
}
|
||||
|
||||
void _markPathDirty({bool immediate = false}) {
|
||||
_pathDirty = true;
|
||||
setState(() {});
|
||||
_schedule(immediate: immediate);
|
||||
}
|
||||
|
||||
void _markStepDirty(GuidedStepDTO step, {bool immediate = false}) {
|
||||
_dirtySteps.add(step);
|
||||
setState(() {});
|
||||
_schedule(immediate: immediate);
|
||||
}
|
||||
|
||||
bool get _hasWork =>
|
||||
_pathDirty || _dirtySteps.isNotEmpty || _pendingDeletes.isNotEmpty;
|
||||
|
||||
Future<void> _writePending() async {
|
||||
if (!_hasWork) return;
|
||||
_flushing = true;
|
||||
if (mounted) setState(() => _status = _SaveStatus.saving);
|
||||
|
||||
try {
|
||||
while (_hasWork) {
|
||||
await _ensurePathExists();
|
||||
|
||||
while (_pendingDeletes.isNotEmpty) {
|
||||
final id = _pendingDeletes.first;
|
||||
await widget.api.deleteStep(id);
|
||||
_pendingDeletes.remove(id);
|
||||
}
|
||||
|
||||
while (_dirtySteps.isNotEmpty) {
|
||||
final step = _dirtySteps.first;
|
||||
_dirtySteps.remove(step);
|
||||
try {
|
||||
await _persistStep(step);
|
||||
} catch (_) {
|
||||
_dirtySteps.add(step);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (_pathDirty) {
|
||||
_pathDirty = false;
|
||||
try {
|
||||
await widget.api.updatePath(_pathPayload());
|
||||
} catch (_) {
|
||||
_pathDirty = true;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _status = _SaveStatus.saved);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _status = _SaveStatus.failed);
|
||||
} finally {
|
||||
_flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensurePathExists() async {
|
||||
if (_path.id != null) return;
|
||||
|
||||
_path.instanceId = widget.instanceId;
|
||||
if (widget.isParcours) {
|
||||
_path.sectionParcoursId = widget.sectionId;
|
||||
} else if (widget.isEvent) {
|
||||
_path.sectionEventId = widget.sectionId;
|
||||
} else {
|
||||
_path.sectionMapId = widget.sectionId;
|
||||
}
|
||||
_path.isLinear ??= false;
|
||||
_path.requireSuccessToAdvance ??= false;
|
||||
_path.hideNextStepsUntilComplete ??= false;
|
||||
|
||||
final created = await widget.api.createPath(widget.sectionId, _pathPayload());
|
||||
if (created?.id == null) {
|
||||
throw StateError("Le serveur n'a pas renvoyé d'identifiant de parcours");
|
||||
}
|
||||
_path.id = created!.id;
|
||||
_path.order = created.order ?? _path.order;
|
||||
_pathDirty = false;
|
||||
}
|
||||
|
||||
/// Le payload n'emporte que les étapes déjà connues du serveur : `UpdateGuidedPath`
|
||||
/// supprime celles qui manquent, et créerait un doublon de celles qui n'ont pas
|
||||
/// encore reçu leur identifiant par `CreateGuidedStep`.
|
||||
GuidedPathDTO _pathPayload() {
|
||||
final payload = GuidedPathDTO.fromJson(jsonDecode(jsonEncode(_path)))!;
|
||||
payload.steps =
|
||||
(payload.steps ?? []).where((s) => s.id != null).toList();
|
||||
for (final s in payload.steps!) {
|
||||
s.isStepTimer ??= false;
|
||||
s.isGeoTriggered ??= false;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
Future<void> _persistStep(GuidedStepDTO step) async {
|
||||
step.guidedPathId = _path.id;
|
||||
step.isStepTimer ??= false;
|
||||
step.isGeoTriggered ??= false;
|
||||
if (step.isGeoTriggered != true) step.zoneRadiusMeters = null;
|
||||
|
||||
final saved = step.id == null
|
||||
? await widget.api.createStep(_path.id!, step)
|
||||
: await widget.api.updateStep(step);
|
||||
if (saved == null) return;
|
||||
|
||||
step.id ??= saved.id;
|
||||
step.order = saved.order ?? step.order;
|
||||
_adoptQuestionIds(step, saved);
|
||||
}
|
||||
|
||||
/// Les questions n'ont pas d'endpoint propre : elles partent avec leur étape
|
||||
/// et le serveur leur attribue un entier. On le récupère par `order`, seul
|
||||
/// repère stable entre les deux listes.
|
||||
void _adoptQuestionIds(GuidedStepDTO local, GuidedStepDTO saved) {
|
||||
final savedQuestions = saved.quizQuestions ?? [];
|
||||
for (final question in local.quizQuestions ?? <QuizQuestion>[]) {
|
||||
if (question.id != 0) continue;
|
||||
final match = savedQuestions.where((q) => q.order == question.order);
|
||||
if (match.isNotEmpty) {
|
||||
question.id = match.first.id;
|
||||
question.guidedStepId = local.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Étapes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
void _addStep() {
|
||||
final step = GuidedStepDTO(
|
||||
title: [],
|
||||
description: [],
|
||||
quizQuestions: [],
|
||||
audioIds: [],
|
||||
contents: [],
|
||||
order: _steps.length,
|
||||
isStepTimer: false,
|
||||
isGeoTriggered: false,
|
||||
);
|
||||
setState(() {
|
||||
_steps.add(step);
|
||||
_selected = step;
|
||||
});
|
||||
_markStepDirty(step, immediate: true);
|
||||
}
|
||||
|
||||
void _deleteStep(GuidedStepDTO step) {
|
||||
showConfirmationDialog(
|
||||
AppLocalizations.of(context)!.deleteStepConfirm,
|
||||
() {},
|
||||
() {
|
||||
final index = _steps.indexOf(step);
|
||||
setState(() {
|
||||
_steps.remove(step);
|
||||
_dirtySteps.remove(step);
|
||||
if (_selected == step) _selected = null;
|
||||
});
|
||||
if (step.id != null) _pendingDeletes.add(step.id!);
|
||||
_reindexSteps(from: index);
|
||||
_schedule(immediate: true);
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
/// `onReorderItem` livre déjà un index cible corrigé du retrait.
|
||||
void _reorderStep(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final step = _steps.removeAt(oldIndex);
|
||||
_steps.insert(newIndex, step);
|
||||
});
|
||||
_reindexSteps(from: 0);
|
||||
_schedule(immediate: true);
|
||||
}
|
||||
|
||||
/// Renumérote les étapes et marque celles dont l'ordre a bougé.
|
||||
void _reindexSteps({required int from}) {
|
||||
for (var i = from; i < _steps.length; i++) {
|
||||
if (_steps[i].order != i) {
|
||||
_steps[i].order = i;
|
||||
_dirtySteps.add(_steps[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Fermeture
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
Future<void> _close() async {
|
||||
await _flushNow();
|
||||
if (!mounted) return;
|
||||
if (_status == _SaveStatus.failed) {
|
||||
// Fermer maintenant jetterait ce qui n'est pas encore parti : la fenêtre
|
||||
// reste ouverte, le pied de page porte le « Réessayer ».
|
||||
showNotification(kError, kWhite,
|
||||
AppLocalizations.of(context)!.saveStatusFailed, context, null);
|
||||
return;
|
||||
}
|
||||
Navigator.pop(context, _path.id == null ? null : _path);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Rendu
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
String _pathTitle(BuildContext context) {
|
||||
final title = _path.title ?? [];
|
||||
if (title.isEmpty) return AppLocalizations.of(context)!.pathNoTitle;
|
||||
final fr =
|
||||
title.firstWhere((t) => t.language == 'FR', orElse: () => title.first);
|
||||
final value = (fr.value ?? "").trim();
|
||||
return value.isEmpty ? AppLocalizations.of(context)!.pathNoTitle : value;
|
||||
}
|
||||
|
||||
String _stepTitle(BuildContext context, GuidedStepDTO step, int index) {
|
||||
final title = step.title ?? [];
|
||||
if (title.isNotEmpty) {
|
||||
final fr =
|
||||
title.firstWhere((t) => t.language == 'FR', orElse: () => title.first);
|
||||
final value = (fr.value ?? "").trim();
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return "${AppLocalizations.of(context)!.stepFallback} ${index + 1}";
|
||||
}
|
||||
|
||||
String _stepSummary(BuildContext context, GuidedStepDTO step) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final medias = step.contents?.length ?? 0;
|
||||
final questions = step.quizQuestions?.length ?? 0;
|
||||
final parts = [
|
||||
if (medias > 0) l10n.mediaCount(medias),
|
||||
if (questions > 0) l10n.questionCount(questions),
|
||||
];
|
||||
return parts.isEmpty ? l10n.stepSummaryEmpty : parts.join(" · ");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final double dialogWidth =
|
||||
size.width * 0.92 > 1180 ? 1180 : size.width * 0.92;
|
||||
|
||||
return PopScope(
|
||||
// `barrierDismissible: false` neutralise le clic hors dialog et Échap,
|
||||
// mais pas le retour arrière du navigateur : il doit passer par la même
|
||||
// fermeture, qui purge d'abord ce qui reste à écrire.
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _close();
|
||||
},
|
||||
child: Dialog(
|
||||
insetPadding: const EdgeInsets.all(kSpace7),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kRadiusShell)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: dialogWidth,
|
||||
height: size.height * 0.9,
|
||||
child: Column(
|
||||
children: [
|
||||
_breadcrumbBar(context),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_rail(context),
|
||||
Expanded(child: _panel(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_footer(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _breadcrumbBar(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final index = _selected == null ? -1 : _steps.indexOf(_selected!);
|
||||
final tail = _selected == null
|
||||
? l10n.pathPanelTitle
|
||||
: "${l10n.stepFallback} ${index + 1} · ${_stepTitle(context, _selected!, index)}";
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace6, vertical: kSpace4),
|
||||
decoration: BoxDecoration(
|
||||
color: kSurface2,
|
||||
border: Border(bottom: BorderSide(color: kLineSoft)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: HtmlWidget(_pathTitle(context), textStyle: kTitleCard),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace2),
|
||||
child: Icon(Icons.chevron_right, size: 16, color: kInk3),
|
||||
),
|
||||
Flexible(child: HtmlWidget(tail, textStyle: kTextSmall)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: kInk3),
|
||||
tooltip: l10n.close,
|
||||
onPressed: _close,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rail(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Container(
|
||||
width: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: kSurface2,
|
||||
border: Border(right: BorderSide(color: kLineSoft)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace6, kSpace5, kSpace4, kSpace4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.guidedPathsLabel.toUpperCase(), style: kOverlineMono),
|
||||
SizedBox(height: kSpace1),
|
||||
HtmlWidget(_pathTitle(context), textStyle: kTitleCard),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
|
||||
children: [
|
||||
_railTile(
|
||||
context,
|
||||
selected: _selected == null,
|
||||
pin: Icon(Icons.tune,
|
||||
size: 14,
|
||||
color: _selected == null ? kOnBrand : kInk3),
|
||||
title: l10n.pathPanelTitle,
|
||||
subtitle: l10n.pathPanelSubtitle,
|
||||
onTap: () => setState(() => _selected = null),
|
||||
),
|
||||
SizedBox(height: kSpace2),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _steps.length,
|
||||
onReorderItem: _reorderStep,
|
||||
itemBuilder: (context, index) {
|
||||
final step = _steps[index];
|
||||
final selected = identical(_selected, step);
|
||||
return Padding(
|
||||
key: ObjectKey(step),
|
||||
padding: const EdgeInsets.only(bottom: kSpace2),
|
||||
child: _railTile(
|
||||
context,
|
||||
selected: selected,
|
||||
pin: Text(
|
||||
"${index + 1}",
|
||||
style: kOverlineMono.copyWith(
|
||||
color: selected ? kOnBrand : kInk3),
|
||||
),
|
||||
title: _stepTitle(context, step, index),
|
||||
titleIsHtml: true,
|
||||
subtitle: _stepSummary(context, step),
|
||||
onTap: () => setState(() => _selected = step),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Icon(Icons.drag_indicator,
|
||||
size: 16, color: kInk3),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(36, kSpace2, 0, kSpace5),
|
||||
child: OutlinedButton.icon(
|
||||
icon: Icon(Icons.add, size: 16, color: kInk3),
|
||||
label: Text(l10n.addStep,
|
||||
style: kTextSmall.copyWith(color: kInk2)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
side: BorderSide(color: kLine),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kRadiusCard)),
|
||||
),
|
||||
onPressed: _addStep,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _railTile(
|
||||
BuildContext context, {
|
||||
required bool selected,
|
||||
required Widget pin,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
bool titleIsHtml = false,
|
||||
Widget? trailing,
|
||||
}) {
|
||||
return Material(
|
||||
color: selected ? kSurface : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace3, vertical: kSpace3),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||
border: Border.all(color: selected ? kLine : Colors.transparent),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? kPrimaryColor : kSurface,
|
||||
border: Border.all(color: selected ? kPrimaryColor : kLine),
|
||||
),
|
||||
child: pin,
|
||||
),
|
||||
SizedBox(width: kSpace4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
titleIsHtml
|
||||
? HtmlWidget(title,
|
||||
textStyle: kTextSmall.copyWith(
|
||||
color: selected ? kInk : kInk2,
|
||||
fontWeight: FontWeight.w600))
|
||||
: Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: kTextSmall.copyWith(
|
||||
color: selected ? kInk : kInk2,
|
||||
fontWeight: FontWeight.w600)),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: kTextHint),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panel(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final step = _selected;
|
||||
final index = step == null ? -1 : _steps.indexOf(step);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(kSpace7, kSpace6, kSpace7, kSpace8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step == null
|
||||
? l10n.pathPanelTitle
|
||||
: "${l10n.stepFallback} ${index + 1}",
|
||||
style: kTitleScreen,
|
||||
),
|
||||
SizedBox(height: kSpace1),
|
||||
Text(
|
||||
step == null
|
||||
? "Ce que le visiteur voit avant de commencer."
|
||||
: _stepSummary(context, step),
|
||||
style: kSubtitleCard,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (step != null)
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.delete_outline, size: 18, color: kError),
|
||||
label: Text(l10n.deleteStep, style: TextStyle(color: kError)),
|
||||
onPressed: () => _deleteStep(step),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: kSpace6),
|
||||
if (step == null)
|
||||
ParcoursFields(
|
||||
path: _path,
|
||||
onChanged: _markPathDirty,
|
||||
)
|
||||
else
|
||||
EtapeFields(
|
||||
key: ObjectKey(step),
|
||||
step: step,
|
||||
isEscapeMode: _path.isGameMode ?? false,
|
||||
isGeolocated: widget.isGeolocated,
|
||||
onChanged: () => _markStepDirty(step),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _footer(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
late final Widget indicator;
|
||||
switch (_status) {
|
||||
case _SaveStatus.idle:
|
||||
indicator = Text(l10n.saveStatusIdle, style: kTextHint);
|
||||
break;
|
||||
case _SaveStatus.pending:
|
||||
case _SaveStatus.saving:
|
||||
indicator = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: kSpace3),
|
||||
Text(l10n.saveStatusSaving, style: kTextSmall),
|
||||
],
|
||||
);
|
||||
break;
|
||||
case _SaveStatus.saved:
|
||||
indicator = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 14, color: kSuccess),
|
||||
SizedBox(width: kSpace3),
|
||||
Text(l10n.saveStatusSaved,
|
||||
style: kTextSmall.copyWith(
|
||||
color: kSuccess, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
break;
|
||||
case _SaveStatus.failed:
|
||||
indicator = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 14, color: kError),
|
||||
SizedBox(width: kSpace3),
|
||||
Text(l10n.saveStatusFailed,
|
||||
style: kTextSmall.copyWith(
|
||||
color: kError, fontWeight: FontWeight.w600)),
|
||||
SizedBox(width: kSpace3),
|
||||
TextButton(
|
||||
onPressed: () => _schedule(immediate: true),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace6, vertical: kSpace4),
|
||||
decoration: BoxDecoration(
|
||||
color: kSurface2,
|
||||
border: Border(top: BorderSide(color: kLineSoft)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
indicator,
|
||||
Spacer(),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: RoundedButton(
|
||||
text: l10n.close,
|
||||
press: _close,
|
||||
color: kPrimaryColor,
|
||||
fontSize: 14,
|
||||
horizontal: kSpace7,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<ParcoursConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre l'éditeur à fenêtre unique. Il enregistre au fil de la saisie : il
|
||||
/// n'y a donc rien à récupérer d'un callback, seulement la liste à relire à
|
||||
/// la fermeture pour repartir de l'état réel du serveur.
|
||||
Future<void> _openEditor(GuidedPathDTO? path) async {
|
||||
final appContext = Provider.of<AppContext>(context, listen: false);
|
||||
final managerContext = appContext.getContext() as ManagerAppContext;
|
||||
|
||||
await showGuidedPathEditor(
|
||||
context,
|
||||
path: path,
|
||||
sectionId: widget.parentId,
|
||||
instanceId: managerContext.instanceId,
|
||||
isEvent: widget.isEvent,
|
||||
isParcours: widget.isParcours,
|
||||
isGeolocated: widget.isGeolocated,
|
||||
api: GuidedPathApi(managerContext.clientAPI!,
|
||||
isParcours: widget.isParcours),
|
||||
newPathOrder: paths.length,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
await _loadFromApi();
|
||||
if (mounted) widget.onChanged(paths);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@ -79,58 +105,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
|
||||
ElevatedButton.icon(
|
||||
icon: Icon(Icons.add),
|
||||
label: Text(AppLocalizations.of(context)!.addPath),
|
||||
onPressed: () {
|
||||
final appContext =
|
||||
Provider.of<AppContext>(context, listen: false);
|
||||
showNewOrUpdateGuidedPath(
|
||||
context,
|
||||
null,
|
||||
widget.parentId,
|
||||
widget.isEvent,
|
||||
isGeolocated: widget.isGeolocated,
|
||||
(newPath) async {
|
||||
try {
|
||||
newPath.order = paths.length;
|
||||
newPath.instanceId =
|
||||
(appContext.getContext() as ManagerAppContext)
|
||||
.instanceId;
|
||||
if (widget.isParcours) {
|
||||
newPath.sectionParcoursId = widget.parentId;
|
||||
} else if (widget.isEvent) {
|
||||
newPath.sectionEventId = widget.parentId;
|
||||
} else {
|
||||
newPath.sectionMapId = widget.parentId;
|
||||
}
|
||||
|
||||
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;
|
||||
final createdPath = widget.isParcours
|
||||
? await clientAPI.sectionParcoursApi!
|
||||
.sectionParcoursCreateGuidedPath(widget.parentId, newPath)
|
||||
: await clientAPI.sectionMapApi!
|
||||
.sectionMapCreateGuidedPath(widget.parentId, newPath);
|
||||
|
||||
if (createdPath != null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
paths.add(createdPath);
|
||||
widget.onChanged(paths);
|
||||
});
|
||||
}
|
||||
showNotification(kSuccess, kWhite,
|
||||
AppLocalizations.of(context)!.pathCreatedSuccess, context, null);
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification(
|
||||
kError,
|
||||
kWhite,
|
||||
AppLocalizations.of(context)!.pathCreateError,
|
||||
context,
|
||||
null);
|
||||
rethrow; // Important so showNewOrUpdateGuidedPath knows it failed
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onPressed: () => _openEditor(null),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: kSuccess, foregroundColor: kWhite),
|
||||
),
|
||||
@ -198,48 +173,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit, color: kPrimaryColor),
|
||||
onPressed: () {
|
||||
final appContext = Provider.of<AppContext>(
|
||||
context,
|
||||
listen: false);
|
||||
showNewOrUpdateGuidedPath(
|
||||
context,
|
||||
path,
|
||||
widget.parentId,
|
||||
widget.isEvent,
|
||||
isGeolocated: widget.isGeolocated,
|
||||
(updatedPath) async {
|
||||
try {
|
||||
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;
|
||||
final result = widget.isParcours
|
||||
? await clientAPI.sectionParcoursApi!.sectionParcoursUpdateGuidedPath(updatedPath)
|
||||
: await clientAPI.sectionMapApi!.sectionMapUpdateGuidedPath(updatedPath);
|
||||
if (result != null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
paths[index] = result;
|
||||
widget.onChanged(paths);
|
||||
});
|
||||
}
|
||||
showNotification(
|
||||
kSuccess,
|
||||
kWhite,
|
||||
AppLocalizations.of(context)!.pathUpdatedSuccess,
|
||||
context,
|
||||
null);
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification(
|
||||
kError,
|
||||
kWhite,
|
||||
AppLocalizations.of(context)!.pathUpdateError,
|
||||
context,
|
||||
null);
|
||||
rethrow;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onPressed: () => _openEditor(path),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete, color: kError),
|
||||
|
||||
@ -1,524 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/l10n/app_localizations.dart';
|
||||
import 'package:manager_app/Components/confirmation_dialog.dart';
|
||||
import 'package:manager_app/Components/rounded_button.dart';
|
||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||
import 'package:manager_app/Components/check_input_container.dart';
|
||||
import 'package:manager_app/Components/number_stepper_field.dart';
|
||||
import 'package:manager_app/Components/resource_input_container.dart';
|
||||
import 'package:manager_app/Components/reorderable_custom_list.dart';
|
||||
import 'package:manager_app/Components/section_card.dart';
|
||||
import 'progression_mode.dart';
|
||||
import 'showNewOrUpdateGuidedStep.dart';
|
||||
|
||||
void showNewOrUpdateGuidedPath(
|
||||
BuildContext context,
|
||||
GuidedPathDTO? path,
|
||||
String parentId,
|
||||
bool isEvent,
|
||||
FutureOr<void> Function(GuidedPathDTO) onSave, {
|
||||
bool isGeolocated = true,
|
||||
}) {
|
||||
GuidedPathDTO workingPath = path != null
|
||||
? GuidedPathDTO.fromJson(jsonDecode(jsonEncode(path)))!
|
||||
: GuidedPathDTO(
|
||||
title: [],
|
||||
description: [],
|
||||
steps: [],
|
||||
order: 0,
|
||||
// Doivent correspondre au mode que `progressionModeOf` affiche par
|
||||
// défaut (Dans l'ordre) : laissés nuls, la radio montrait « Dans
|
||||
// l'ordre » et la sauvegarde enregistrait « Libre ».
|
||||
isLinear: true,
|
||||
requireSuccessToAdvance: false,
|
||||
hideNextStepsUntilComplete: false,
|
||||
);
|
||||
|
||||
bool isSaving = false;
|
||||
int stepsRevision = 0;
|
||||
|
||||
// Instantané de référence pour détecter les modifications non enregistrées.
|
||||
// Pris après la première frame : le corps du dialog normalise certains champs
|
||||
// pendant sa construction, et ces écritures ne sont pas des saisies.
|
||||
String? baseline;
|
||||
bool hasUnsavedChanges() =>
|
||||
baseline != null && jsonEncode(workingPath) != baseline;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
if (baseline == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => baseline = jsonEncode(workingPath));
|
||||
}
|
||||
|
||||
void closeWithConfirmation() {
|
||||
if (!hasUnsavedChanges()) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
showConfirmationDialog(
|
||||
AppLocalizations.of(context)!.discardPathChangesConfirm,
|
||||
() {},
|
||||
// Les deux dialogs partagent le même Navigator : popper ici
|
||||
// fermerait la confirmation, pas l'éditeur. On attend donc que
|
||||
// showConfirmationDialog ait retiré sa propre route.
|
||||
() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
final double screenHeight = MediaQuery.of(context).size.height;
|
||||
final double dialogWidth = screenWidth * 0.82;
|
||||
// contentWidth = dialogWidth minus the 24px padding on each side
|
||||
final double contentWidth = dialogWidth - 48;
|
||||
final double halfWidth = (contentWidth - 20) / 2;
|
||||
|
||||
return PopScope(
|
||||
// `barrierDismissible: false` neutralise déjà le clic hors dialog et
|
||||
// la touche Échap, mais pas le retour arrière du navigateur — sur une
|
||||
// app web c'est le vrai chemin par lequel la saisie disparaît.
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) closeWithConfirmation();
|
||||
},
|
||||
child: Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
constraints: BoxConstraints(maxHeight: screenHeight * 0.88),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// -- Titre du dialog --
|
||||
Text(
|
||||
path == null ? "Nouveau Parcours" : "Modifier le Parcours",
|
||||
style: TextStyle(
|
||||
color: kPrimaryColor,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// -- Corps scrollable --
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image du parcours
|
||||
ResourceInputContainer(
|
||||
label: "Image :",
|
||||
color: kPrimaryColor,
|
||||
initialValue: workingPath.imageResourceId,
|
||||
onChanged: (resourceDTO) => setState(
|
||||
() => workingPath.imageResourceId = resourceDTO.id),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// Titre + Description côte à côte
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: halfWidth,
|
||||
child: MultiStringInputContainer(
|
||||
label: "Titre :",
|
||||
modalLabel: "Titre du parcours",
|
||||
initialValue: workingPath.title ?? [],
|
||||
onGetResult: (val) =>
|
||||
setState(() => workingPath.title = val),
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20),
|
||||
SizedBox(
|
||||
width: halfWidth,
|
||||
child: MultiStringInputContainer(
|
||||
label: "Description :",
|
||||
modalLabel: "Description du parcours",
|
||||
initialValue: workingPath.description ?? [],
|
||||
onGetResult: (val) => setState(
|
||||
() => workingPath.description = val),
|
||||
maxLines: 1,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// Mode jeu
|
||||
SectionCard(
|
||||
icon: Icons.sports_esports,
|
||||
title: "Ambiance",
|
||||
subtitle: "Visite classique ou jeu",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Quelle ambiance ?",
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
RadioGroup<bool>(
|
||||
groupValue: workingPath.isGameMode ?? false,
|
||||
onChanged: (val) => setState(
|
||||
() => workingPath.isGameMode = val),
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<bool>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kPrimaryColor,
|
||||
value: false,
|
||||
title: Text("Visite",
|
||||
style: TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
"Parcours de découverte : le visiteur avance à son rythme.",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
RadioListTile<bool>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kPrimaryColor,
|
||||
value: true,
|
||||
title: Text("Jeu",
|
||||
style: TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
"Escape game ou chasse au trésor : messages de début et de fin, vocabulaire de jeu sur les étapes.",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (workingPath.isGameMode == true) ...[
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Message de début :",
|
||||
modalLabel: "Message d'introduction du jeu",
|
||||
color: kPrimaryColor,
|
||||
initialValue: (workingPath.gameMessageDebut ?? [])
|
||||
.map((t) => TranslationDTO(language: t.language, value: t.value))
|
||||
.toList(),
|
||||
onGetResult: (val) {
|
||||
setState(() {
|
||||
workingPath.gameMessageDebut = val.map((t) {
|
||||
final prev = (workingPath.gameMessageDebut ?? []).firstWhere(
|
||||
(e) => e.language == t.language,
|
||||
orElse: () => TranslationAndResourceDTO(),
|
||||
);
|
||||
return TranslationAndResourceDTO(
|
||||
language: t.language,
|
||||
value: t.value,
|
||||
resourceId: prev.resourceId,
|
||||
resource: prev.resource,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
},
|
||||
maxLines: 2,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Message de fin :",
|
||||
modalLabel: "Message de félicitations du jeu",
|
||||
color: kPrimaryColor,
|
||||
initialValue: (workingPath.gameMessageFin ?? [])
|
||||
.map((t) => TranslationDTO(language: t.language, value: t.value))
|
||||
.toList(),
|
||||
onGetResult: (val) {
|
||||
setState(() {
|
||||
workingPath.gameMessageFin = val.map((t) {
|
||||
final prev = (workingPath.gameMessageFin ?? []).firstWhere(
|
||||
(e) => e.language == t.language,
|
||||
orElse: () => TranslationAndResourceDTO(),
|
||||
);
|
||||
return TranslationAndResourceDTO(
|
||||
language: t.language,
|
||||
value: t.value,
|
||||
resourceId: prev.resourceId,
|
||||
resource: prev.resource,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
},
|
||||
maxLines: 2,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// Options
|
||||
SectionCard(
|
||||
icon: Icons.settings,
|
||||
title: "Options du parcours",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Comment le visiteur progresse-t-il ?",
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
RadioGroup<ProgressionMode>(
|
||||
groupValue: progressionModeOf(workingPath),
|
||||
onChanged: (val) => setState(() =>
|
||||
applyProgressionMode(workingPath, val!)),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final mode in ProgressionMode.values)
|
||||
RadioListTile<ProgressionMode>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: kPrimaryColor,
|
||||
value: mode,
|
||||
title: Text(mode.label,
|
||||
style: TextStyle(fontSize: 14)),
|
||||
subtitle: Text(mode.description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (progressionModeOf(workingPath) ==
|
||||
ProgressionMode.stepByStep)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: CheckInputContainer(
|
||||
label: "Masquer les étapes pas encore atteintes",
|
||||
subtitle:
|
||||
"Le visiteur ne découvre les étapes suivantes qu'au fur et à mesure.",
|
||||
isChecked:
|
||||
workingPath.hideNextStepsUntilComplete ??
|
||||
false,
|
||||
onChanged: (val) => setState(() => workingPath
|
||||
.hideNextStepsUntilComplete = val),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
NumberStepperField(
|
||||
label: "Durée estimée",
|
||||
value: workingPath.estimatedDurationMinutes,
|
||||
min: 0,
|
||||
max: 600,
|
||||
unit: "min",
|
||||
onChanged: (val) => setState(() =>
|
||||
workingPath.estimatedDurationMinutes = val.toInt()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// Étapes
|
||||
SectionCard(
|
||||
icon: Icons.list_alt,
|
||||
title: AppLocalizations.of(context)!.pathStepsLabel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.add_circle_outline,
|
||||
color: kSuccess),
|
||||
onPressed: () {
|
||||
showNewOrUpdateGuidedStep(
|
||||
context,
|
||||
null,
|
||||
workingPath.id ?? "temp",
|
||||
workingPath.isGameMode ?? false,
|
||||
isGeolocated: isGeolocated,
|
||||
(newStep) async {
|
||||
setState(() {
|
||||
newStep.order =
|
||||
workingPath.steps?.length ?? 0;
|
||||
workingPath.steps = [
|
||||
...(workingPath.steps ?? []),
|
||||
newStep
|
||||
];
|
||||
stepsRevision++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (workingPath.steps?.isEmpty ?? true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.noStepConfigured,
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
)
|
||||
else
|
||||
ReorderableCustomList<GuidedStepDTO>(
|
||||
key: ValueKey(stepsRevision),
|
||||
items: workingPath.steps!,
|
||||
shrinkWrap: true,
|
||||
onChanged: (updatedList) {
|
||||
setState(() {
|
||||
for (var i = 0; i < updatedList.length; i++) {
|
||||
updatedList[i].order = i;
|
||||
}
|
||||
workingPath.steps = List.from(updatedList);
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index, step) {
|
||||
return ListTile(
|
||||
leading:
|
||||
CircleAvatar(child: Text("${index + 1}")),
|
||||
title: HtmlWidget(
|
||||
step.title != null && step.title!.isNotEmpty
|
||||
? step.title!
|
||||
.firstWhere(
|
||||
(t) => t.language == 'FR',
|
||||
orElse: () =>
|
||||
step.title![0])
|
||||
.value ??
|
||||
"${AppLocalizations.of(context)!.stepFallback} $index"
|
||||
: "${AppLocalizations.of(context)!.stepFallback} $index",
|
||||
),
|
||||
subtitle: workingPath.isGameMode ?? false
|
||||
? Text(
|
||||
"${step.quizQuestions?.length ?? 0} question(s)")
|
||||
: null,
|
||||
);
|
||||
},
|
||||
actions: [
|
||||
(context, index, step) => IconButton(
|
||||
icon: Icon(Icons.edit,
|
||||
color: kPrimaryColor),
|
||||
onPressed: () {
|
||||
showNewOrUpdateGuidedStep(
|
||||
context,
|
||||
step,
|
||||
workingPath.id ?? "temp",
|
||||
workingPath.isGameMode ?? false,
|
||||
isGeolocated: isGeolocated,
|
||||
(updatedStep) async {
|
||||
setState(() {
|
||||
updatedStep.order = step.order;
|
||||
workingPath.steps![index] =
|
||||
updatedStep;
|
||||
stepsRevision++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
(context, index, step) => IconButton(
|
||||
icon: Icon(Icons.delete, color: kError),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
workingPath.steps!.removeAt(index);
|
||||
stepsRevision++;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// -- Boutons --
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: RoundedButton(
|
||||
text: "Annuler",
|
||||
press: closeWithConfirmation,
|
||||
color: kSecond,
|
||||
fontSize: 15,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: RoundedButton(
|
||||
text: isSaving ? "Sauvegarde..." : "Sauvegarder",
|
||||
icon: isSaving ? Icons.hourglass_empty : null,
|
||||
press: () async {
|
||||
if (isSaving) return;
|
||||
setState(() => isSaving = true);
|
||||
// Initialise les booleans null → false
|
||||
workingPath.isLinear ??= false;
|
||||
workingPath.requireSuccessToAdvance ??= false;
|
||||
workingPath.hideNextStepsUntilComplete ??= false;
|
||||
// Initialise les booleans nuls dans chaque étape
|
||||
for (final s in workingPath.steps ?? []) {
|
||||
s.isStepTimer ??= false;
|
||||
}
|
||||
try {
|
||||
await onSave(workingPath);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
setState(() => isSaving = false);
|
||||
}
|
||||
},
|
||||
color: kPrimaryColor,
|
||||
fontSize: 15,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1,555 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/Components/confirmation_dialog.dart';
|
||||
import 'package:manager_app/Components/geometry_input_container.dart';
|
||||
import 'package:manager_app/Components/number_stepper_field.dart';
|
||||
import 'package:manager_app/Components/reorderable_custom_list.dart';
|
||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||
import 'package:manager_app/Components/rounded_button.dart';
|
||||
import 'package:manager_app/Components/section_card.dart';
|
||||
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/listView_card_image.dart';
|
||||
import 'package:manager_app/Screens/Configurations/Section/SubSection/Slider/new_update_image_slider.dart';
|
||||
import 'package:manager_app/app_context.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'showNewOrUpdateQuizQuestion.dart';
|
||||
|
||||
/// Médias acceptés sur une étape de parcours. Le PDF s'ajoute aux types du
|
||||
/// slider : une étape peut porter un plan, une partition, un fac-similé.
|
||||
/// Le rendu visiteur correspondant vit dans `CachedCustomResource`
|
||||
/// (mymuseum-visitapp) et `ResourceViewer.tsx` (visitapp-web).
|
||||
const kGuidedStepResourceTypes = <ResourceType>[
|
||||
...kSliderContentResourceTypes,
|
||||
ResourceType.Pdf,
|
||||
];
|
||||
|
||||
void showNewOrUpdateGuidedStep(
|
||||
BuildContext context,
|
||||
GuidedStepDTO? step,
|
||||
String pathId,
|
||||
bool isEscapeMode,
|
||||
FutureOr<void> Function(GuidedStepDTO) onSave, {
|
||||
bool isGeolocated = true,
|
||||
}) {
|
||||
// Use jsonEncode/jsonDecode for a robust deep copy that handles nested DTOs correctly
|
||||
GuidedStepDTO workingStep = step != null
|
||||
? GuidedStepDTO.fromJson(jsonDecode(jsonEncode(step)))!
|
||||
: GuidedStepDTO(
|
||||
title: [],
|
||||
description: [],
|
||||
quizQuestions: [],
|
||||
audioIds: [],
|
||||
contents: [],
|
||||
order: 0,
|
||||
);
|
||||
workingStep.audioIds = List.from(workingStep.audioIds ?? []);
|
||||
workingStep.contents = List.from(workingStep.contents ?? []);
|
||||
workingStep.quizQuestions = List.from(workingStep.quizQuestions ?? []);
|
||||
|
||||
bool isSaving = false;
|
||||
int questionsRevision = 0;
|
||||
|
||||
// Voir showNewOrUpdateGuidedPath : instantané pris après la première frame.
|
||||
String? baseline;
|
||||
bool hasUnsavedChanges() =>
|
||||
baseline != null && jsonEncode(workingStep) != baseline;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
final double screenHeight = MediaQuery.of(context).size.height;
|
||||
final double dialogWidth = screenWidth * 0.75;
|
||||
final double contentWidth = dialogWidth - 48;
|
||||
final double halfWidth = (contentWidth - 20) / 2;
|
||||
final appCtx = Provider.of<AppContext>(context, listen: false);
|
||||
|
||||
if (baseline == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => baseline = jsonEncode(workingStep));
|
||||
}
|
||||
|
||||
void closeWithConfirmation() {
|
||||
if (!hasUnsavedChanges()) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
showConfirmationDialog(
|
||||
AppLocalizations.of(context)!.discardStepChangesConfirm,
|
||||
() {},
|
||||
// Voir showNewOrUpdateGuidedPath : on laisse la confirmation
|
||||
// retirer sa route avant de fermer l'éditeur.
|
||||
() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) closeWithConfirmation();
|
||||
},
|
||||
child: Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
constraints: BoxConstraints(maxHeight: screenHeight * 0.85),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step == null ? AppLocalizations.of(context)!.newStepTitle : AppLocalizations.of(context)!.editStepTitle,
|
||||
style: TextStyle(
|
||||
color: kPrimaryColor,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre + Description côte à côte
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: halfWidth,
|
||||
child: MultiStringInputContainer(
|
||||
label: "Titre :",
|
||||
modalLabel: AppLocalizations.of(context)!.stepTitleLabel,
|
||||
initialValue: workingStep.title ?? [],
|
||||
onGetResult: (val) =>
|
||||
setState(() => workingStep.title = val),
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20),
|
||||
SizedBox(
|
||||
width: halfWidth,
|
||||
child: MultiStringInputContainer(
|
||||
label: "Description :",
|
||||
modalLabel: AppLocalizations.of(context)!.stepDescriptionLabel,
|
||||
initialValue: workingStep.description ?? [],
|
||||
onGetResult: (val) => setState(
|
||||
() => workingStep.description = val),
|
||||
maxLines: 1,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// Emplacement — Directement avec GeometryDTO.
|
||||
// Masqué pour un parcours en salle (ShowMap = false) : sans carte,
|
||||
// une position et une zone de déclenchement n'ont aucun effet.
|
||||
if (isGeolocated)
|
||||
SectionCard(
|
||||
icon: Icons.location_on,
|
||||
title: "Emplacement",
|
||||
subtitle: "Position et déclenchement géolocalisé de l'étape",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GeometryInputContainer(
|
||||
label: AppLocalizations.of(context)!.stepLocationLabel,
|
||||
initialGeometry: workingStep.geometry,
|
||||
initialColor: null,
|
||||
// GuidedStepDTO n'a pas de champ color (contrairement à
|
||||
// MapAnnotationDTO.polyColor) : bouton masqué plutôt que
|
||||
// supprimé, ne pas retirer comme du code mort.
|
||||
showColorButton: false,
|
||||
onSave: (geometry, color) {
|
||||
setState(() {
|
||||
workingStep.geometry = geometry;
|
||||
});
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: workingStep.isGeoTriggered ?? false,
|
||||
onChanged: (val) => setState(() => workingStep.isGeoTriggered = val),
|
||||
activeThumbColor: kPrimaryColor,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Déclenchement géolocalisé", style: TextStyle(fontSize: 14)),
|
||||
Text(
|
||||
"L'étape se débloque automatiquement quand le visiteur entre dans la zone.",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (workingStep.isGeoTriggered == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: NumberStepperField(
|
||||
label: "Rayon",
|
||||
value: workingStep.zoneRadiusMeters,
|
||||
min: 1,
|
||||
max: 500,
|
||||
unit: "m",
|
||||
onChanged: (val) => setState(() =>
|
||||
workingStep.zoneRadiusMeters = val.toDouble()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isGeolocated) SizedBox(height: 16),
|
||||
SectionCard(
|
||||
icon: Icons.perm_media,
|
||||
title: "Contenu riche",
|
||||
subtitle: "Audio guide, images et vidéos affichés sur la fiche de l'étape",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Audio
|
||||
MultiStringInputContainer(
|
||||
label: "Audio :",
|
||||
resourceTypes: [ResourceType.Audio],
|
||||
modalLabel: "Audio du guide",
|
||||
initialValue: workingStep.audioIds!,
|
||||
onGetResult: (val) => setState(() => workingStep.audioIds = val.isEmpty ? null : val),
|
||||
maxLines: 1,
|
||||
isTitle: false,
|
||||
isHTML: false,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
// Images
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("Médias :", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add_circle_outline, color: kSuccess),
|
||||
onPressed: () async {
|
||||
final result = await showNewOrUpdateContentSlider(
|
||||
null, appCtx, context, true, false,
|
||||
resourceTypes: kGuidedStepResourceTypes,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
result.order = workingStep.contents!.length;
|
||||
workingStep.contents = [...workingStep.contents!, result];
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (workingStep.contents!.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
"Aucun média — cliquez + pour ajouter une image, une vidéo ou un audio",
|
||||
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey[600], fontSize: 13),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: ReorderableListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
final item = workingStep.contents!.removeAt(oldIndex);
|
||||
workingStep.contents!.insert(newIndex, item);
|
||||
for (var i = 0; i < workingStep.contents!.length; i++) {
|
||||
workingStep.contents![i].order = i;
|
||||
}
|
||||
});
|
||||
},
|
||||
children: List.generate(workingStep.contents!.length, (i) => ListViewCardContent(
|
||||
workingStep.contents!,
|
||||
i,
|
||||
Key('content_$i'),
|
||||
appCtx,
|
||||
(updated) => setState(() => workingStep.contents = List.from(updated)),
|
||||
true,
|
||||
false,
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
// Questions — disponibles pour tous les parcours (guidés et escape game)
|
||||
SectionCard(
|
||||
icon: Icons.quiz,
|
||||
title: AppLocalizations.of(context)!.questionsChallengesLabel,
|
||||
subtitle: isEscapeMode
|
||||
? "Énigme à résoudre pour progresser"
|
||||
: "Quiz optionnel pour cette étape",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.add_circle_outline,
|
||||
color: kSuccess),
|
||||
onPressed: () {
|
||||
showNewOrUpdateQuizQuestion(
|
||||
context,
|
||||
null,
|
||||
workingStep.id ?? "temp",
|
||||
isEscapeMode,
|
||||
(newQuestion) {
|
||||
setState(() {
|
||||
newQuestion.order =
|
||||
workingStep.quizQuestions?.length ?? 0;
|
||||
workingStep.quizQuestions = [
|
||||
...(workingStep.quizQuestions ??
|
||||
[]),
|
||||
newQuestion
|
||||
];
|
||||
questionsRevision++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: workingStep.isStepTimer ?? false,
|
||||
onChanged: (val) => setState(() => workingStep.isStepTimer = val),
|
||||
activeThumbColor: kPrimaryColor,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Timer", style: TextStyle(fontSize: 14)),
|
||||
Text(
|
||||
"Chronomètre la réponse ; passé le délai, le message d'expiration s'affiche.",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (workingStep.isStepTimer == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: NumberStepperField(
|
||||
label: AppLocalizations.of(context)!.durationSecondsLabel,
|
||||
value: workingStep.timerSeconds,
|
||||
min: 0,
|
||||
max: 3600,
|
||||
unit: "sec",
|
||||
onChanged: (val) => setState(() => workingStep.timerSeconds = val.toInt()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (workingStep.isStepTimer == true) ...[
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "Message d'expiration :",
|
||||
modalLabel: "Message d'expiration du timer",
|
||||
initialValue: workingStep.timerExpiredMessage ?? [],
|
||||
onGetResult: (val) => setState(() =>
|
||||
workingStep.timerExpiredMessage = val.isEmpty ? null : val),
|
||||
maxLines: 2,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
if (workingStep.quizQuestions == null ||
|
||||
workingStep.quizQuestions!.isEmpty)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.noQuestionsConfigured,
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
)
|
||||
else
|
||||
ReorderableCustomList<QuizQuestion>(
|
||||
key: ValueKey(questionsRevision),
|
||||
items: workingStep.quizQuestions!,
|
||||
shrinkWrap: true,
|
||||
onChanged: (updatedList) {
|
||||
setState(() {
|
||||
for (var i = 0; i < updatedList.length; i++) {
|
||||
updatedList[i].order = i;
|
||||
}
|
||||
workingStep.quizQuestions =
|
||||
List.from(updatedList);
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, qIndex, question) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: HtmlWidget(
|
||||
question.label.isNotEmpty
|
||||
? question.label
|
||||
.firstWhere(
|
||||
(t) => t.language == 'FR',
|
||||
orElse: () =>
|
||||
question.label[0])
|
||||
.value ??
|
||||
"Question $qIndex"
|
||||
: "Question $qIndex",
|
||||
textStyle: TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Type: ${question.validationQuestionType == QuestionType.puzzle ? 'Puzzle' : question.validationQuestionType == QuestionType.multipleChoice ? 'QCM' : 'Texte'}"),
|
||||
);
|
||||
},
|
||||
actions: [
|
||||
(context, qIndex, question) => IconButton(
|
||||
icon: Icon(Icons.edit,
|
||||
size: 18, color: kPrimaryColor),
|
||||
onPressed: () {
|
||||
showNewOrUpdateQuizQuestion(
|
||||
context,
|
||||
question,
|
||||
workingStep.id ?? "temp",
|
||||
isEscapeMode,
|
||||
(updatedQuestion) {
|
||||
setState(() {
|
||||
updatedQuestion.order =
|
||||
question.order;
|
||||
workingStep.quizQuestions![
|
||||
qIndex] = updatedQuestion;
|
||||
questionsRevision++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
(context, qIndex, question) => IconButton(
|
||||
icon: Icon(Icons.delete,
|
||||
size: 18, color: kError),
|
||||
onPressed: () {
|
||||
showConfirmationDialog(
|
||||
"Supprimer cette question ?",
|
||||
() {},
|
||||
() => setState(() {
|
||||
workingStep.quizQuestions!
|
||||
.removeAt(qIndex);
|
||||
questionsRevision++;
|
||||
}),
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: RoundedButton(
|
||||
text: "Annuler",
|
||||
press: closeWithConfirmation,
|
||||
color: kSecond,
|
||||
fontSize: 15,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: RoundedButton(
|
||||
text: isSaving ? "Sauvegarde..." : "Sauvegarder",
|
||||
icon: isSaving ? Icons.hourglass_empty : null,
|
||||
press: () async {
|
||||
if (isSaving) return;
|
||||
setState(() => isSaving = true);
|
||||
// Initialise les booleans null → false
|
||||
workingStep.isStepTimer ??= false;
|
||||
workingStep.isGeoTriggered ??= false;
|
||||
if (workingStep.isGeoTriggered != true) {
|
||||
workingStep.zoneRadiusMeters = null;
|
||||
}
|
||||
try {
|
||||
await onSave(workingStep);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
setState(() => isSaving = false);
|
||||
}
|
||||
},
|
||||
color: kPrimaryColor,
|
||||
fontSize: 15,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1,453 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:manager_app/constants.dart';
|
||||
import 'package:manager_app/l10n/app_localizations.dart';
|
||||
import 'package:manager_app/Components/confirmation_dialog.dart';
|
||||
import 'package:manager_app/Components/rounded_button.dart';
|
||||
import 'package:manager_app/Components/multi_string_input_container.dart';
|
||||
import 'package:manager_app/Components/resource_input_container.dart';
|
||||
import 'package:manager_app/Components/number_input_container.dart';
|
||||
import 'package:manager_app/Components/check_input_container.dart';
|
||||
|
||||
// Conversions between TranslationDTO and TranslationAndResourceDTO
|
||||
// (ResponseDTO.label uses TranslationAndResourceDTO, but we use MultiStringInputContainer
|
||||
// which requires TranslationDTO — the resource field is not needed for quiz responses)
|
||||
List<TranslationDTO> _toTranslationList(
|
||||
List<TranslationAndResourceDTO>? list) =>
|
||||
(list ?? [])
|
||||
.map((t) => TranslationDTO(language: t.language, value: t.value))
|
||||
.toList();
|
||||
|
||||
List<TranslationAndResourceDTO> _fromTranslationList(
|
||||
List<TranslationDTO> list) =>
|
||||
list
|
||||
.map((t) =>
|
||||
TranslationAndResourceDTO(language: t.language, value: t.value))
|
||||
.toList();
|
||||
|
||||
// Creates an empty ResponseDTO; labels are populated by MultiStringInputContainer
|
||||
ResponseDTO _emptyResponse({bool isGood = false, int order = 0}) =>
|
||||
ResponseDTO(label: [], isGood: isGood, order: order);
|
||||
|
||||
void showNewOrUpdateQuizQuestion(
|
||||
BuildContext context,
|
||||
QuizQuestion? question,
|
||||
String stepId,
|
||||
bool isEscapeMode,
|
||||
Function(QuizQuestion) onSave,
|
||||
) {
|
||||
// Use JSON cloning for a robust deep copy
|
||||
QuizQuestion? clonedQuestion = question != null
|
||||
? QuizQuestion.fromJson(jsonDecode(jsonEncode(question)))
|
||||
: null;
|
||||
|
||||
List<TranslationDTO> workingLabel = _toTranslationList(
|
||||
clonedQuestion != null && clonedQuestion.label.isNotEmpty
|
||||
? clonedQuestion.label
|
||||
: [TranslationAndResourceDTO(language: 'FR', value: '')]);
|
||||
|
||||
List<ResponseDTO> workingResponses = clonedQuestion?.responses ?? [];
|
||||
|
||||
QuizQuestion workingQuestion = clonedQuestion ??
|
||||
QuizQuestion(
|
||||
id: 0,
|
||||
label: _fromTranslationList(workingLabel),
|
||||
responses: workingResponses,
|
||||
validationQuestionType: QuestionType.simple,
|
||||
order: question?.order ?? 0,
|
||||
guidedStepId: question?.guidedStepId ?? stepId,
|
||||
);
|
||||
|
||||
// Voir showNewOrUpdateGuidedPath. L'instantané est d'autant plus nécessaire ici
|
||||
// que `ensureSimpleResponse` écrit dans workingQuestion pendant la construction :
|
||||
// le prendre avant la première frame déclarerait « modifié » un dialog intact.
|
||||
String? baseline;
|
||||
bool hasUnsavedChanges() =>
|
||||
baseline != null && jsonEncode(workingQuestion) != baseline;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
final double screenHeight = MediaQuery.of(context).size.height;
|
||||
final double dialogWidth = screenWidth * 0.65;
|
||||
final double contentWidth = dialogWidth - 48;
|
||||
final double halfWidth = (contentWidth - 20) / 2;
|
||||
|
||||
if (baseline == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => baseline = jsonEncode(workingQuestion));
|
||||
}
|
||||
|
||||
void closeWithConfirmation() {
|
||||
if (!hasUnsavedChanges()) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
showConfirmationDialog(
|
||||
AppLocalizations.of(context)!.discardQuestionChangesConfirm,
|
||||
() {},
|
||||
// Voir showNewOrUpdateGuidedPath : on laisse la confirmation
|
||||
// retirer sa route avant de fermer l'éditeur.
|
||||
() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
void ensureSimpleResponse() {
|
||||
if (workingQuestion.responses.isEmpty) {
|
||||
workingQuestion.responses
|
||||
.add(_emptyResponse(isGood: true, order: 0));
|
||||
}
|
||||
workingQuestion.responses[0].isGood = true;
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) closeWithConfirmation();
|
||||
},
|
||||
child: Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
constraints: BoxConstraints(maxHeight: screenHeight * 0.85),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
question == null
|
||||
? "Nouvelle Question"
|
||||
: "Modifier la Question",
|
||||
style: TextStyle(
|
||||
color: kPrimaryColor,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- Intitulé (multi-langue via MultiStringInputContainer) ---
|
||||
MultiStringInputContainer(
|
||||
label: AppLocalizations.of(context)!.questionAskedLabel,
|
||||
modalLabel: AppLocalizations.of(context)!.questionTitleLabel,
|
||||
initialValue: workingLabel,
|
||||
onGetResult: (val) => setState(() {
|
||||
workingLabel = val;
|
||||
workingQuestion.label = _fromTranslationList(val);
|
||||
}),
|
||||
maxLines: 3,
|
||||
isTitle: false,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
|
||||
// --- Type ---
|
||||
Text("Type de validation :",
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 8),
|
||||
DropdownButton<QuestionType>(
|
||||
value: workingQuestion.validationQuestionType,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: QuestionType.simple,
|
||||
child: Text("Simple (texte attendu)")),
|
||||
DropdownMenuItem(
|
||||
value: QuestionType.multipleChoice,
|
||||
child: Text("Choix multiples (QCM)")),
|
||||
DropdownMenuItem(
|
||||
value: QuestionType.puzzle,
|
||||
child: Text("Puzzle")),
|
||||
],
|
||||
onChanged: (val) => setState(() {
|
||||
workingQuestion.validationQuestionType = val;
|
||||
workingQuestion.responses = [];
|
||||
}),
|
||||
),
|
||||
|
||||
// =========================================
|
||||
// Type 0 : Simple texte
|
||||
// =========================================
|
||||
if (workingQuestion.validationQuestionType ==
|
||||
QuestionType.simple) ...[
|
||||
Divider(height: 24),
|
||||
Text(AppLocalizations.of(context)!.expectedAnswerLabel,
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 8),
|
||||
Builder(builder: (_) {
|
||||
ensureSimpleResponse();
|
||||
final List<TranslationDTO> respLabel =
|
||||
_toTranslationList(
|
||||
workingQuestion.responses[0].label);
|
||||
return MultiStringInputContainer(
|
||||
label: "",
|
||||
modalLabel: AppLocalizations.of(context)!.expectedAnswerModalLabel,
|
||||
initialValue: respLabel,
|
||||
onGetResult: (val) => setState(() {
|
||||
workingQuestion.responses[0].label =
|
||||
_fromTranslationList(val);
|
||||
workingQuestion.responses[0].isGood = true;
|
||||
}),
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
);
|
||||
}),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.validationNote,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: kPrimaryColor.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.dialpad, size: 18, color: kPrimaryColor),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Si la réponse attendue ne contient que des chiffres, le visiteur "
|
||||
"voit automatiquement un pavé numérique façon cadenas (digicode) "
|
||||
"au lieu du champ texte. Rien à configurer.",
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[700]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// =========================================
|
||||
// Type 1 : QCM
|
||||
// =========================================
|
||||
if (workingQuestion.validationQuestionType ==
|
||||
QuestionType.multipleChoice) ...[
|
||||
Divider(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.possibleAnswersLabel,
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.add_circle_outline,
|
||||
color: kSuccess),
|
||||
label: Text("Ajouter",
|
||||
style: TextStyle(color: kSuccess)),
|
||||
onPressed: () => setState(() =>
|
||||
workingQuestion.responses.add(
|
||||
_emptyResponse(
|
||||
order: workingQuestion
|
||||
.responses.length))),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (workingQuestion.responses.isEmpty)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.noAnswerDefined,
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: List.generate(
|
||||
workingQuestion.responses.length, (i) {
|
||||
final resp = workingQuestion.responses[i];
|
||||
final List<TranslationDTO> respLabel =
|
||||
_toTranslationList(resp.label);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Checkbox bonne réponse
|
||||
Tooltip(
|
||||
message: resp.isGood == true
|
||||
? AppLocalizations.of(context)!.correctAnswer
|
||||
: AppLocalizations.of(context)!.wrongAnswer,
|
||||
child: Checkbox(
|
||||
value: resp.isGood ?? false,
|
||||
activeColor: kSuccess,
|
||||
onChanged: (val) => setState(
|
||||
() => resp.isGood = val),
|
||||
),
|
||||
),
|
||||
// Traductions (via MultiStringInputContainer)
|
||||
Expanded(
|
||||
child: MultiStringInputContainer(
|
||||
label: "${AppLocalizations.of(context)!.answerLabel} ${i + 1} :",
|
||||
modalLabel: "${AppLocalizations.of(context)!.answerLabel} ${i + 1}",
|
||||
initialValue: respLabel,
|
||||
onGetResult: (val) => setState(
|
||||
() => resp.label =
|
||||
_fromTranslationList(
|
||||
val)),
|
||||
maxLines: 1,
|
||||
isTitle: true,
|
||||
isHTML: true,
|
||||
showPreview: true,
|
||||
),
|
||||
),
|
||||
// Supprimer
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline,
|
||||
color: kError, size: 20),
|
||||
onPressed: () => setState(() =>
|
||||
workingQuestion.responses
|
||||
.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.answerNote,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
||||
// =========================================
|
||||
// Type 2 : Puzzle
|
||||
// =========================================
|
||||
if (workingQuestion.validationQuestionType ==
|
||||
QuestionType.puzzle) ...[
|
||||
Divider(height: 24),
|
||||
Text("Configuration du Puzzle",
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 12),
|
||||
ResourceInputContainer(
|
||||
label: "Image du puzzle :",
|
||||
initialValue: workingQuestion.puzzleImageId,
|
||||
onChanged: (res) => setState(() {
|
||||
workingQuestion.puzzleImageId = res.id;
|
||||
workingQuestion.puzzleImage =
|
||||
Resource.fromJson(res.toJson());
|
||||
}),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: halfWidth,
|
||||
child: NumberInputContainer(
|
||||
label: "Lignes :",
|
||||
initialValue:
|
||||
workingQuestion.puzzleRows ?? 3,
|
||||
onChanged: (val) => setState(() =>
|
||||
workingQuestion.puzzleRows =
|
||||
int.tryParse(val) ?? 3),
|
||||
isSmall: true,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20),
|
||||
SizedBox(
|
||||
width: halfWidth,
|
||||
child: NumberInputContainer(
|
||||
label: "Colonnes :",
|
||||
initialValue:
|
||||
workingQuestion.puzzleCols ?? 3,
|
||||
onChanged: (val) => setState(() =>
|
||||
workingQuestion.puzzleCols =
|
||||
int.tryParse(val) ?? 3),
|
||||
isSmall: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
CheckInputContainer(
|
||||
label: "Puzzle glissant (Sliding) :",
|
||||
isChecked:
|
||||
workingQuestion.isSlidingPuzzle ?? false,
|
||||
onChanged: (val) => setState(
|
||||
() => workingQuestion.isSlidingPuzzle = val),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: RoundedButton(
|
||||
text: "Annuler",
|
||||
press: closeWithConfirmation,
|
||||
color: kSecond,
|
||||
fontSize: 15,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: RoundedButton(
|
||||
text: "Sauvegarder",
|
||||
press: () {
|
||||
for (int i = 0;
|
||||
i < workingQuestion.responses.length;
|
||||
i++) {
|
||||
workingQuestion.responses[i].order = i;
|
||||
}
|
||||
onSave(workingQuestion);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
color: kPrimaryColor,
|
||||
fontSize: 15,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user