2026-07-14 17:20:20 +02:00

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