Thomas Fransolet b39854afb0 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>
2026-08-11 17:15:36 +02:00

337 lines
13 KiB
Dart

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,
),
],
),
),
],
);
}
}