Regroupe la configuration de carte en deux blocs (Carte / Points d'interet), ajoute le filtre par categorie et l'editeur de point geographique, et passe les ecrans Users et les jauges de quota par AppLocalizations (FR/EN/NL). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
551 lines
18 KiB
Dart
551 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/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<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() {
|
|
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<List<int>?>(
|
|
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<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) {
|
|
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<CategorieDTO>? 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<Widget> 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),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|