Thomas Fransolet a54952e5f4 MISC
2026-07-10 16:54:26 +02:00

467 lines
24 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/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/string_input_container.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,
),
),
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,
),
),
],
),
Divider(height: 24),
// Géométrie — Directement avec GeometryDTO
GeometryInputContainer(
label: AppLocalizations.of(context)!.stepLocationLabel,
initialGeometry: workingStep.geometry,
initialColor: null,
onSave: (geometry, color) {
setState(() {
workingStep.geometry = geometry;
});
},
),
SizedBox(height: 8),
SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text("Déclenchement géolocalisé"),
value: workingStep.isGeoTriggered ?? false,
onChanged: (val) => setState(() => workingStep.isGeoTriggered = val),
activeThumbColor: kPrimaryColor,
),
if (workingStep.isGeoTriggered == true) ...[
SizedBox(height: 8),
SizedBox(
width: halfWidth,
child: StringInputContainer(
label: "Rayon (mètres) :",
initialValue: workingStep.zoneRadiusMeters?.toString() ?? "",
onChanged: (val) => setState(() =>
workingStep.zoneRadiusMeters = double.tryParse(val)),
),
),
],
Divider(height: 24),
// Comportement de l'étape
Text("Comportement", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
SizedBox(height: 8),
Row(
children: [
Expanded(
child: SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(context)!.initiallyHiddenLabel),
value: workingStep.isHiddenInitially ?? false,
onChanged: (val) => setState(() => workingStep.isHiddenInitially = val),
activeThumbColor: kPrimaryColor,
),
),
Expanded(
child: SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(context)!.lockedLabel),
value: workingStep.isStepLocked ?? false,
onChanged: (val) => setState(() => workingStep.isStepLocked = val),
activeThumbColor: kPrimaryColor,
),
),
],
),
Divider(height: 24),
Text("Contenu riche", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
SizedBox(height: 8),
// 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,
)),
),
),
// Questions — uniquement en mode Escape Game
if (isEscapeMode) ...[
Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(AppLocalizations.of(context)!.questionsChallengesLabel,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15)),
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++;
});
},
);
},
),
],
),
SizedBox(
width: halfWidth,
child: SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text("Timer"),
value: workingStep.isStepTimer ?? false,
onChanged: (val) => setState(() => workingStep.isStepTimer = val),
activeThumbColor: kPrimaryColor,
),
),
if (workingStep.isStepTimer == true) ...[
SizedBox(height: 8),
Row(
children: [
SizedBox(
width: halfWidth,
child: StringInputContainer(
label: AppLocalizations.of(context)!.durationSecondsLabel,
initialValue: workingStep.timerSeconds?.toString() ?? "",
onChanged: (val) => setState(() =>
workingStep.timerSeconds = int.tryParse(val)),
),
),
SizedBox(width: 20),
SizedBox(
width: halfWidth,
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,
),
),
],
),
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;
}
if (!isEscapeMode) {
workingStep.isStepTimer = false;
workingStep.timerSeconds = null;
workingStep.timerExpiredMessage = null;
}
try {
await onSave(workingStep);
if (context.mounted) Navigator.pop(context);
} catch (e) {
setState(() => isSaving = false);
}
},
color: kPrimaryColor,
fontSize: 15,
horizontal: 24,
),
),
],
),
],
),
),
);
},
);
},
);
}