Thomas Fransolet 5066b6c5e3 Lot E : PDF en média d'étape, et QuestionType nommé
PDF en média d'étape — la liste de types du sélecteur de médias devient un
paramètre (`kSliderContentResourceTypes` par défaut) et l'étape de parcours
passe `kGuidedStepResourceTypes`, soit les types du slider plus Pdf.

QuestionType — le client généré nomme ses valeurs number0/1/2, ce qui
poussait le front à comparer des entiers bruts (`?.value == 2 ? 'Puzzle'`).
Alias nommés d'après l'enum serveur ajoutés à la main dans
question_type.dart (simple / multipleChoice / puzzle), `values` inchangé :
ce sont les mêmes trois valeurs. Les 7 usages number* du dialogue de
question sont migrés avec.

Fournisseur de carte (W1) — décision : 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 ; il est donc
conservé et documenté dans l'interface comme réglage mobile/tablette.

flutter build web , analyse du dossier sans erreur ni warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:31:07 +02:00

754 lines
35 KiB
Dart

import 'package:diacritic/diacritic.dart';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.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/fetch_section_icon.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/showNewOrUpdateGeoPoint.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) {
final appContext = Provider.of<AppContext>(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:
mapProviderIn = "Google";
break;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildMapHeader(size, mapProviderIn),
SizedBox(
height: 700,
child: SingleChildScrollView(
child: Column(
children: [
FutureBuilder(
future: _geoPointsFuture,
builder: (context, AsyncSnapshot<dynamic> snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return Center(child: CommonLoader());
} else {
if (snapshot.connectionState ==
ConnectionState.done) {
mapDTO.points = snapshot.data;
pointsToShow = mapDTO.points!;
pointsToShow.sort((a, b) => a.title!
.firstWhere((t) => t.language == 'FR')
.value!
.toLowerCase()
.compareTo(b.title!
.firstWhere((t) => t.language == 'FR')
.value!
.toLowerCase()));
return Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
border: Border.all(
width: 1.5, color: kSecond)),
child: Stack(children: [
Container(
constraints:
BoxConstraints(minHeight: 100),
child: Padding(
padding: const EdgeInsets.only(
top: 95,
left: 10,
right: 10,
bottom: 10),
child:
ValueListenableBuilder<String?>(
valueListenable:
searchNotifier,
builder: (context,
searchValue, child) {
return ValueListenableBuilder<
List<int>?>(
valueListenable:
selectedCategoriesNotifier,
builder: (context,
selectedCategories,
child) {
if (selectedCategories ==
null ||
selectedCategories
.length ==
0) {
pointsToShow = mapDTO
.points!
.where((point) =>
point
.categorieId ==
null)
.toList();
} else {
pointsToShow = mapDTO
.points!
.where((point) =>
selectedCategories.any((tps) =>
point.categorieId ==
null ||
point.categorieId ==
tps))
.toList();
}
pointsToShow = searchValue !=
null &&
searchValue
.trim()
.isNotEmpty
? pointsToShow
.where((GeoPointDTO pointGeo) => removeDiacritics(pointGeo
.title!
.firstWhere((t) =>
t.language ==
"FR")
.value!
.toUpperCase())
.contains(
removeDiacritics(
searchValue.toUpperCase())))
.toList()
: pointsToShow;
return GridView
.builder(
shrinkWrap:
true,
gridDelegate:
new SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
8),
itemCount:
pointsToShow
.length,
itemBuilder:
(BuildContext
context,
int index) {
return Container(
decoration: boxDecoration(
pointsToShow[
index],
appContext),
padding:
const EdgeInsets
.all(
5),
margin: EdgeInsets.symmetric(
vertical:
10,
horizontal:
10),
child: getElement(
index,
pointsToShow[
index],
size,
appContext),
);
});
});
}),
),
),
Positioned(
top: 10,
left: 10,
child: Text(
AppLocalizations.of(context)!.geopointsLabel,
style: TextStyle(fontSize: 15),
),
),
Positioned(
top: 10,
left: 175,
child: MultiSelectContainer(
label: null,
color: kSecond,
width: size.width * 0.45,
initialValue: mapDTO.categories!
.where((cat) =>
selectedCategoriesNotifier
.value!
.contains(cat.id))
.map((categorie) => categorie
.label!
.firstWhere((element) =>
element.language ==
'FR')
.value!)
.toList(),
isMultiple: true,
isHTMLLabel: true,
values: mapDTO.categories!
.map((categorie) => categorie
.label!
.firstWhere((element) =>
element.language ==
'FR')
.value!)
.toList(),
onChanged: (value) {
var tempOutput =
new List<String>.from(value);
selectedCategoriesNotifier.value =
mapDTO.categories!
.where((c) => tempOutput
.contains(c.label!
.firstWhere(
(element) =>
element
.language ==
'FR')
.value!))
.map((cat) => cat.id!)
.toList();
},
)),
Positioned(
top: 0,
right: 150,
child: Container(
width: size.width * 0.2,
height: size.height * 0.1,
constraints:
BoxConstraints(minHeight: 85),
child: StringInputContainer(
label: AppLocalizations.of(context)!.searchLabel,
isSmall: true,
fontSize: 15,
fontSizeText: 15,
onChanged: (String value) {
searchNotifier.value = value;
},
),
),
),
Positioned(
top: 10,
right: 10,
child: InkWell(
onTap: () {
showNewOrUpdateGeoPoint(
mapDTO, null,
(GeoPointDTO geoPoint) async {
try {
await (appContext.getContext()
as ManagerAppContext)
.clientAPI!
.sectionMapApi!
.sectionMapCreate(
mapDTO.id!, geoPoint);
showNotification(
kSuccess,
kWhite,
AppLocalizations.of(context)!.geopointCreatedSuccess,
context,
null);
setState(() {
_geoPointsFuture = _loadGeoPoints();
});
} catch (e) {
showNotification(
kError,
kWhite,
AppLocalizations.of(context)!.geopointCreateError,
context,
null);
}
}, appContext, context);
},
child: Container(
height: 40,
width: 40,
child: Icon(
Icons.add,
color: kTextLightColor,
size: 25.0,
),
decoration: BoxDecoration(
color: kSuccess,
shape: BoxShape.rectangle,
borderRadius:
BorderRadius.circular(20.0),
boxShadow: [
BoxShadow(
color: kSecond,
spreadRadius: 0.5,
blurRadius: 5,
offset: Offset(0,
1.5), // changes position of shadow
),
],
),
),
),
)
]),
),
);
} else {
return Center(
child: Text(
AppLocalizations.of(context)!.geopointsLoadError),
);
}
}
}),
],
),
),
),
],
);
}
Future<List<GeoPointDTO>?> getGeoPoints(Client client) async {
List<GeoPointDTO>? geoPoints = await client.sectionMapApi!
.sectionMapGetAllGeoPointsFromSection(widget.initialValue.id!);
return geoPoints ?? [];
}
getElement(int index, GeoPointDTO? point, Size size, AppContext appContext) {
return Container(
width: double.infinity,
height: double.infinity,
child: Stack(
children: [
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: HtmlWidget(
point != null
? point.title == null
? ""
: point.title![0].value!
: "",
//textAlign: TextAlign.left,
customStylesBuilder: (element) {
return {'text-align': 'center'};
}, textStyle: TextStyle(fontSize: 20)),
),
),
Positioned(
top: 0,
right: 0,
child: Icon(
getSectionIcon(SectionType.Map),
color: kSecond,
size: 18.0,
),
),
Positioned(
bottom: 0,
left: 0,
child: InkWell(
onTap: () {
showNewOrUpdateGeoPoint(mapDTO, pointsToShow[index],
(GeoPointDTO geoPoint) async {
try {
await (appContext.getContext() as ManagerAppContext)
.clientAPI!
.sectionMapApi!
.sectionMapUpdate(geoPoint);
showNotification(
kSuccess,
kWhite,
AppLocalizations.of(context)!.geopointUpdatedSuccess,
context,
null);
setState(() {
// refresh ui
print("Refresh UI");
});
} catch (e) {
showNotification(
kError,
kWhite,
AppLocalizations.of(context)!.geopointUpdateError,
context,
null);
}
/*setState(() {
var pointToUpdate = pointsToShow[index];
var pointToUpdateMapDTOPoints = mapDTO.points!.firstWhere((p) => p.longitude == pointToUpdate.longitude && p.latitude == pointToUpdate.latitude);
var mapDTOPointsIndex = mapDTO.points!.indexOf(pointToUpdateMapDTOPoints);
mapDTO.points![mapDTOPointsIndex] = pointToUpdate;
mapDTO.points!.sort((a, b) => a.title!.firstWhere((t) => t.language == 'FR').value!.toLowerCase().compareTo(b.title!.firstWhere((t) => t.language == 'FR').value!.toLowerCase()));
//widget.onChanged(jsonEncode(mapDTO).toString());
widget.onChanged(mapDTO);
});*/
}, appContext, context);
},
child: Icon(
Icons.edit,
color: kPrimaryColor,
size: 20.0,
)),
),
Positioned(
bottom: 0,
right: 0,
child: InkWell(
onTap: () async {
showConfirmationDialog(
AppLocalizations.of(context)!.geopointDeleteConfirm,
() {}, () async {
try {
var pointToRemove = pointsToShow[index];
(appContext.getContext() as ManagerAppContext)
.clientAPI!
.sectionMapApi!
.sectionMapDelete(pointToRemove.id!);
showNotification(
kSuccess,
kWhite,
AppLocalizations.of(context)!.geopointDeletedSuccess,
context,
null);
// refresh UI
setState(() {
print("Refresh UI");
});
} catch (e) {
showNotification(
kError,
kWhite,
AppLocalizations.of(context)!.geopointDeleteError,
context,
null);
}
}, context);
},
child: Icon(
Icons.delete,
color: kError,
size: 20.0,
)),
)
],
),
);
}
Widget _buildMapHeader(Size size, String mapProviderIn) {
return Container(
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 2,
blurRadius: 5,
offset: Offset(0, 3),
),
],
border: Border.all(color: kPrimaryColor.withOpacity(0.2)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
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: 8),
// 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: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
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);
},
),
Container(
height: 70,
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);
}
},
),
)
],
),
],
),
);
}
}
boxDecoration(GeoPointDTO geoPointDTO, appContext) {
return BoxDecoration(
color: kBackgroundColor,
shape: BoxShape.rectangle,
border: Border.all(width: 1.5, color: kSecond),
borderRadius: BorderRadius.circular(10.0),
boxShadow: [
BoxShadow(
color: kSecond,
spreadRadius: 0.5,
blurRadius: 5,
offset: Offset(0, 1.5), // changes position of shadow
),
],
);
}