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/constants.dart'; import 'package:intl/intl.dart'; import 'showNewOrUpdateProgrammeBlock.dart'; import 'package:provider/provider.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart'; import 'package:manager_app/Components/dropDown_input_container.dart'; import 'package:manager_app/Components/collection_editor.dart'; import 'package:manager_app/Components/map_canvas.dart'; import 'package:manager_app/Components/multi_string_input_container.dart'; import 'package:manager_app/Components/string_input_container.dart'; class EventConfig extends StatefulWidget { final SectionEventDTO initialValue; final ValueChanged onChanged; const EventConfig({ Key? key, required this.initialValue, required this.onChanged, }) : super(key: key); @override _EventConfigState createState() => _EventConfigState(); } class _EventConfigState extends State { late SectionEventDTO eventDTO; List availableMaps = []; @override void initState() { super.initState(); eventDTO = widget.initialValue; if (eventDTO.startDate != null && eventDTO.startDate!.toUtc().year < 1000) { eventDTO.startDate = null; } if (eventDTO.endDate != null && eventDTO.endDate!.toUtc().year < 1000) { eventDTO.endDate = null; } WidgetsBinding.instance.addPostFrameCallback((_) { _loadProgrammeBlocks(); _loadGlobalAnnotations(); _loadAvailableMaps(); }); } Future _loadProgrammeBlocks() async { if (eventDTO.id == null || !mounted) return; final appContext = Provider.of(context, listen: false); final api = (appContext.getContext() as ManagerAppContext).clientAPI!.sectionEventApi!; try { final blocks = await api.sectionEventGetAllProgrammeBlockFromSection(eventDTO.id!); if (blocks == null || !mounted) return; setState(() { eventDTO.programme = blocks; }); } catch (e) { // Silently keep initial value on error } } Future _loadGlobalAnnotations() async { if (eventDTO.id == null || !mounted) return; final appContext = Provider.of(context, listen: false); final api = (appContext.getContext() as ManagerAppContext).clientAPI!.sectionEventApi!; try { final annotations = await api.sectionEventGetGlobalMapAnnotations(eventDTO.id!); if (annotations == null || !mounted) return; setState(() { eventDTO.globalMapAnnotations = annotations; }); } catch (e) { // Silently keep initial value on error } } Future _loadAvailableMaps() async { if (eventDTO.configurationId == null || !mounted) return; final appContext = Provider.of(context, listen: false); final api = (appContext.getContext() as ManagerAppContext).clientAPI!.sectionApi!; try { final sections = await api.sectionGetFromConfiguration(eventDTO.configurationId!); if (sections == null || !mounted) return; final maps = sections.where((s) => s.type == SectionType.Map).toList(); setState(() { availableMaps = maps; // Plan par défaut : plutôt que de bloquer le canevas faute de fond, on // prend le premier plan disponible. Le client peut en choisir un autre. if (eventDTO.baseSectionMapId == null && maps.isNotEmpty) { eventDTO.baseSectionMapId = maps.first.id; widget.onChanged(eventDTO); } }); } catch (e) { // Silently keep empty on error } } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Expanded( child: _DateField( label: AppLocalizations.of(context)!.startDateLabel, value: eventDTO.startDate != null ? DateFormat('dd/MM/yyyy HH:mm') .format(eventDTO.startDate!.toLocal()) : AppLocalizations.of(context)!.notDefined, onTap: () async { DateTime initialDate = eventDTO.startDate?.toLocal() ?? DateTime.now(); if (initialDate.isBefore(DateTime(2000))) { initialDate = DateTime.now(); } DateTime? picked = await showDatePicker( context: context, initialDate: initialDate, firstDate: DateTime(2000), lastDate: DateTime(2100), builder: (context, child) { return Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( primary: kPrimaryColor, onPrimary: kWhite, onSurface: kSecond, ), ), child: child!, ); }, ); if (picked != null) { TimeOfDay? time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime( eventDTO.startDate?.toLocal() ?? DateTime.now()), builder: (context, child) { return Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( primary: kPrimaryColor, onPrimary: kWhite, onSurface: kSecond, ), ), child: child!, ); }, ); if (time != null) { setState(() { eventDTO.startDate = DateTime(picked.year, picked.month, picked.day, time.hour, time.minute); widget.onChanged(eventDTO); }); } } }, ), ), Expanded( child: _DateField( label: AppLocalizations.of(context)!.endDateLabel, value: eventDTO.endDate != null ? DateFormat('dd/MM/yyyy HH:mm').format(eventDTO.endDate!.toLocal()) : AppLocalizations.of(context)!.notDefined, onTap: () async { DateTime initialDate = eventDTO.endDate?.toLocal() ?? DateTime.now().add(Duration(days: 1)); if (initialDate.isBefore(DateTime(2000))) { initialDate = DateTime.now().add(Duration(days: 1)); } DateTime? picked = await showDatePicker( context: context, initialDate: initialDate, firstDate: DateTime(2000), lastDate: DateTime(2100), builder: (context, child) { return Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( primary: kPrimaryColor, onPrimary: kWhite, onSurface: kSecond, ), ), child: child!, ); }, ); if (picked != null) { TimeOfDay? time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(eventDTO.endDate?.toLocal() ?? DateTime.now().add(Duration(days: 1))), builder: (context, child) { return Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( primary: kPrimaryColor, onPrimary: kWhite, onSurface: kSecond, ), ), child: child!, ); }, ); if (time != null) { setState(() { eventDTO.endDate = DateTime(picked.year, picked.month, picked.day, time.hour, time.minute); widget.onChanged(eventDTO); }); } } }, ), ), ], ), ), const SizedBox(height: kSpace6), // --- Carte de base --- _buildBaseSectionMapSection(), const SizedBox(height: kSpace6), // --- Annotations globales --- _buildGlobalAnnotationsSection(), const SizedBox(height: kSpace6), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(AppLocalizations.of(context)!.programmeLabel, style: kLabelField), TextButton.icon( icon: const Icon(Icons.add, size: 15), label: Text(AppLocalizations.of(context)!.addBlock), onPressed: () { final appContext = Provider.of(context, listen: false); showNewOrUpdateProgrammeBlock( context, null, (newBlock) async { try { final programmeBlockDTO = ProgrammeBlockDTO( id: newBlock.id, title: newBlock.title, description: newBlock.description, startTime: newBlock.startTime, endTime: newBlock.endTime, ); final createdBlockDTO = await (appContext.getContext() as ManagerAppContext) .clientAPI! .sectionEventApi! .sectionEventCreateProgrammeBlock( eventDTO.id!, programmeBlockDTO); if (createdBlockDTO != null) { final createdBlock = ProgrammeBlock( id: createdBlockDTO.id, title: createdBlockDTO.title, description: createdBlockDTO.description, startTime: createdBlockDTO.startTime, endTime: createdBlockDTO.endTime, ); setState(() { eventDTO.programme = [ ...(eventDTO.programme ?? []), createdBlock ]; widget.onChanged(eventDTO); }); showNotification( kSuccess, kWhite, AppLocalizations.of(context)!.programmeBlockCreatedSuccess, context, null); } } catch (e) { showNotification( kError, kWhite, AppLocalizations.of(context)!.programmeBlockCreateError, context, null); } }, ); }, style: TextButton.styleFrom( foregroundColor: kPrimaryColor, textStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600), ), ), ], ), ), // La liste des blocs suit son contenu : l'écran de section défile déjà. (eventDTO.programme == null || eventDTO.programme!.isEmpty) ? Padding( padding: const EdgeInsets.symmetric(vertical: kSpace7), child: Center( child: Text(AppLocalizations.of(context)!.noBlocks, style: kTextHint))) : ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: eventDTO.programme!.length, itemBuilder: (context, index) { final block = eventDTO.programme![index]; return Container( margin: const EdgeInsets.only(bottom: kSpace2), padding: const EdgeInsets.symmetric( horizontal: kSpace4, vertical: kSpace3), decoration: BoxDecoration( color: kSurface, border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusCard), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ block.title != null && block.title!.isNotEmpty ? HtmlWidget( block.title! .firstWhere( (t) => t.language == 'FR', orElse: () => block.title![0]) .value ?? "${AppLocalizations.of(context)!.blockFallback} ${index + 1}", textStyle: kTextSmall, ) : Text( "${AppLocalizations.of(context)!.blockFallback} ${index + 1}", style: kTextSmall), Text( "${block.startTime != null ? DateFormat('HH:mm').format(block.startTime!.toLocal()) : '??'} - ${block.endTime != null ? DateFormat('HH:mm').format(block.endTime!.toLocal()) : '??'}", style: kTextHint), ], ), ), Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon(Icons.edit, color: kPrimaryColor), onPressed: () { final appContext = Provider.of( context, listen: false); showNewOrUpdateProgrammeBlock( context, block, (updatedBlock) async { try { final programmeBlockDTO = ProgrammeBlockDTO( id: updatedBlock.id, title: updatedBlock.title, description: updatedBlock.description, startTime: updatedBlock.startTime, endTime: updatedBlock.endTime, ); final resultDTO = await (appContext.getContext() as ManagerAppContext) .clientAPI! .sectionEventApi! .sectionEventUpdateProgrammeBlock( programmeBlockDTO); if (resultDTO != null) { final result = ProgrammeBlock( id: resultDTO.id, title: resultDTO.title, description: resultDTO.description, startTime: resultDTO.startTime, endTime: resultDTO.endTime, ); setState(() { eventDTO.programme![index] = result; widget.onChanged(eventDTO); }); showNotification( kSuccess, kWhite, AppLocalizations.of(context)!.programmeBlockUpdatedSuccess, context, null); } } catch (e) { showNotification( kError, kWhite, AppLocalizations.of(context)!.programmeBlockUpdateError, context, null); } }, ); }, ), IconButton( icon: Icon(Icons.delete, color: kError), onPressed: () async { final appContext = Provider.of( context, listen: false); try { if (block.id != null) { await (appContext.getContext() as ManagerAppContext) .clientAPI! .sectionEventApi! .sectionEventDeleteProgrammeBlock( block.id!); } setState(() { eventDTO.programme!.removeAt(index); widget.onChanged(eventDTO); }); showNotification( kSuccess, kWhite, AppLocalizations.of(context)!.programmeBlockDeletedSuccess, context, null); } catch (e) { showNotification( kError, kWhite, AppLocalizations.of(context)!.programmeBlockDeleteError, context, null); } }, ), ], ), ], ), ); }, ), const SizedBox(height: kSpace6), // --- Parcours --- if (eventDTO.id != null) ParcoursConfig( initialValue: const [], parentId: eventDTO.id!, isEvent: true, onChanged: (paths) {}, ), ], ); } Widget _buildBaseSectionMapSection() { final mapItems = ['Aucune', ...availableMaps.map((m) => m.label ?? m.id ?? '')]; final currentValue = eventDTO.baseSectionMapId != null ? (availableMaps .where((m) => m.id == eventDTO.baseSectionMapId) .map((m) => m.label ?? m.id ?? '') .firstOrNull ?? 'Aucune') : 'Aucune'; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(AppLocalizations.of(context)!.baseMapLabel, style: kLabelField), DropDownInputContainer( label: AppLocalizations.of(context)!.mapLabel, values: mapItems, initialValue: currentValue, onChange: (val) { setState(() { if (val == 'Aucune') { eventDTO.baseSectionMapId = null; } else { final match = availableMaps.firstWhere( (m) => (m.label ?? m.id ?? '') == val, orElse: () => availableMaps.first); eventDTO.baseSectionMapId = match.id; } widget.onChanged(eventDTO); }); }, ), ], ), ); } /// Les annotations du plan, posées sur le canevas au lieu d'une modale. /// /// ⚠️ `MapAnnotationDTO` portait déjà un champ `geometry` que l'ancienne /// modale ne remplissait jamais : elle ne réglait que `geometryType`. Une /// annotation déclarait donc une forme sans jamais recevoir de coordonnées. /// Le canevas les lui donne. Widget _buildGlobalAnnotationsSection() { final l = AppLocalizations.of(context)!; final annotations = eventDTO.globalMapAnnotations ?? []; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l.globalAnnotationsLabel, style: kLabelField), const SizedBox(height: kSpace2), if (eventDTO.id == null) Padding( padding: const EdgeInsets.symmetric(vertical: kSpace5), child: Text(l.parcoursSaveSectionFirst, style: kTextHint), ) else CollectionEditor( items: annotations, addLabel: l.addAnnotation, itemLabel: (annotation, index) => _annotationLabel(l, annotation, index), createItem: () => MapAnnotationDTO(), setOrder: (annotation, order) {}, onChanged: (updated) { eventDTO.globalMapAnnotations = updated; widget.onChanged(eventDTO); }, remote: RemoteCollection( create: () async { try { final created = await _eventApi() .sectionEventCreateGlobalMapAnnotation( eventDTO.id!, _emptyAnnotation()); if (created != null && mounted) { showNotification(kSuccess, kWhite, l.annotationCreatedSuccess, context, null); } return created; } catch (e) { showNotification( kError, kWhite, l.annotationCreateError, context, null); return null; } }, delete: (annotation) async { try { if (annotation.id != null) { await _eventApi() .sectionEventDeleteMapAnnotation(annotation.id!); } showNotification(kSuccess, kWhite, l.annotationDeletedSuccess, context, null); return true; } catch (e) { showNotification( kError, kWhite, l.annotationDeleteError, context, null); return false; } }, // Les annotations n'ont pas d'ordre : rien à écrire. reorder: (ordered) async => true, ), detailBuilder: (annotation, index) => _AnnotationFields( key: ValueKey(annotation.id ?? identityHashCode(annotation)), annotation: annotation, others: annotations .where((a) => a.id != annotation.id && a.geometry != null) .toList(), onChanged: () => _saveAnnotation(annotation), ), ), ], ), ); } SectionEventApi _eventApi() { return (Provider.of(context, listen: false).getContext() as ManagerAppContext) .clientAPI! .sectionEventApi!; } MapAnnotationDTO _emptyAnnotation() { final languages = (Provider.of(context, listen: false) .getContext() as ManagerAppContext) .selectedConfiguration! .languages!; return MapAnnotationDTO( label: [ for (final language in languages) TranslationDTO(language: language, value: "") ], type: [ for (final language in languages) TranslationDTO(language: language, value: "") ], geometryType: GeometryType.number0, polyColor: '#FF0000', ); } String _annotationLabel( AppLocalizations l, MapAnnotationDTO annotation, int index) { final labels = annotation.label ?? []; if (labels.isEmpty) return '${l.annotationFallback} ${index + 1}'; final value = labels .firstWhere((t) => t.language == 'FR', orElse: () => labels.first) .value ?? ""; final plain = value.replaceAll(RegExp(r'<[^>]*>'), ' ').trim(); return plain.isEmpty ? '${l.annotationFallback} ${index + 1}' : plain; } Future _saveAnnotation(MapAnnotationDTO annotation) async { final l = AppLocalizations.of(context)!; try { await _eventApi().sectionEventUpdateMapAnnotation(annotation); } catch (e) { showNotification(kError, kWhite, l.annotationUpdateError, context, null); } } } /// Les champs d'une annotation, plus le canevas qui lui donne sa position. class _AnnotationFields extends StatefulWidget { const _AnnotationFields({ Key? key, required this.annotation, required this.others, required this.onChanged, }) : super(key: key); final MapAnnotationDTO annotation; final List others; final VoidCallback onChanged; @override State<_AnnotationFields> createState() => _AnnotationFieldsState(); } class _AnnotationFieldsState extends State<_AnnotationFields> { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final annotation = widget.annotation; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LayoutBuilder( builder: (context, constraints) { final label = MultiStringInputContainer( label: l.annotationLabelColon, modalLabel: l.annotationLabelModal, initialValue: annotation.label ?? [], onGetResult: (val) { setState(() => annotation.label = val); widget.onChanged(); }, maxLines: 1, isTitle: true, isHTML: false, showPreview: true, ); final icon = StringInputContainer( label: l.materialIconLabel, initialValue: annotation.icon ?? '', onChanged: (val) { annotation.icon = val.isEmpty ? null : val; widget.onChanged(); }, ); if (constraints.maxWidth < 520) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [label, const SizedBox(height: kSpace5), icon], ); } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(flex: 2, child: label), const SizedBox(width: kSpace5), Expanded(child: icon), ], ); }, ), const SizedBox(height: kSpace5), MapCanvas( geometry: _toGeometryDTO(annotation.geometry), color: annotation.polyColor, height: 380, // Comme les points de carte, les annotations suivent GeoJSON. coordinateOrder: MapCoordinateOrder.lngLat, ghosts: [ for (final other in widget.others) MapCanvasGhost( geometry: _toGeometryDTO(other.geometry)!, color: other.polyColor), ], onChanged: (geometry, color) { annotation.geometry = EventAddressDTOGeometry( type: geometry.type, coordinates: geometry.coordinates, ); annotation.geometryType = _toGeometryType(geometry.type); annotation.polyColor = color; widget.onChanged(); }, ), ], ); } } /// Une date d'événement, au format des autres champs : libellé au-dessus, /// valeur dans un champ bordé. C'était un `ListTile`, qui écrivait le libellé /// comme un titre et la date comme un sous-titre. class _DateField extends StatelessWidget { const _DateField({ required this.label, required this.value, required this.onTap, }); final String label; final String value; final VoidCallback onTap; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: kLabelField), const SizedBox(height: kSpace2), Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(kRadiusInput), onTap: onTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: kSpace4, vertical: kSpace3), decoration: BoxDecoration( color: kSurface2, border: Border.all(color: kLine), borderRadius: BorderRadius.circular(kRadiusInput), ), child: Row( children: [ Expanded( child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: kInk)), ), const Icon(Icons.calendar_today_outlined, size: 15, color: kInk3), ], ), ), ), ), ], ); } } /// `MapAnnotationDTO` porte sa géométrie dans `EventAddressDTOGeometry`, le /// canevas parle `GeometryDTO` : même forme, deux classes générées. GeometryDTO? _toGeometryDTO(EventAddressDTOGeometry? geometry) { if (geometry == null) return null; return GeometryDTO(type: geometry.type, coordinates: geometry.coordinates); } GeometryType _toGeometryType(String? type) { switch (type) { case "LineString": return GeometryType.number1; case "Polygon": return GeometryType.number3; default: return GeometryType.number0; } }