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