514 lines
18 KiB
Dart

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<MapDTO> 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<MapConfig> {
late MapDTO mapDTO;
late List<GeoPointDTO> pointsToShow = [];
String mapType = "hybrid";
String mapTypeMapBox = "standard";
String filterSearch = '';
final ValueNotifier<List<int>?> selectedCategoriesNotifier =
ValueNotifier([]);
final ValueNotifier<String?> searchNotifier = ValueNotifier("");
late Future<List<GeoPointDTO>?> _geoPointsFuture;
bool _geoPointsInitialized = false;
Future<List<GeoPointDTO>?> _loadGeoPoints() {
final appContext = Provider.of<AppContext>(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<dynamic> 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 ?? <GeoPointDTO>[];
return ValueListenableBuilder<String?>(
valueListenable: searchNotifier,
builder: (context, searchValue, child) {
return ValueListenableBuilder<List<int>?>(
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<GeoPointDTO> _filterPoints(
List<int>? selectedCategories, String? searchValue) {
var points = mapDTO.points ?? <GeoPointDTO>[];
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<String>.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<void> _savePoint(GeoPointDTO point) async {
final l = AppLocalizations.of(context)!;
try {
await (Provider.of<AppContext>(context, listen: false).getContext()
as ManagerAppContext)
.clientAPI!
.sectionMapApi!
.sectionMapUpdate(point);
} catch (e) {
showNotification(kError, kWhite, l.geopointUpdateError, context, null);
}
}
Future<void> _createPoint() async {
final l = AppLocalizations.of(context)!;
final appContext = Provider.of<AppContext>(context, listen: false);
final managerContext = appContext.getContext() as ManagerAppContext;
final languages = managerContext.selectedConfiguration!.languages!;
final point = GeoPointDTO();
point.title = <TranslationDTO>[];
point.description = <TranslationDTO>[];
point.schedules = <TranslationDTO>[];
point.prices = <TranslationDTO>[];
point.phone = <TranslationDTO>[];
point.email = <TranslationDTO>[];
point.site = <TranslationDTO>[];
point.contents = <ContentDTO>[];
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<void> _deletePoint(GeoPointDTO point) async {
final l = AppLocalizations.of(context)!;
showConfirmationDialog(l.geopointDeleteConfirm, () {}, () async {
try {
await (Provider.of<AppContext>(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<List<GeoPointDTO>?> getGeoPoints(Client client) async {
List<GeoPointDTO>? 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<CategorieDTO>? 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);
}
},
),
)
],
),
],
),
);
}
}