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:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
import 'package:manager_app/constants.dart';
|
import 'package:manager_app/constants.dart';
|
||||||
import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedPath.dart';
|
import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/guided_path_api.dart';
|
||||||
|
import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/guided_path_editor.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:manager_app/app_context.dart';
|
import 'package:manager_app/app_context.dart';
|
||||||
import 'package:manager_app/l10n/app_localizations.dart';
|
import 'package:manager_app/l10n/app_localizations.dart';
|
||||||
@ -65,6 +66,31 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ouvre l'éditeur à fenêtre unique. Il enregistre au fil de la saisie : il
|
||||||
|
/// n'y a donc rien à récupérer d'un callback, seulement la liste à relire à
|
||||||
|
/// la fermeture pour repartir de l'état réel du serveur.
|
||||||
|
Future<void> _openEditor(GuidedPathDTO? path) async {
|
||||||
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
||||||
|
final managerContext = appContext.getContext() as ManagerAppContext;
|
||||||
|
|
||||||
|
await showGuidedPathEditor(
|
||||||
|
context,
|
||||||
|
path: path,
|
||||||
|
sectionId: widget.parentId,
|
||||||
|
instanceId: managerContext.instanceId,
|
||||||
|
isEvent: widget.isEvent,
|
||||||
|
isParcours: widget.isParcours,
|
||||||
|
isGeolocated: widget.isGeolocated,
|
||||||
|
api: GuidedPathApi(managerContext.clientAPI!,
|
||||||
|
isParcours: widget.isParcours),
|
||||||
|
newPathOrder: paths.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
await _loadFromApi();
|
||||||
|
if (mounted) widget.onChanged(paths);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
@ -79,58 +105,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
|
|||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: Icon(Icons.add),
|
icon: Icon(Icons.add),
|
||||||
label: Text(AppLocalizations.of(context)!.addPath),
|
label: Text(AppLocalizations.of(context)!.addPath),
|
||||||
onPressed: () {
|
onPressed: () => _openEditor(null),
|
||||||
final appContext =
|
|
||||||
Provider.of<AppContext>(context, listen: false);
|
|
||||||
showNewOrUpdateGuidedPath(
|
|
||||||
context,
|
|
||||||
null,
|
|
||||||
widget.parentId,
|
|
||||||
widget.isEvent,
|
|
||||||
isGeolocated: widget.isGeolocated,
|
|
||||||
(newPath) async {
|
|
||||||
try {
|
|
||||||
newPath.order = paths.length;
|
|
||||||
newPath.instanceId =
|
|
||||||
(appContext.getContext() as ManagerAppContext)
|
|
||||||
.instanceId;
|
|
||||||
if (widget.isParcours) {
|
|
||||||
newPath.sectionParcoursId = widget.parentId;
|
|
||||||
} else if (widget.isEvent) {
|
|
||||||
newPath.sectionEventId = widget.parentId;
|
|
||||||
} else {
|
|
||||||
newPath.sectionMapId = widget.parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;
|
|
||||||
final createdPath = widget.isParcours
|
|
||||||
? await clientAPI.sectionParcoursApi!
|
|
||||||
.sectionParcoursCreateGuidedPath(widget.parentId, newPath)
|
|
||||||
: await clientAPI.sectionMapApi!
|
|
||||||
.sectionMapCreateGuidedPath(widget.parentId, newPath);
|
|
||||||
|
|
||||||
if (createdPath != null) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
paths.add(createdPath);
|
|
||||||
widget.onChanged(paths);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
showNotification(kSuccess, kWhite,
|
|
||||||
AppLocalizations.of(context)!.pathCreatedSuccess, context, null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showNotification(
|
|
||||||
kError,
|
|
||||||
kWhite,
|
|
||||||
AppLocalizations.of(context)!.pathCreateError,
|
|
||||||
context,
|
|
||||||
null);
|
|
||||||
rethrow; // Important so showNewOrUpdateGuidedPath knows it failed
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: kSuccess, foregroundColor: kWhite),
|
backgroundColor: kSuccess, foregroundColor: kWhite),
|
||||||
),
|
),
|
||||||
@ -198,48 +173,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.edit, color: kPrimaryColor),
|
icon: Icon(Icons.edit, color: kPrimaryColor),
|
||||||
onPressed: () {
|
onPressed: () => _openEditor(path),
|
||||||
final appContext = Provider.of<AppContext>(
|
|
||||||
context,
|
|
||||||
listen: false);
|
|
||||||
showNewOrUpdateGuidedPath(
|
|
||||||
context,
|
|
||||||
path,
|
|
||||||
widget.parentId,
|
|
||||||
widget.isEvent,
|
|
||||||
isGeolocated: widget.isGeolocated,
|
|
||||||
(updatedPath) async {
|
|
||||||
try {
|
|
||||||
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;
|
|
||||||
final result = widget.isParcours
|
|
||||||
? await clientAPI.sectionParcoursApi!.sectionParcoursUpdateGuidedPath(updatedPath)
|
|
||||||
: await clientAPI.sectionMapApi!.sectionMapUpdateGuidedPath(updatedPath);
|
|
||||||
if (result != null) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
paths[index] = result;
|
|
||||||
widget.onChanged(paths);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
showNotification(
|
|
||||||
kSuccess,
|
|
||||||
kWhite,
|
|
||||||
AppLocalizations.of(context)!.pathUpdatedSuccess,
|
|
||||||
context,
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showNotification(
|
|
||||||
kError,
|
|
||||||
kWhite,
|
|
||||||
AppLocalizations.of(context)!.pathUpdateError,
|
|
||||||
context,
|
|
||||||
null);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.delete, color: kError),
|
icon: Icon(Icons.delete, color: kError),
|
||||||
|
|||||||
@ -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",
|
"categoriesTitle": "Categories",
|
||||||
"endTimeLabel": "End time",
|
"endTimeLabel": "End time",
|
||||||
"annotationsLabel": "Annotations",
|
"annotationsLabel": "Annotations",
|
||||||
"discardPathChangesConfirm": "This path has unsaved changes, along with its steps and their questions. Discard these changes?",
|
"addStep": "Add a step",
|
||||||
"discardStepChangesConfirm": "This step has unsaved changes, along with its questions. Discard these changes?",
|
"addQuestion": "Add a question",
|
||||||
"discardQuestionChangesConfirm": "This question has unsaved changes, along with its answers. Discard these changes?",
|
"deleteStep": "Delete step",
|
||||||
|
"deleteStepConfirm": "Delete this step and its questions?",
|
||||||
|
"pathPanelTitle": "The path",
|
||||||
|
"pathPanelSubtitle": "Title, mood, progression",
|
||||||
|
"stepSummaryEmpty": "No content",
|
||||||
|
"mediaCount": "{count} media",
|
||||||
|
"@mediaCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questionCount": "{count} question(s)",
|
||||||
|
"@questionCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saveStatusIdle": "Saved automatically",
|
||||||
|
"saveStatusSaving": "Saving…",
|
||||||
|
"saveStatusSaved": "Saved",
|
||||||
|
"saveStatusFailed": "Could not save",
|
||||||
|
"retry": "Retry",
|
||||||
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map."
|
"mapProviderMobileOnlyNote": "The map provider and type apply to the mobile and tablet apps. The web app always renders its own map."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -682,8 +682,29 @@
|
|||||||
"categoriesTitle": "Catégories",
|
"categoriesTitle": "Catégories",
|
||||||
"endTimeLabel": "Heure de fin",
|
"endTimeLabel": "Heure de fin",
|
||||||
"annotationsLabel": "Annotations",
|
"annotationsLabel": "Annotations",
|
||||||
"discardPathChangesConfirm": "Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?",
|
"addStep": "Ajouter une étape",
|
||||||
"discardStepChangesConfirm": "Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?",
|
"addQuestion": "Ajouter une question",
|
||||||
"discardQuestionChangesConfirm": "Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?",
|
"deleteStep": "Supprimer l'étape",
|
||||||
|
"deleteStepConfirm": "Supprimer cette étape et ses questions ?",
|
||||||
|
"pathPanelTitle": "Le parcours",
|
||||||
|
"pathPanelSubtitle": "Titre, ambiance, progression",
|
||||||
|
"stepSummaryEmpty": "Aucun contenu",
|
||||||
|
"mediaCount": "{count} média(s)",
|
||||||
|
"@mediaCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questionCount": "{count} question(s)",
|
||||||
|
"@questionCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saveStatusIdle": "Enregistrement automatique",
|
||||||
|
"saveStatusSaving": "Enregistrement…",
|
||||||
|
"saveStatusSaved": "Enregistré",
|
||||||
|
"saveStatusFailed": "Échec de l'enregistrement",
|
||||||
|
"retry": "Réessayer",
|
||||||
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte."
|
"mapProviderMobileOnlyNote": "Le fournisseur et le type de carte s'appliquent aux applications mobile et tablette. L'application web affiche toujours sa propre carte."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3298,23 +3298,89 @@ abstract class AppLocalizations {
|
|||||||
/// **'Annotations'**
|
/// **'Annotations'**
|
||||||
String get annotationsLabel;
|
String get annotationsLabel;
|
||||||
|
|
||||||
/// No description provided for @discardPathChangesConfirm.
|
/// No description provided for @addStep.
|
||||||
///
|
///
|
||||||
/// In fr, this message translates to:
|
/// In fr, this message translates to:
|
||||||
/// **'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?'**
|
/// **'Ajouter une étape'**
|
||||||
String get discardPathChangesConfirm;
|
String get addStep;
|
||||||
|
|
||||||
/// No description provided for @discardStepChangesConfirm.
|
/// No description provided for @addQuestion.
|
||||||
///
|
///
|
||||||
/// In fr, this message translates to:
|
/// In fr, this message translates to:
|
||||||
/// **'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?'**
|
/// **'Ajouter une question'**
|
||||||
String get discardStepChangesConfirm;
|
String get addQuestion;
|
||||||
|
|
||||||
/// No description provided for @discardQuestionChangesConfirm.
|
/// No description provided for @deleteStep.
|
||||||
///
|
///
|
||||||
/// In fr, this message translates to:
|
/// In fr, this message translates to:
|
||||||
/// **'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?'**
|
/// **'Supprimer l\'étape'**
|
||||||
String get discardQuestionChangesConfirm;
|
String get deleteStep;
|
||||||
|
|
||||||
|
/// No description provided for @deleteStepConfirm.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Supprimer cette étape et ses questions ?'**
|
||||||
|
String get deleteStepConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @pathPanelTitle.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Le parcours'**
|
||||||
|
String get pathPanelTitle;
|
||||||
|
|
||||||
|
/// No description provided for @pathPanelSubtitle.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Titre, ambiance, progression'**
|
||||||
|
String get pathPanelSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @stepSummaryEmpty.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Aucun contenu'**
|
||||||
|
String get stepSummaryEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @mediaCount.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'{count} média(s)'**
|
||||||
|
String mediaCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @questionCount.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'{count} question(s)'**
|
||||||
|
String questionCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @saveStatusIdle.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Enregistrement automatique'**
|
||||||
|
String get saveStatusIdle;
|
||||||
|
|
||||||
|
/// No description provided for @saveStatusSaving.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Enregistrement…'**
|
||||||
|
String get saveStatusSaving;
|
||||||
|
|
||||||
|
/// No description provided for @saveStatusSaved.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Enregistré'**
|
||||||
|
String get saveStatusSaved;
|
||||||
|
|
||||||
|
/// No description provided for @saveStatusFailed.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Échec de l\'enregistrement'**
|
||||||
|
String get saveStatusFailed;
|
||||||
|
|
||||||
|
/// No description provided for @retry.
|
||||||
|
///
|
||||||
|
/// In fr, this message translates to:
|
||||||
|
/// **'Réessayer'**
|
||||||
|
String get retry;
|
||||||
|
|
||||||
/// No description provided for @mapProviderMobileOnlyNote.
|
/// No description provided for @mapProviderMobileOnlyNote.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -1746,16 +1746,50 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get annotationsLabel => 'Annotations';
|
String get annotationsLabel => 'Annotations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardPathChangesConfirm =>
|
String get addStep => 'Add a step';
|
||||||
'This path has unsaved changes, along with its steps and their questions. Discard these changes?';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardStepChangesConfirm =>
|
String get addQuestion => 'Add a question';
|
||||||
'This step has unsaved changes, along with its questions. Discard these changes?';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardQuestionChangesConfirm =>
|
String get deleteStep => 'Delete step';
|
||||||
'This question has unsaved changes, along with its answers. Discard these changes?';
|
|
||||||
|
@override
|
||||||
|
String get deleteStepConfirm => 'Delete this step and its questions?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathPanelTitle => 'The path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathPanelSubtitle => 'Title, mood, progression';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stepSummaryEmpty => 'No content';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String mediaCount(int count) {
|
||||||
|
return '$count media';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String questionCount(int count) {
|
||||||
|
return '$count question(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusIdle => 'Saved automatically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusSaving => 'Saving…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusSaved => 'Saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusFailed => 'Could not save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Retry';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapProviderMobileOnlyNote =>
|
String get mapProviderMobileOnlyNote =>
|
||||||
|
|||||||
@ -1784,16 +1784,50 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get annotationsLabel => 'Annotations';
|
String get annotationsLabel => 'Annotations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardPathChangesConfirm =>
|
String get addStep => 'Ajouter une étape';
|
||||||
'Ce parcours contient des modifications non enregistrées, ainsi que ses étapes et leurs questions. Abandonner ces modifications ?';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardStepChangesConfirm =>
|
String get addQuestion => 'Ajouter une question';
|
||||||
'Cette étape contient des modifications non enregistrées, ainsi que ses questions. Abandonner ces modifications ?';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardQuestionChangesConfirm =>
|
String get deleteStep => 'Supprimer l\'étape';
|
||||||
'Cette question contient des modifications non enregistrées, ainsi que ses réponses. Abandonner ces modifications ?';
|
|
||||||
|
@override
|
||||||
|
String get deleteStepConfirm => 'Supprimer cette étape et ses questions ?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathPanelTitle => 'Le parcours';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathPanelSubtitle => 'Titre, ambiance, progression';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stepSummaryEmpty => 'Aucun contenu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String mediaCount(int count) {
|
||||||
|
return '$count média(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String questionCount(int count) {
|
||||||
|
return '$count question(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusIdle => 'Enregistrement automatique';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusSaving => 'Enregistrement…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusSaved => 'Enregistré';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusFailed => 'Échec de l\'enregistrement';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Réessayer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapProviderMobileOnlyNote =>
|
String get mapProviderMobileOnlyNote =>
|
||||||
|
|||||||
@ -1763,16 +1763,51 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get annotationsLabel => 'Annotaties';
|
String get annotationsLabel => 'Annotaties';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardPathChangesConfirm =>
|
String get addStep => 'Een stap toevoegen';
|
||||||
'Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardStepChangesConfirm =>
|
String get addQuestion => 'Een vraag toevoegen';
|
||||||
'Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discardQuestionChangesConfirm =>
|
String get deleteStep => 'Stap verwijderen';
|
||||||
'Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?';
|
|
||||||
|
@override
|
||||||
|
String get deleteStepConfirm =>
|
||||||
|
'Deze stap en de bijbehorende vragen verwijderen?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathPanelTitle => 'De route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathPanelSubtitle => 'Titel, sfeer, voortgang';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stepSummaryEmpty => 'Geen inhoud';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String mediaCount(int count) {
|
||||||
|
return '$count media';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String questionCount(int count) {
|
||||||
|
return '$count vraag/vragen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusIdle => 'Automatisch opslaan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusSaving => 'Opslaan…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusSaved => 'Opgeslagen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveStatusFailed => 'Opslaan mislukt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retry => 'Opnieuw proberen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapProviderMobileOnlyNote =>
|
String get mapProviderMobileOnlyNote =>
|
||||||
|
|||||||
@ -682,8 +682,29 @@
|
|||||||
"categoriesTitle": "Categorieën",
|
"categoriesTitle": "Categorieën",
|
||||||
"endTimeLabel": "Eindtijd",
|
"endTimeLabel": "Eindtijd",
|
||||||
"annotationsLabel": "Annotaties",
|
"annotationsLabel": "Annotaties",
|
||||||
"discardPathChangesConfirm": "Deze route heeft niet-opgeslagen wijzigingen, samen met de stappen en hun vragen. Deze wijzigingen weggooien?",
|
"addStep": "Een stap toevoegen",
|
||||||
"discardStepChangesConfirm": "Deze stap heeft niet-opgeslagen wijzigingen, samen met de vragen. Deze wijzigingen weggooien?",
|
"addQuestion": "Een vraag toevoegen",
|
||||||
"discardQuestionChangesConfirm": "Deze vraag heeft niet-opgeslagen wijzigingen, samen met de antwoorden. Deze wijzigingen weggooien?",
|
"deleteStep": "Stap verwijderen",
|
||||||
|
"deleteStepConfirm": "Deze stap en de bijbehorende vragen verwijderen?",
|
||||||
|
"pathPanelTitle": "De route",
|
||||||
|
"pathPanelSubtitle": "Titel, sfeer, voortgang",
|
||||||
|
"stepSummaryEmpty": "Geen inhoud",
|
||||||
|
"mediaCount": "{count} media",
|
||||||
|
"@mediaCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questionCount": "{count} vraag/vragen",
|
||||||
|
"@questionCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saveStatusIdle": "Automatisch opslaan",
|
||||||
|
"saveStatusSaving": "Opslaan…",
|
||||||
|
"saveStatusSaved": "Opgeslagen",
|
||||||
|
"saveStatusFailed": "Opslaan mislukt",
|
||||||
|
"retry": "Opnieuw proberen",
|
||||||
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart."
|
"mapProviderMobileOnlyNote": "De kaartprovider en het kaarttype gelden voor de mobiele en tablet-apps. De web-app toont altijd zijn eigen kaart."
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user