import 'package:diacritic/diacritic.dart'; import 'package:flutter/material.dart'; import 'package:location_picker_flutter_map/location_picker_flutter_map.dart'; import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/geoloc_input_container.dart'; import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/Configurations/Section/SubSection/Map/category_input_container.dart'; import 'package:manager_app/Components/dropDown_input_container.dart'; import 'package:manager_app/Components/resource_input_container.dart'; import 'package:manager_app/Screens/Configurations/Section/SubSection/Map/category_filter.dart'; import 'package:manager_app/Components/single_select_container.dart'; import 'package:manager_app/Components/check_input_container.dart'; import 'package:manager_app/Components/slider_input_container.dart'; import 'package:manager_app/Components/string_input_container.dart'; import 'package:manager_app/Screens/Configurations/Section/SubSection/Map/geo_point_editor.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/client.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_api_new/api.dart'; import 'package:provider/provider.dart'; class MapConfig extends StatefulWidget { final String? color; final String? label; final MapDTO initialValue; final ValueChanged onChanged; const MapConfig({ Key? key, this.color, this.label, required this.initialValue, required this.onChanged, }) : super(key: key); @override _MapConfigState createState() => _MapConfigState(); } class _MapConfigState extends State { late MapDTO mapDTO; late List pointsToShow = []; String mapType = "hybrid"; String mapTypeMapBox = "standard"; String filterSearch = ''; final ValueNotifier?> selectedCategoriesNotifier = ValueNotifier([]); final ValueNotifier searchNotifier = ValueNotifier(""); late Future?> _geoPointsFuture; bool _geoPointsInitialized = false; Future?> _loadGeoPoints() { final appContext = Provider.of(context, listen: false); return getGeoPoints((appContext.getContext() as ManagerAppContext).clientAPI!); } @override void initState() { super.initState(); mapDTO = widget.initialValue; if (mapDTO.mapType != null) { switch (mapDTO.mapType!.value) { case 0: mapType = "none"; break; case 1: mapType = "normal"; break; case 2: mapType = "satellite"; break; case 3: mapType = "terrain"; break; case 4: mapType = "hybrid"; break; } } if (mapDTO.mapTypeMapbox != null) { switch (mapDTO.mapTypeMapbox!.value) { case 0: mapTypeMapBox = "standard"; break; case 1: mapTypeMapBox = "streets"; break; case 2: mapTypeMapBox = "outdoors"; break; case 3: mapTypeMapBox = "light"; break; case 4: mapTypeMapBox = "dark"; break; case 5: mapTypeMapBox = "satellite"; break; case 6: mapTypeMapBox = "satellite_streets"; break; } } selectedCategoriesNotifier.value = mapDTO.categories?.map((c) => c.id!).toList() ?? []; } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_geoPointsInitialized) { _geoPointsInitialized = true; _geoPointsFuture = _loadGeoPoints(); } } @override Widget build(BuildContext context) { Size size = MediaQuery.of(context).size; var mapProviderIn = ""; switch (mapDTO.mapProvider) { case MapProvider.Google: mapProviderIn = "Google"; break; case MapProvider.MapBox: mapProviderIn = "MapBox"; break; default: // MapBox par défaut : seul fournisseur dont les tuiles peuvent être // embarquées pour une visite hors ligne. mapProviderIn = "MapBox"; break; } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildMapHeader(size, mapProviderIn), Text(AppLocalizations.of(context)!.geopointsLabel, style: kLabelField), const SizedBox(height: kSpace2), FutureBuilder( future: _geoPointsFuture, builder: (context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.all(kSpace8), child: Center(child: CommonLoader()), ); } if (snapshot.connectionState != ConnectionState.done) { return Center( child: Text( AppLocalizations.of(context)!.geopointsLoadError)); } mapDTO.points = snapshot.data ?? []; return ValueListenableBuilder( valueListenable: searchNotifier, builder: (context, searchValue, child) { return ValueListenableBuilder?>( valueListenable: selectedCategoriesNotifier, builder: (context, selectedCategories, child) { pointsToShow = _filterPoints(selectedCategories, searchValue); return GeoPointEditor( mapDTO: mapDTO, points: mapDTO.points!, visiblePoints: pointsToShow, toolbar: _buildPointsToolbar(), onPointChanged: _savePoint, onCreate: _createPoint, onDelete: _deletePoint, ); }, ); }, ); }, ), ], ); } /// La recherche et les catégories cachent les mêmes points dans la liste et /// sur la carte : le filtre est calculé une fois, ici. List _filterPoints( List? selectedCategories, String? searchValue) { var points = mapDTO.points ?? []; if (selectedCategories != null && selectedCategories.isNotEmpty) { points = points .where((point) => point.categorieId == null || selectedCategories.contains(point.categorieId)) .toList(); } if (searchValue != null && searchValue.trim().isNotEmpty) { points = points.where((point) { final titles = point.title ?? []; if (titles.isEmpty) return false; final value = titles .firstWhere((t) => t.language == "FR", orElse: () => titles.first) .value ?? ""; return removeDiacritics(value.toUpperCase()) .contains(removeDiacritics(searchValue.toUpperCase())); }).toList(); } return points; } Widget _buildPointsToolbar() { final categories = mapDTO.categories ?? []; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 240, child: StringInputContainer( label: AppLocalizations.of(context)!.searchLabel, onChanged: (String value) => searchNotifier.value = value, ), ), if (categories.isNotEmpty) ...[ const SizedBox(width: kSpace6), // Prend la largeur restante pour que les puces reviennent à la ligne // au lieu de sortir du cadre. Expanded( child: ValueListenableBuilder?>( valueListenable: selectedCategoriesNotifier, builder: (context, selected, _) => CategoryFilter( categories: categories, selectedIds: selected ?? const [], onChanged: (ids) => selectedCategoriesNotifier.value = ids, ), ), ), ], ], ); } /// Un point vit côté serveur : chaque champ validé et chaque déplacement sur /// la carte part tout de suite. Future _savePoint(GeoPointDTO point) async { final l = AppLocalizations.of(context)!; try { await (Provider.of(context, listen: false).getContext() as ManagerAppContext) .clientAPI! .sectionMapApi! .sectionMapUpdate(point); } catch (e) { showNotification(kError, kWhite, l.geopointUpdateError, context, null); } } Future _createPoint() async { final l = AppLocalizations.of(context)!; final appContext = Provider.of(context, listen: false); final managerContext = appContext.getContext() as ManagerAppContext; final languages = managerContext.selectedConfiguration!.languages!; final point = GeoPointDTO(); point.title = []; point.description = []; point.schedules = []; point.prices = []; point.phone = []; point.email = []; point.site = []; point.contents = []; for (final language in languages) { point.title!.add(TranslationDTO(language: language, value: "")); point.description!.add(TranslationDTO(language: language, value: "")); point.schedules!.add(TranslationDTO(language: language, value: "")); point.prices!.add(TranslationDTO(language: language, value: "")); point.phone!.add(TranslationDTO(language: language, value: "")); point.email!.add(TranslationDTO(language: language, value: "")); point.site!.add(TranslationDTO(language: language, value: "")); } try { await managerContext.clientAPI!.sectionMapApi! .sectionMapCreate(mapDTO.id!, point); showNotification( kSuccess, kWhite, l.geopointCreatedSuccess, context, null); setState(() => _geoPointsFuture = _loadGeoPoints()); } catch (e) { showNotification(kError, kWhite, l.geopointCreateError, context, null); } } Future _deletePoint(GeoPointDTO point) async { final l = AppLocalizations.of(context)!; showConfirmationDialog(l.geopointDeleteConfirm, () {}, () async { try { await (Provider.of(context, listen: false).getContext() as ManagerAppContext) .clientAPI! .sectionMapApi! .sectionMapDelete(point.id!); showNotification( kSuccess, kWhite, l.geopointDeletedSuccess, context, null); setState(() => _geoPointsFuture = _loadGeoPoints()); } catch (e) { showNotification(kError, kWhite, l.geopointDeleteError, context, null); } }, context, isDestructive: true); } Future?> getGeoPoints(Client client) async { List? geoPoints = await client.sectionMapApi! .sectionMapGetAllGeoPointsFromSection(widget.initialValue.id!); return geoPoints ?? []; } Widget _buildMapHeader(Size size, String mapProviderIn) { final l = AppLocalizations.of(context)!; // Décision du 2026-08-11 (lot E, W1) : visitapp-web reste sur Leaflet. Le // champ ne peut pas être masqué pour le web — il est porté par la section, // et une même configuration est servie au mobile comme au web. La note vit // sous le champ qu'elle qualifie, au lieu de flotter en pleine largeur. final service = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SingleSelectContainer( label: l.serviceLabel, color: Colors.black, initialValue: mapProviderIn, inputValues: map_providers, onChanged: (String value) { setState(() { switch (value) { case "Google": mapDTO.mapProvider = MapProvider.Google; break; case "MapBox": mapDTO.mapProvider = MapProvider.MapBox; break; } widget.onChanged(mapDTO); }); }), const SizedBox(height: kSpace2), Text(l.mapProviderMobileOnlyNote, style: kTextHint), ], ); final typeField = mapDTO.mapProvider == MapProvider.Google ? DropDownInputContainer( label: l.typeLabel, values: map_types, initialValue: mapType, onChange: (String? value) { mapDTO.mapType = MapTypeApp.fromJson(value); widget.onChanged(mapDTO); }, ) : DropDownInputContainer( label: l.typeLabel, values: map_types_mapBox, initialValue: mapTypeMapBox, onChange: (String? value) { mapDTO.mapTypeMapbox = MapTypeMapBox.fromJson(value); widget.onChanged(mapDTO); }, ); final centerPoint = GeolocInputContainer( label: l.centerPointLabel, initialValue: mapDTO.centerLatitude != null && mapDTO.centerLongitude != null ? LatLong(double.parse(mapDTO.centerLatitude!), double.parse(mapDTO.centerLongitude!)) : null, color: kPrimaryColor, onChanged: (LatLong? localisation) { if (localisation != null) { mapDTO.centerLongitude = localisation.longitude.toString(); mapDTO.centerLatitude = localisation.latitude.toString(); } widget.onChanged(mapDTO); }, isSmall: true); final zoom = SliderInputContainer( label: l.zoomLabel, initialValue: mapDTO.zoom != null ? mapDTO.zoom!.toDouble() : 18, color: kPrimaryColor, min: 0, max: 30, onChanged: (double value) { mapDTO.zoom = value.toInt(); widget.onChanged(mapDTO); }, ); final icon = ResourceInputContainer( label: l.iconLabel, initialValue: mapDTO.iconResourceId, color: kPrimaryColor, imageFit: BoxFit.contain, onChanged: (ResourceDTO resource) { if (resource.id == null) { mapDTO.iconSource = null; mapDTO.iconResourceId = null; } else { mapDTO.iconResourceId = resource.id; mapDTO.iconSource = resource.url; } widget.onChanged(mapDTO); }, isSmall: true); final categories = CategoryInputContainer( label: l.categoriesLabel, initialValue: mapDTO.categories ?? [], color: kPrimaryColor, onChanged: (List? value) { if (value == null) return; mapDTO.categories = value; mapDTO.points?.forEach((p) { if (p.categorieId != null && !mapDTO.categories! .map((c) => c.id) .any((e) => e != null && e == p.categorieId)) { p.categorieId = null; } }); widget.onChanged(mapDTO); }, ); final display = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(l.displayLabel, style: kLabelField), const SizedBox(height: kSpace2), CheckInputContainer( label: l.listViewOption, isChecked: mapDTO.isListViewEnabled ?? false, onChanged: (value) { setState(() { mapDTO.isListViewEnabled = value; widget.onChanged(mapDTO); }); }, ), ], ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _FieldGroup( title: l.mapGroupMap, fields: [service, typeField, centerPoint, zoom], ), const SizedBox(height: kSpace4), _FieldGroup( title: l.mapGroupPoints, fields: [icon, categories, display], ), const SizedBox(height: kSpace5), ], ); } } /// Un bloc de réglages : un intertitre, puis les champs répartis en colonnes de /// ~280 px (1, 2 ou 3 selon la place). Ils se posaient dans un `Wrap` libre, /// donc chacun prenait sa largeur naturelle et rien ne s'alignait d'une ligne /// à l'autre. class _FieldGroup extends StatelessWidget { const _FieldGroup({required this.title, required this.fields}); final String title; final List fields; static const double _minColumnWidth = 280; @override Widget build(BuildContext context) { return Container( padding: kCardPadding, decoration: BoxDecoration( color: kSurface, borderRadius: BorderRadius.circular(kRadiusCard), border: Border.all(color: kLine), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title.toUpperCase(), style: const TextStyle( fontSize: 10.5, fontWeight: FontWeight.w600, letterSpacing: 0.9, color: kInk3, ), ), const SizedBox(height: kSpace4), LayoutBuilder( builder: (context, constraints) { const spacing = kSpace6; final columns = ((constraints.maxWidth + spacing) / (_minColumnWidth + spacing)) .floor() .clamp(1, 3); final width = (constraints.maxWidth - spacing * (columns - 1)) / columns; return Wrap( spacing: spacing, runSpacing: kSpace6, crossAxisAlignment: WrapCrossAlignment.start, children: [ for (final field in fields) SizedBox(width: width, child: field), ], ); }, ), ], ), ); } }