542 lines
29 KiB
Dart
542 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';
|
|
|
|
void showNewOrUpdateGuidedStep(
|
|
BuildContext context,
|
|
GuidedStepDTO? step,
|
|
String pathId,
|
|
bool isEscapeMode,
|
|
FutureOr<void> Function(GuidedStepDTO) onSave,
|
|
) {
|
|
// 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;
|
|
|
|
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);
|
|
|
|
return 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
|
|
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,
|
|
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()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
// Comportement de l'étape
|
|
SectionCard(
|
|
icon: Icons.tune,
|
|
title: "Comportement",
|
|
subtitle: "Visibilité et verrouillage de l'étape",
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: SwitchListTile(
|
|
dense: true,
|
|
title: Text(AppLocalizations.of(context)!.initiallyHiddenLabel),
|
|
subtitle: Text(
|
|
"N'apparaît pas sur la carte tant qu'elle n'a pas été débloquée.",
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
value: workingStep.isHiddenInitially ?? false,
|
|
onChanged: (val) => setState(() => workingStep.isHiddenInitially = val),
|
|
activeThumbColor: kPrimaryColor,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SwitchListTile(
|
|
dense: true,
|
|
title: Text(AppLocalizations.of(context)!.lockedLabel),
|
|
subtitle: Text(
|
|
"Visible mais non accessible tant que les étapes précédentes ne sont pas terminées.",
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
value: workingStep.isStepLocked ?? false,
|
|
onChanged: (val) => setState(() => workingStep.isStepLocked = val),
|
|
activeThumbColor: kPrimaryColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
SectionCard(
|
|
icon: Icons.perm_media,
|
|
title: "Contenu riche",
|
|
subtitle: "Audio guide et images 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("Images :", 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);
|
|
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(
|
|
"Aucune image — cliquez + pour en ajouter",
|
|
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?.value == 2 ? 'Puzzle' : question.validationQuestionType?.value == 1 ? '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: () => Navigator.pop(context),
|
|
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.isHiddenInitially ??= false;
|
|
workingStep.isStepTimer ??= false;
|
|
workingStep.isStepLocked ??= 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|