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/Components/multi_select_container.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() { return Wrap( spacing: kSpace5, runSpacing: kSpace4, crossAxisAlignment: WrapCrossAlignment.end, children: [ SizedBox( width: 240, child: StringInputContainer( label: AppLocalizations.of(context)!.searchLabel, onChanged: (String value) => searchNotifier.value = value, ), ), if (mapDTO.categories != null && mapDTO.categories!.isNotEmpty) SizedBox( width: 320, child: MultiSelectContainer( label: AppLocalizations.of(context)!.categoriesLabel, color: kSecond, initialValue: mapDTO.categories! .where((cat) => selectedCategoriesNotifier.value!.contains(cat.id)) .map((categorie) => _categoryLabel(categorie)) .toList(), isMultiple: true, isHTMLLabel: true, values: mapDTO.categories! .map((categorie) => _categoryLabel(categorie)) .toList(), onChanged: (value) { var tempOutput = new List.from(value); selectedCategoriesNotifier.value = mapDTO.categories! .where((c) => tempOutput.contains(_categoryLabel(c))) .map((cat) => cat.id!) .toList(); }, ), ), ], ); } String _categoryLabel(CategorieDTO categorie) { final labels = categorie.label ?? []; if (labels.isEmpty) return ""; return labels .firstWhere((element) => element.language == 'FR', orElse: () => labels.first) .value ?? ""; } /// 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) { return Container( padding: kCardPadding, margin: const EdgeInsets.only(bottom: kSpace5), decoration: BoxDecoration( color: kSurface, borderRadius: BorderRadius.circular(kRadiusCard), border: Border.all(color: kLine), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Wrap( spacing: kSpace6, runSpacing: kSpace5, crossAxisAlignment: WrapCrossAlignment.start, children: [ SingleSelectContainer( label: AppLocalizations.of(context)!.serviceLabel, color: Colors.black, initialValue: mapProviderIn, inputValues: map_providers, onChanged: (String value) { switch (value) { case "Google": mapDTO.mapProvider = MapProvider.Google; break; case "MapBox": mapDTO.mapProvider = MapProvider.MapBox; break; } widget.onChanged(mapDTO); }), GeolocInputContainer( label: AppLocalizations.of(context)!.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), ResourceInputContainer( label: AppLocalizations.of(context)!.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), ], ), const SizedBox(height: kSpace3), // 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. // On le dit donc au client au lieu de le laisser croire le contraire. Align( alignment: Alignment.centerLeft, child: Text( AppLocalizations.of(context)!.mapProviderMobileOnlyNote, style: kTextHint, ), ), const SizedBox(height: kSpace6), Wrap( spacing: kSpace6, runSpacing: kSpace5, crossAxisAlignment: WrapCrossAlignment.start, children: [ CheckInputContainer( label: AppLocalizations.of(context)!.listViewLabel, isChecked: mapDTO.isListViewEnabled ?? false, onChanged: (value) { setState(() { mapDTO.isListViewEnabled = value; widget.onChanged(mapDTO); }); }, ), if (mapDTO.mapProvider == MapProvider.Google) DropDownInputContainer( label: AppLocalizations.of(context)!.typeLabel, values: map_types, initialValue: mapType, onChange: (String? value) { mapDTO.mapType = MapTypeApp.fromJson(value); widget.onChanged(mapDTO); }, ), if (mapDTO.mapProvider == MapProvider.MapBox) DropDownInputContainer( label: AppLocalizations.of(context)!.typeLabel, values: map_types_mapBox, initialValue: mapTypeMapBox, onChange: (String? value) { mapDTO.mapTypeMapbox = MapTypeMapBox.fromJson(value); widget.onChanged(mapDTO); }, ), SliderInputContainer( label: AppLocalizations.of(context)!.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); }, ), SizedBox( width: 220, child: CategoryInputContainer( label: AppLocalizations.of(context)!.categoriesLabel, initialValue: mapDTO.categories != null ? mapDTO.categories! : [], color: kPrimaryColor, onChanged: (List? value) { if (value != null) { mapDTO.categories = value; if (mapDTO.points != null) { 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); } }, ), ) ], ), ], ), ); } }