Thomas Fransolet 0054122829 Carte : filtre par categorie, editeur de point, et corrections users/quotas
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>
2026-09-08 16:22:39 +02:00

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),
],
);
},
),
],
),
);
}
}