PDF en média d'étape — la liste de types du sélecteur de médias devient un
paramètre (`kSliderContentResourceTypes` par défaut) et l'étape de parcours
passe `kGuidedStepResourceTypes`, soit les types du slider plus Pdf.
QuestionType — le client généré nomme ses valeurs number0/1/2, ce qui
poussait le front à comparer des entiers bruts (`?.value == 2 ? 'Puzzle'`).
Alias nommés d'après l'enum serveur ajoutés à la main dans
question_type.dart (simple / multipleChoice / puzzle), `values` inchangé :
ce sont les mêmes trois valeurs. Les 7 usages number* du dialogue de
question sont migrés avec.
Fournisseur de carte (W1) — décision : visitapp-web reste sur Leaflet. Le
champ ne peut pas être masqué pour le web, il est porté par la section et
une même configuration est servie au mobile comme au web ; il est donc
conservé et documenté dans l'interface comme réglage mobile/tablette.
flutter build web ✅, analyse du dossier sans erreur ni warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
556 lines
29 KiB
Dart
556 lines
29 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|