diff --git a/lib/Components/quota_bars_widget.dart b/lib/Components/quota_bars_widget.dart index 1902d7c..c3dec45 100644 --- a/lib/Components/quota_bars_widget.dart +++ b/lib/Components/quota_bars_widget.dart @@ -15,13 +15,8 @@ class QuotaBarsWidget extends StatefulWidget { class _QuotaBarsWidgetState extends State { InstanceQuotaDTO? _quota; - bool _loading = true; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _fetchQuota()); - } + bool _loading = false; + String? _loadedInstanceId; Future _fetchQuota() async { final managerContext = Provider.of(context, listen: false).getContext() as ManagerAppContext; @@ -72,6 +67,17 @@ class _QuotaBarsWidgetState extends State { final isAssistant = managerContext.instanceDTO?.isAssistant ?? false; final l10n = AppLocalizations.of(context)!; + // Premier rendu, mais aussi changement d'instance par un SuperAdmin : sans + // ce test, les barres resteraient sur le stockage de l'instance précédente. + final instanceId = managerContext.instanceId; + if (instanceId != _loadedInstanceId) { + _loadedInstanceId = instanceId; + _loading = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchQuota(); + }); + } + if (_loading) { return const SizedBox( height: 8, diff --git a/lib/Screens/Configurations/Section/SubSection/Map/category_filter.dart b/lib/Screens/Configurations/Section/SubSection/Map/category_filter.dart new file mode 100644 index 0000000..5f6375b --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Map/category_filter.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:html/parser.dart' show parse; +import 'package:manager_app/constants.dart'; +import 'package:manager_app/l10n/app_localizations.dart'; +import 'package:manager_api_new/api.dart'; + +/// Filtre par catégorie de la liste des points. +/// +/// Il passait par `MultiSelectContainer` : un `ListView` horizontal dans un +/// `SingleChildScrollView` horizontal, largeur figée à 25 % de l'écran. Au-delà +/// de deux catégories les suivantes étaient hors champ, sans aucun indice. +/// Ici les puces reviennent à la ligne et toutes sont visibles. +class CategoryFilter extends StatelessWidget { + const CategoryFilter({ + Key? key, + required this.categories, + required this.selectedIds, + required this.onChanged, + }) : super(key: key); + + final List categories; + final List selectedIds; + final ValueChanged> onChanged; + + static String labelOf(CategorieDTO categorie) { + final labels = categorie.label ?? []; + if (labels.isEmpty) return ""; + final raw = labels + .firstWhere((t) => t.language == 'FR', orElse: () => labels.first) + .value ?? + ""; + return parse(raw).documentElement?.text.trim() ?? raw; + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text(l.categoriesLabel, style: kLabelField), + if (selectedIds.isNotEmpty) ...[ + const SizedBox(width: kSpace3), + _ClearButton(onPressed: () => onChanged([])), + ], + ], + ), + const SizedBox(height: kSpace2), + Wrap( + spacing: kSpace2, + runSpacing: kSpace2, + children: [ + for (final categorie in categories) + _CategoryChip( + label: labelOf(categorie), + isSelected: selectedIds.contains(categorie.id), + onTap: () { + final next = List.from(selectedIds); + next.contains(categorie.id) + ? next.remove(categorie.id) + : next.add(categorie.id!); + onChanged(next); + }, + ), + ], + ), + ], + ); + } +} + +class _CategoryChip extends StatelessWidget { + const _CategoryChip({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(kRadiusInput), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric( + horizontal: kSpace3, vertical: kSpace2), + decoration: BoxDecoration( + color: isSelected ? kPrimaryColor : kSurface, + border: Border.all(color: isSelected ? kPrimaryColor : kLine), + borderRadius: BorderRadius.circular(kRadiusInput), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + const Icon(Icons.check, size: 13, color: kWhite), + const SizedBox(width: kSpace2), + ], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? kWhite : kInk, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ClearButton extends StatelessWidget { + const _ClearButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(kRadiusInput), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: kSpace2), + child: Text( + AppLocalizations.of(context)!.clearFilter, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: kPrimaryColor), + ), + ), + ), + ); + } +} diff --git a/lib/Screens/Configurations/Section/SubSection/Map/geo_point_editor.dart b/lib/Screens/Configurations/Section/SubSection/Map/geo_point_editor.dart index 8365a16..bf819b8 100644 --- a/lib/Screens/Configurations/Section/SubSection/Map/geo_point_editor.dart +++ b/lib/Screens/Configurations/Section/SubSection/Map/geo_point_editor.dart @@ -45,10 +45,34 @@ class GeoPointEditor extends StatefulWidget { State createState() => _GeoPointEditorState(); } +/// Une géométrie dessinée mais pas encore validée. +class _PendingGeometry { + const _PendingGeometry(this.geometry, this.color); + final GeometryDTO geometry; + final String color; +} + class _GeoPointEditorState extends State { int? selectedId; bool isMapExpanded = false; + /// Un déplacement sur la carte partait au serveur au relâchement de la + /// souris : le moindre glissement involontaire était écrit, et « Annuler » + /// de l'en-tête ne le défaisait pas. Il attend maintenant sa validation. + /// Gardé par point pour qu'un aller-retour dans la liste ne le perde pas. + final Map _pending = {}; + + void _commit(GeoPointDTO point) { + final pending = _pending[point.id]; + if (pending == null) return; + setState(() { + point.geometry = pending.geometry; + point.polyColor = pending.color; + _pending.remove(point.id); + }); + widget.onPointChanged(point); + } + GeoPointDTO? get _selected { if (widget.visiblePoints.isEmpty) return null; return widget.visiblePoints.firstWhere( @@ -265,12 +289,34 @@ class _GeoPointEditorState extends State { Widget _buildCanvas(AppLocalizations l, GeoPointDTO? selected, {required double height}) { + final pending = selected == null ? null : _pending[selected.id]; + return Padding( padding: const EdgeInsets.all(kSpace4), - child: MapCanvas( + child: Stack( + children: [ + _buildMapCanvas(l, selected, pending, height), + if (pending != null) + Positioned( + right: kSpace4, + bottom: kSpace8, + child: _PendingGeometryActions( + onConfirm: () => _commit(selected!), + onDiscard: () => + setState(() => _pending.remove(selected!.id)), + ), + ), + ], + ), + ); + } + + Widget _buildMapCanvas(AppLocalizations l, GeoPointDTO? selected, + _PendingGeometry? pending, double height) { + return MapCanvas( key: ValueKey(selected?.id ?? 'none'), - geometry: selected?.geometry, - color: selected?.polyColor, + geometry: pending?.geometry ?? selected?.geometry, + color: pending?.color ?? selected?.polyColor, height: height, // Les points d'une carte sont stockés en GeoJSON — voir // `visitapp-web/src/lib/geo.ts`, qui les lit en `[lng, lat]`. @@ -282,18 +328,18 @@ class _GeoPointEditorState extends State { : _label(l, selected, widget.visiblePoints.indexOf(selected)), ghosts: [ for (final point in widget.visiblePoints) - if (point.id != selected?.id && point.geometry != null) + if (point.id != selected?.id && + (_pending[point.id]?.geometry ?? point.geometry) != null) MapCanvasGhost( - geometry: point.geometry!, color: point.polyColor), + geometry: _pending[point.id]?.geometry ?? point.geometry!, + color: _pending[point.id]?.color ?? point.polyColor), ], onChanged: (geometry, color) { if (selected == null) return; - selected.geometry = geometry; - selected.polyColor = color; - widget.onPointChanged(selected); + setState(() => + _pending[selected.id!] = _PendingGeometry(geometry, color)); }, - ), - ); + ); } Widget _buildForm(AppLocalizations l, GeoPointDTO? selected) { @@ -322,6 +368,54 @@ class _GeoPointEditorState extends State { } } +/// Posé sur la carte quand une position a bougé sans être écrite. +class _PendingGeometryActions extends StatelessWidget { + const _PendingGeometryActions({ + required this.onConfirm, + required this.onDiscard, + }); + + final VoidCallback onConfirm; + final VoidCallback onDiscard; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + + return Material( + elevation: 3, + borderRadius: BorderRadius.circular(kRadiusInput), + color: kSurface, + child: Padding( + padding: const EdgeInsets.all(kSpace2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: onDiscard, + child: Text(l.cancel, + style: const TextStyle(fontSize: 12, color: kInk3)), + ), + const SizedBox(width: kSpace1), + FilledButton.icon( + onPressed: onConfirm, + icon: const Icon(Icons.check, size: 15), + label: Text(l.geopointConfirmPosition, + style: const TextStyle(fontSize: 12)), + style: FilledButton.styleFrom( + backgroundColor: kPrimaryColor, + foregroundColor: kWhite, + padding: const EdgeInsets.symmetric( + horizontal: kSpace4, vertical: kSpace2), + ), + ), + ], + ), + ), + ); + } +} + /// Les champs d'un point, repris de `showNewOrUpdateGeoPoint`. /// /// ⚠️ Un point porte neuf champs traduits. Les quatre du haut sont presque diff --git a/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart b/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart index 1bec337..8328f5c 100644 --- a/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart +++ b/lib/Screens/Configurations/Section/SubSection/Map/map_config.dart @@ -9,7 +9,7 @@ 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/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'; @@ -226,10 +226,10 @@ class _MapConfigState extends State { } Widget _buildPointsToolbar() { - return Wrap( - spacing: kSpace5, - runSpacing: kSpace4, - crossAxisAlignment: WrapCrossAlignment.end, + final categories = mapDTO.categories ?? []; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 240, @@ -238,45 +238,25 @@ class _MapConfigState extends State { 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(); - }, + 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?>( + valueListenable: selectedCategoriesNotifier, + builder: (context, selected, _) => CategoryFilter( + categories: categories, + selectedIds: selected ?? const [], + onChanged: (ids) => selectedCategoriesNotifier.value = ids, + ), ), ), + ], ], ); } - 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 { @@ -353,158 +333,215 @@ class _MapConfigState extends State { } 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? 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 fields; + + static const double _minColumnWidth = 280; + + @override + Widget build(BuildContext context) { 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, + crossAxisAlignment: CrossAxisAlignment.start, 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, + Text( + title.toUpperCase(), + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + letterSpacing: 0.9, + color: kInk3, ), ), - 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); - } - }, - ), - ) - ], + 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), + ], + ); + }, ), ], ), diff --git a/lib/Screens/Configurations/Section/SubSection/Menu/menu_config.dart b/lib/Screens/Configurations/Section/SubSection/Menu/menu_config.dart index a164692..0356f54 100644 --- a/lib/Screens/Configurations/Section/SubSection/Menu/menu_config.dart +++ b/lib/Screens/Configurations/Section/SubSection/Menu/menu_config.dart @@ -143,6 +143,26 @@ class _MenuConfigState extends State { } } + Future _toggleVisibility(SectionDTO section) async { + final l = AppLocalizations.of(context)!; + final newValue = section.isActive == false; + + setState(() => section.isActive = newValue); + + try { + await _api().sectionSetVisibility(section.id!, newValue); + showNotification( + kSuccess, + kWhite, + newValue ? l.sectionShownSuccess : l.sectionHiddenSuccess, + context, + null); + } catch (e) { + setState(() => section.isActive = !newValue); + showNotification(kError, kWhite, l.sectionVisibilityError, context, null); + } + } + /// Ouvre l'écran de la sous-section. Il lit `selectedSubSectionRawData`, donc /// on lui passe le JSON brut correspondant, pas seulement le DTO. void _openSubSection(SectionDTO section, int index) { @@ -167,6 +187,7 @@ class _MenuConfigState extends State { onReorder: _onReorder, onAdd: _createSubSection, onTap: _openSubSection, + onToggleVisibility: _toggleVisibility, ); } } diff --git a/lib/Screens/Configurations/Section/section_detail_screen.dart b/lib/Screens/Configurations/Section/section_detail_screen.dart index 137782d..753618d 100644 --- a/lib/Screens/Configurations/Section/section_detail_screen.dart +++ b/lib/Screens/Configurations/Section/section_detail_screen.dart @@ -58,6 +58,12 @@ class _SectionDetailScreenState extends State { final GlobalKey globalKey = GlobalKey(); late Future _sectionFuture; + // Les champs sont des TextFormField non contrôlés : leur `initialValue` n'est + // lu qu'à la création de l'élément. Recharger le DTO ne suffit donc pas à + // remettre le formulaire à l'état serveur — il faut forcer une nouvelle + // sous-arborescence, d'où ce compteur. + int _formRevision = 0; + Future _loadSection() { final appContext = Provider.of(context, listen: false); return getSection( @@ -170,14 +176,17 @@ class _SectionDetailScreenState extends State { Expanded( child: SingleChildScrollView( padding: kPagePadding, - child: EditorColumns( - main: [ - _cardIdentity(appContext, l), - _cardTypeConfig(rawSectionData, appContext), - ], - rail: [ - _cardQR(appContext, l), - ], + child: KeyedSubtree( + key: ValueKey(_formRevision), + child: EditorColumns( + main: [ + _cardIdentity(appContext, l), + _cardTypeConfig(rawSectionData, appContext), + ], + rail: [ + _cardQR(appContext, l), + ], + ), ), ), ), @@ -354,10 +363,18 @@ class _SectionDetailScreenState extends State { .sectionApi! .sectionGetDetail(sectionDTO.id!); var nullableSection = SectionDTO.fromJson(rawData); - if (nullableSection != null) { - managerAppContext.selectedSection = nullableSection!; - appContext.setContext(managerAppContext); - } + if (nullableSection == null) return; + + managerAppContext.selectedSection = nullableSection; + appContext.setContext(managerAppContext); + + if (!mounted) return; + setState(() { + sectionDetailDTO = null; + lastLoadedSectionId = null; + _sectionFuture = Future.value(rawData); + _formRevision++; + }); } Future delete(AppContext appContext) async { diff --git a/lib/Screens/Configurations/configuration_detail_screen.dart b/lib/Screens/Configurations/configuration_detail_screen.dart index a4db5bf..7874269 100644 --- a/lib/Screens/Configurations/configuration_detail_screen.dart +++ b/lib/Screens/Configurations/configuration_detail_screen.dart @@ -38,6 +38,15 @@ class _ConfigurationDetailScreenState extends State { Future? _configFuture; Future?>? _sectionsFuture; + // Les champs sont des TextFormField non contrôlés : leur `initialValue` n'est + // lu qu'à la création de l'élément. Changer de DTO ne suffit donc pas à + // remettre le formulaire à l'état serveur — il faut forcer une nouvelle + // sous-arborescence, d'où ce compteur. + int _formRevision = 0; + + /// L'ordre des sections a été touché mais pas encore écrit. + bool _orderDirty = false; + @override Widget build(BuildContext context) { final appContext = Provider.of(context); @@ -107,14 +116,17 @@ class _ConfigurationDetailScreenState extends State { Expanded( child: SingleChildScrollView( padding: kPagePadding, - child: EditorColumns( - main: [ - _cardGeneral(config, l), - _cardSections(config, appContext), - ], - rail: [ - _cardImages(config, l), - ], + child: KeyedSubtree( + key: ValueKey(_formRevision), + child: EditorColumns( + main: [ + _cardGeneral(config, l), + _cardSections(config, appContext), + ], + rail: [ + _cardImages(config, l), + ], + ), ), ), ), @@ -277,9 +289,12 @@ class _ConfigurationDetailScreenState extends State { return SectionReorderList( sectionsIn: sections!, configurationId: config.id!, - onChangedOrder: (List sectionsOut) async { + // Le nouvel ordre partait au serveur au relâchement de la souris, + // donc « Annuler » n'avait plus rien à annuler. Il attend + // maintenant « Enregistrer », comme les autres champs de l'écran. + onChangedOrder: (List sectionsOut) { sections = sectionsOut; - await managerCtx.clientAPI!.sectionApi!.sectionUpdateOrder(sections!); + _orderDirty = true; }, askReload: () => setState(() { _sectionsFuture = null; @@ -327,6 +342,15 @@ class _ConfigurationDetailScreenState extends State { ConfigurationDTO? configuration = await managerAppContext.clientAPI!.configurationApi!.configurationGetDetail(config.id!); managerAppContext.selectedConfiguration = configuration; appContext.setContext(managerAppContext); + + if (!mounted) return; + setState(() { + _configFuture = Future.value(configuration); + _sectionsFuture = null; + sections = null; + _orderDirty = false; + _formRevision++; + }); } Future delete(ConfigurationDTO config, AppContext appContext) async { @@ -346,6 +370,12 @@ class _ConfigurationDetailScreenState extends State { Future save(ConfigurationDTO config, AppContext appContext) async { ManagerAppContext managerAppContext = appContext.getContext(); + + if (_orderDirty && sections != null) { + await managerAppContext.clientAPI!.sectionApi!.sectionUpdateOrder(sections!); + _orderDirty = false; + } + ConfigurationDTO? configuration = await managerAppContext.clientAPI!.configurationApi!.configurationUpdate(config); managerAppContext.selectedConfiguration = configuration; appContext.setContext(managerAppContext); diff --git a/lib/Screens/Configurations/listView_card_section.dart b/lib/Screens/Configurations/listView_card_section.dart index 6776709..2bab4c3 100644 --- a/lib/Screens/Configurations/listView_card_section.dart +++ b/lib/Screens/Configurations/listView_card_section.dart @@ -17,6 +17,7 @@ class SectionCard extends StatelessWidget { required this.onTap, this.dragHandle, this.isDropTarget = false, + this.onToggleVisibility, }) : super(key: key); final SectionDTO section; @@ -25,6 +26,9 @@ class SectionCard extends StatelessWidget { final Widget? dragHandle; final bool isDropTarget; + /// Null pour un utilisateur qui ne peut pas éditer : la tuile perd le bouton. + final VoidCallback? onToggleVisibility; + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; @@ -101,6 +105,20 @@ class SectionCard extends StatelessWidget { fontSize: 10, color: kInk3)), ), ), + if (onToggleVisibility != null) + Positioned( + bottom: 2, + right: 2, + child: _OverlayIconButton( + icon: isHidden + ? Icons.visibility_off + : Icons.visibility, + tooltip: isHidden + ? l.sectionShowAction + : l.sectionHideAction, + onPressed: onToggleVisibility!, + ), + ), if (dragHandle != null) Positioned(top: 2, right: 2, child: dragHandle!), ], @@ -150,6 +168,41 @@ class SectionCard extends StatelessWidget { } } +/// Bouton posé sur l'image de la tuile : l'ombre porte le contraste, parce que +/// l'image derrière peut être de n'importe quelle couleur. +class _OverlayIconButton extends StatelessWidget { + const _OverlayIconButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(kRadiusInput), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(kSpace1), + child: Icon(icon, + size: 17, + color: kWhite, + shadows: const [Shadow(color: kBlack, blurRadius: 3)]), + ), + ), + ), + ); + } +} + /// La grille de sections, réordonnable, terminée par la tuile « Ajouter ». /// /// Partagée par les sections d'une configuration et par les sous-sections d'un @@ -163,12 +216,14 @@ class SectionGrid extends StatefulWidget { required this.onReorder, required this.onTap, required this.onAdd, + this.onToggleVisibility, }) : super(key: key); final List sections; final void Function(int oldIndex, int newIndex) onReorder; final void Function(SectionDTO section, int index) onTap; final VoidCallback onAdd; + final void Function(SectionDTO section)? onToggleVisibility; @override State createState() => _SectionGridState(); @@ -212,6 +267,9 @@ class _SectionGridState extends State { isDropTarget: dropTargetIndex == index, dragHandle: SectionDragHandle(index: index, width: width), onTap: () => widget.onTap(widget.sections[index], index), + onToggleVisibility: widget.onToggleVisibility == null + ? null + : () => widget.onToggleVisibility!(widget.sections[index]), ), ), ), diff --git a/lib/Screens/Configurations/section_reorderList.dart b/lib/Screens/Configurations/section_reorderList.dart index dea195a..4990238 100644 --- a/lib/Screens/Configurations/section_reorderList.dart +++ b/lib/Screens/Configurations/section_reorderList.dart @@ -79,10 +79,37 @@ class _SectionReorderListState extends State { } } + /// Retirer une section de l'app sans la supprimer. Le champ existe depuis + /// toujours côté serveur et les apps visiteur le respectent déjà — il n'avait + /// simplement aucun geste dans le manager. + Future _toggleVisibility( + AppContext appContext, SectionDTO section) async { + final l = AppLocalizations.of(context)!; + final newValue = section.isActive == false; + + setState(() => section.isActive = newValue); + + try { + ManagerAppContext managerAppContext = appContext.getContext(); + await managerAppContext.clientAPI!.sectionApi! + .sectionSetVisibility(section.id!, newValue); + showNotification( + kSuccess, + kWhite, + newValue ? l.sectionShownSuccess : l.sectionHiddenSuccess, + context, + null); + } catch (e) { + setState(() => section.isActive = !newValue); + showNotification(kError, kWhite, l.sectionVisibilityError, context, null); + } + } + @override Widget build(BuildContext context) { final appContext = Provider.of(context); final l = AppLocalizations.of(context)!; + final canEdit = (appContext.getContext() as ManagerAppContext).canEdit; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -102,6 +129,9 @@ class _SectionReorderListState extends State { sections: sections, onReorder: _onReorder, onAdd: () => _createSection(appContext), + onToggleVisibility: canEdit + ? (section) => _toggleVisibility(appContext, section) + : null, onTap: (section, index) { WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { diff --git a/lib/Screens/Main/main_screen.dart b/lib/Screens/Main/main_screen.dart index e1ed394..d257be1 100644 --- a/lib/Screens/Main/main_screen.dart +++ b/lib/Screens/Main/main_screen.dart @@ -88,12 +88,29 @@ class _MainScreenState extends State { void initState() { super.initState(); _buildMenu(widget.instance); + _renderedInstanceId = widget.instance.id; currentPosition.value ??= _defaultPosition(widget.instance); selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); } + /// L'instance sur laquelle le menu et l'écran courant ont été construits. + String? _renderedInstanceId; + + /// Reconstruit le menu et recale la position quand on a changé d'instance. + void _applyInstance(InstanceDTO instance) { + _buildMenu(instance); + _renderedInstanceId = instance.id; + + final positions = menu.sections! + .expand((s) => [s.menuId, ...s.subMenu.map((sub) => sub.menuId)]) + .toSet(); + if (!positions.contains(currentPosition.value)) { + currentPosition.value = _defaultPosition(instance); + } + } + /// Le changement d'instance passe par la même route : Flutter réutilise cet /// State et `initState` ne rejoue pas. Sans cela le menu reste celui de /// l'instance précédente, et la position courante peut désigner une @@ -103,14 +120,7 @@ class _MainScreenState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.instance.id == widget.instance.id) return; - _buildMenu(widget.instance); - - final positions = menu.sections! - .expand((s) => [s.menuId, ...s.subMenu.map((sub) => sub.menuId)]) - .toSet(); - if (!positions.contains(currentPosition.value)) { - currentPosition.value = _defaultPosition(widget.instance); - } + _applyInstance(widget.instance); selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); } @@ -275,7 +285,17 @@ class _MainScreenState extends State { setState(() { managerCtx.instanceId = newInstance.id; managerCtx.instanceDTO = newInstance; + // Une configuration ou une section restée + // sélectionnée appartient à l'instance qu'on + // vient de quitter : l'écran de détail la + // réaffichait telle quelle. + managerCtx.selectedConfiguration = null; + managerCtx.selectedSection = null; + managerCtx.selectedSubSection = null; + managerCtx.selectedSubSectionRawData = null; }); + Provider.of(context, listen: false) + .setContext(managerCtx); final view = newInstance.isMobile! ? 'mobile' : newInstance.isTablet! ? 'kiosk' : newInstance.isWeb! ? 'web' @@ -543,6 +563,16 @@ class _MainScreenState extends State { final appContext = Provider.of(context); ManagerAppContext managerAppContext = appContext.getContext(); + // `widget.instance` est figée à la construction de la route. Or changer + // d'instance en restant sur la même vue ne change pas l'URL, donc GoRouter + // ne rejoue pas son builder et `didUpdateWidget` ne se déclenche jamais : + // l'écran continuait d'afficher l'instance précédente jusqu'à ce qu'on + // change d'onglet. C'est le contexte qui fait foi, pas le widget. + final instance = managerAppContext.instanceDTO ?? widget.instance; + if (instance.id != _renderedInstanceId) { + _applyInstance(instance); + } + // Synchronise les items de menu sensibles au rôle à chaque rebuild final role = managerAppContext.role; final hasAdminItems = menu.sections!.any((s) => s.menuId == 8); @@ -608,10 +638,16 @@ class _MainScreenState extends State { child: ValueListenableBuilder( valueListenable: currentPosition, builder: (context, value, _) { - selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); + selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, instance); + // La clé porte l'instance : sans elle les écrans en dessous + // gardent leur State, donc leurs futures déjà résolues sur + // l'instance précédente. return Padding( padding: const EdgeInsets.all(8.0), - child: selectedElement, + child: KeyedSubtree( + key: ValueKey(instance.id), + child: selectedElement!, + ), ); } ), diff --git a/lib/Screens/Users/users_screen.dart b/lib/Screens/Users/users_screen.dart index 196c8c0..35c764c 100644 --- a/lib/Screens/Users/users_screen.dart +++ b/lib/Screens/Users/users_screen.dart @@ -5,6 +5,7 @@ import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/Components/common_loader.dart'; +import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/constants.dart'; import 'package:provider/provider.dart'; @@ -59,11 +60,14 @@ class _UsersScreenState extends State { String firstName, String lastName, int roleValue) async { // No password sent: the backend generates an invitation token and emails // a "set your password" link to the new user instead. + // `instanceId` est obligatoire côté serveur : il rejetait la création avec + // « InstanceId is null » tant qu'on ne l'envoyait pas. final body = { 'email': email, 'firstName': firstName, 'lastName': lastName, 'role': roleValue, + 'instanceId': ctx.instanceId, }; final response = await ctx.clientAPI!.apiApi!.invokeAPI( '/api/User', 'POST', [], body, {}, {}, 'application/json'); @@ -71,14 +75,16 @@ class _UsersScreenState extends State { // `invokeAPI` ne lève pas sur un code d'erreur : sans ce test, un e-mail déjà // utilisé (409) ou un rôle refusé (403) laissaient la liste inchangée sans // qu'aucun message n'explique pourquoi. - if (response.statusCode != 200 && mounted) { - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(l.userCreateError(utf8.decode(response.bodyBytes))), - backgroundColor: kError, - )); + if (!mounted) return; + final l = AppLocalizations.of(context)!; + + if (response.statusCode != 200) { + showNotification(kError, kWhite, + l.userCreateError(utf8.decode(response.bodyBytes)), context, null); return; } + + showNotification(kSuccess, kWhite, l.userCreatedSuccess, context, null); await _loadUsers(ctx); } @@ -90,13 +96,36 @@ class _UsersScreenState extends State { 'lastName': lastName, 'role': roleValue, }; - await ctx.clientAPI!.apiApi!.invokeAPI( + final response = await ctx.clientAPI!.apiApi!.invokeAPI( '/api/User', 'PUT', [], body, {}, {}, 'application/json'); + + if (!mounted) return; + final l = AppLocalizations.of(context)!; + + if (response.statusCode != 200) { + showNotification(kError, kWhite, + l.userUpdateError(utf8.decode(response.bodyBytes)), context, null); + return; + } + + showNotification(kSuccess, kWhite, l.userUpdatedSuccess, context, null); await _loadUsers(ctx); } Future _deleteUser(ManagerAppContext ctx, String id) async { - await ctx.clientAPI!.userApi!.userDeleteUser(id); + final l = AppLocalizations.of(context)!; + try { + await ctx.clientAPI!.userApi!.userDeleteUser(id); + if (mounted) { + showNotification(kSuccess, kWhite, l.userDeletedSuccess, context, null); + } + } catch (e) { + if (mounted) { + showNotification( + kError, kWhite, l.userDeleteError(e.toString()), context, null); + } + return; + } await _loadUsers(ctx); } @@ -206,15 +235,17 @@ class _UsersScreenState extends State { final l = AppLocalizations.of(context)!; showDialog( context: context, - builder: (_) => AlertDialog( + // `context` est celui de l'écran, pas du dialogue : le popper ferme la page + // sous GoRouter au lieu de la boîte de dialogue. + builder: (dialogContext) => AlertDialog( title: Text(l.deleteUserTitle), content: Text(l.deleteUserConfirm(user['email'] as String? ?? '')), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(l.cancel)), + TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(l.cancel)), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.red), onPressed: () async { - Navigator.pop(context); + Navigator.pop(dialogContext); await _deleteUser(ctx, user['id'] as String); }, child: Text(l.delete, style: const TextStyle(color: Colors.white)), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b793a7e..ef8e2a9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -581,13 +581,19 @@ "searchLabel": "Search:", "geopointsLoadError": "Error loading geographic points", "geopointDeleteConfirm": "Are you sure you want to delete this geographic point?", - "serviceLabel": "Service:", - "centerPointLabel": "Center point:", - "iconLabel": "Icon:", - "listViewLabel": "List view:", - "typeLabel": "Type:", - "zoomLabel": "Zoom:", - "categoriesLabel": "Categories:", + "serviceLabel": "Service", + "geopointConfirmPosition": "Confirm position", + "clearFilter": "Show all", + "mapGroupMap": "Map", + "mapGroupPoints": "Points of interest", + "displayLabel": "Display", + "listViewOption": "Offer the list view", + "centerPointLabel": "Center point", + "iconLabel": "Default icon", + "listViewLabel": "List view", + "typeLabel": "Map type", + "zoomLabel": "Initial zoom", + "categoriesLabel": "Categories", "startDateLabel": "Start date", "notDefined": "Not defined", "endDateLabel": "End date", @@ -834,6 +840,25 @@ } }, "usersQuotaHint": "Delete a user to invite another one.", + "userCreatedSuccess": "User created, the invitation has been emailed", + "userUpdatedSuccess": "User updated", + "userDeletedSuccess": "User deleted", + "userUpdateError": "Update failed: {message}", + "@userUpdateError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, + "userDeleteError": "Deletion failed: {message}", + "@userDeleteError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, "userCreateError": "Creation failed: {message}", "@userCreateError": { "placeholders": { @@ -1341,6 +1366,11 @@ } }, "sectionHiddenBadge": "Hidden", + "sectionHideAction": "Hide in the visitor app", + "sectionShowAction": "Show in the visitor app", + "sectionHiddenSuccess": "Section hidden", + "sectionShownSuccess": "Section shown", + "sectionVisibilityError": "Could not change the section visibility", "addSectionTile": "Add a section", "resourceTypeImage": "Image", "resourceTypeImageUrl": "Image (URL)", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4992849..334ae4d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -581,13 +581,19 @@ "searchLabel": "Recherche :", "geopointsLoadError": "Une erreur est survenue lors de la récupération des points géographiques", "geopointDeleteConfirm": "Êtes-vous sûr de vouloir supprimer ce point géographique ?", - "serviceLabel": "Service :", - "centerPointLabel": "Point de centrage :", - "iconLabel": "Icône :", - "listViewLabel": "Vue liste :", - "typeLabel": "Type :", - "zoomLabel": "Zoom :", - "categoriesLabel": "Catégories :", + "serviceLabel": "Service", + "geopointConfirmPosition": "Valider la position", + "clearFilter": "Tout afficher", + "mapGroupMap": "Carte", + "mapGroupPoints": "Points d'intérêt", + "displayLabel": "Affichage", + "listViewOption": "Proposer la vue liste", + "centerPointLabel": "Point de centrage", + "iconLabel": "Icône par défaut", + "listViewLabel": "Vue liste", + "typeLabel": "Type de carte", + "zoomLabel": "Zoom initial", + "categoriesLabel": "Catégories", "startDateLabel": "Date de début", "notDefined": "Non définie", "endDateLabel": "Date de fin", @@ -834,6 +840,25 @@ } }, "usersQuotaHint": "Supprimez un utilisateur pour en inviter un autre.", + "userCreatedSuccess": "Utilisateur créé, l'invitation est partie par e-mail", + "userUpdatedSuccess": "Utilisateur mis à jour", + "userDeletedSuccess": "Utilisateur supprimé", + "userUpdateError": "La mise à jour a échoué : {message}", + "@userUpdateError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, + "userDeleteError": "La suppression a échoué : {message}", + "@userDeleteError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, "userCreateError": "La création a échoué : {message}", "@userCreateError": { "placeholders": { @@ -1341,6 +1366,11 @@ } }, "sectionHiddenBadge": "Masquée", + "sectionHideAction": "Masquer dans l'app visiteur", + "sectionShowAction": "Afficher dans l'app visiteur", + "sectionHiddenSuccess": "Section masquée", + "sectionShownSuccess": "Section affichée", + "sectionVisibilityError": "Impossible de changer la visibilité de la section", "addSectionTile": "Ajouter une section", "resourceTypeImage": "Image", "resourceTypeImageUrl": "Image (URL)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ba06b8..20073e2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2527,43 +2527,79 @@ abstract class AppLocalizations { /// No description provided for @serviceLabel. /// /// In fr, this message translates to: - /// **'Service :'** + /// **'Service'** String get serviceLabel; + /// No description provided for @geopointConfirmPosition. + /// + /// In fr, this message translates to: + /// **'Valider la position'** + String get geopointConfirmPosition; + + /// No description provided for @clearFilter. + /// + /// In fr, this message translates to: + /// **'Tout afficher'** + String get clearFilter; + + /// No description provided for @mapGroupMap. + /// + /// In fr, this message translates to: + /// **'Carte'** + String get mapGroupMap; + + /// No description provided for @mapGroupPoints. + /// + /// In fr, this message translates to: + /// **'Points d\'intérêt'** + String get mapGroupPoints; + + /// No description provided for @displayLabel. + /// + /// In fr, this message translates to: + /// **'Affichage'** + String get displayLabel; + + /// No description provided for @listViewOption. + /// + /// In fr, this message translates to: + /// **'Proposer la vue liste'** + String get listViewOption; + /// No description provided for @centerPointLabel. /// /// In fr, this message translates to: - /// **'Point de centrage :'** + /// **'Point de centrage'** String get centerPointLabel; /// No description provided for @iconLabel. /// /// In fr, this message translates to: - /// **'Icône :'** + /// **'Icône par défaut'** String get iconLabel; /// No description provided for @listViewLabel. /// /// In fr, this message translates to: - /// **'Vue liste :'** + /// **'Vue liste'** String get listViewLabel; /// No description provided for @typeLabel. /// /// In fr, this message translates to: - /// **'Type :'** + /// **'Type de carte'** String get typeLabel; /// No description provided for @zoomLabel. /// /// In fr, this message translates to: - /// **'Zoom :'** + /// **'Zoom initial'** String get zoomLabel; /// No description provided for @categoriesLabel. /// /// In fr, this message translates to: - /// **'Catégories :'** + /// **'Catégories'** String get categoriesLabel; /// No description provided for @startDateLabel. @@ -3694,6 +3730,36 @@ abstract class AppLocalizations { /// **'Supprimez un utilisateur pour en inviter un autre.'** String get usersQuotaHint; + /// No description provided for @userCreatedSuccess. + /// + /// In fr, this message translates to: + /// **'Utilisateur créé, l\'invitation est partie par e-mail'** + String get userCreatedSuccess; + + /// No description provided for @userUpdatedSuccess. + /// + /// In fr, this message translates to: + /// **'Utilisateur mis à jour'** + String get userUpdatedSuccess; + + /// No description provided for @userDeletedSuccess. + /// + /// In fr, this message translates to: + /// **'Utilisateur supprimé'** + String get userDeletedSuccess; + + /// No description provided for @userUpdateError. + /// + /// In fr, this message translates to: + /// **'La mise à jour a échoué : {message}'** + String userUpdateError(String message); + + /// No description provided for @userDeleteError. + /// + /// In fr, this message translates to: + /// **'La suppression a échoué : {message}'** + String userDeleteError(String message); + /// No description provided for @userCreateError. /// /// In fr, this message translates to: @@ -5632,6 +5698,36 @@ abstract class AppLocalizations { /// **'Masquée'** String get sectionHiddenBadge; + /// No description provided for @sectionHideAction. + /// + /// In fr, this message translates to: + /// **'Masquer dans l\'app visiteur'** + String get sectionHideAction; + + /// No description provided for @sectionShowAction. + /// + /// In fr, this message translates to: + /// **'Afficher dans l\'app visiteur'** + String get sectionShowAction; + + /// No description provided for @sectionHiddenSuccess. + /// + /// In fr, this message translates to: + /// **'Section masquée'** + String get sectionHiddenSuccess; + + /// No description provided for @sectionShownSuccess. + /// + /// In fr, this message translates to: + /// **'Section affichée'** + String get sectionShownSuccess; + + /// No description provided for @sectionVisibilityError. + /// + /// In fr, this message translates to: + /// **'Impossible de changer la visibilité de la section'** + String get sectionVisibilityError; + /// No description provided for @addSectionTile. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1193360..d592a9c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1349,25 +1349,43 @@ class AppLocalizationsEn extends AppLocalizations { 'Are you sure you want to delete this geographic point?'; @override - String get serviceLabel => 'Service:'; + String get serviceLabel => 'Service'; @override - String get centerPointLabel => 'Center point:'; + String get geopointConfirmPosition => 'Confirm position'; @override - String get iconLabel => 'Icon:'; + String get clearFilter => 'Show all'; @override - String get listViewLabel => 'List view:'; + String get mapGroupMap => 'Map'; @override - String get typeLabel => 'Type:'; + String get mapGroupPoints => 'Points of interest'; @override - String get zoomLabel => 'Zoom:'; + String get displayLabel => 'Display'; @override - String get categoriesLabel => 'Categories:'; + String get listViewOption => 'Offer the list view'; + + @override + String get centerPointLabel => 'Center point'; + + @override + String get iconLabel => 'Default icon'; + + @override + String get listViewLabel => 'List view'; + + @override + String get typeLabel => 'Map type'; + + @override + String get zoomLabel => 'Initial zoom'; + + @override + String get categoriesLabel => 'Categories'; @override String get startDateLabel => 'Start date'; @@ -1960,6 +1978,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get usersQuotaHint => 'Delete a user to invite another one.'; + @override + String get userCreatedSuccess => + 'User created, the invitation has been emailed'; + + @override + String get userUpdatedSuccess => 'User updated'; + + @override + String get userDeletedSuccess => 'User deleted'; + + @override + String userUpdateError(String message) { + return 'Update failed: $message'; + } + + @override + String userDeleteError(String message) { + return 'Deletion failed: $message'; + } + @override String userCreateError(String message) { return 'Creation failed: $message'; @@ -3048,6 +3086,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sectionHiddenBadge => 'Hidden'; + @override + String get sectionHideAction => 'Hide in the visitor app'; + + @override + String get sectionShowAction => 'Show in the visitor app'; + + @override + String get sectionHiddenSuccess => 'Section hidden'; + + @override + String get sectionShownSuccess => 'Section shown'; + + @override + String get sectionVisibilityError => + 'Could not change the section visibility'; + @override String get addSectionTile => 'Add a section'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1f408b3..6ca8ab8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1381,25 +1381,43 @@ class AppLocalizationsFr extends AppLocalizations { 'Êtes-vous sûr de vouloir supprimer ce point géographique ?'; @override - String get serviceLabel => 'Service :'; + String get serviceLabel => 'Service'; @override - String get centerPointLabel => 'Point de centrage :'; + String get geopointConfirmPosition => 'Valider la position'; @override - String get iconLabel => 'Icône :'; + String get clearFilter => 'Tout afficher'; @override - String get listViewLabel => 'Vue liste :'; + String get mapGroupMap => 'Carte'; @override - String get typeLabel => 'Type :'; + String get mapGroupPoints => 'Points d\'intérêt'; @override - String get zoomLabel => 'Zoom :'; + String get displayLabel => 'Affichage'; @override - String get categoriesLabel => 'Catégories :'; + String get listViewOption => 'Proposer la vue liste'; + + @override + String get centerPointLabel => 'Point de centrage'; + + @override + String get iconLabel => 'Icône par défaut'; + + @override + String get listViewLabel => 'Vue liste'; + + @override + String get typeLabel => 'Type de carte'; + + @override + String get zoomLabel => 'Zoom initial'; + + @override + String get categoriesLabel => 'Catégories'; @override String get startDateLabel => 'Date de début'; @@ -2000,6 +2018,26 @@ class AppLocalizationsFr extends AppLocalizations { String get usersQuotaHint => 'Supprimez un utilisateur pour en inviter un autre.'; + @override + String get userCreatedSuccess => + 'Utilisateur créé, l\'invitation est partie par e-mail'; + + @override + String get userUpdatedSuccess => 'Utilisateur mis à jour'; + + @override + String get userDeletedSuccess => 'Utilisateur supprimé'; + + @override + String userUpdateError(String message) { + return 'La mise à jour a échoué : $message'; + } + + @override + String userDeleteError(String message) { + return 'La suppression a échoué : $message'; + } + @override String userCreateError(String message) { return 'La création a échoué : $message'; @@ -3099,6 +3137,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sectionHiddenBadge => 'Masquée'; + @override + String get sectionHideAction => 'Masquer dans l\'app visiteur'; + + @override + String get sectionShowAction => 'Afficher dans l\'app visiteur'; + + @override + String get sectionHiddenSuccess => 'Section masquée'; + + @override + String get sectionShownSuccess => 'Section affichée'; + + @override + String get sectionVisibilityError => + 'Impossible de changer la visibilité de la section'; + @override String get addSectionTile => 'Ajouter une section'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index d841cec..708b969 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1363,25 +1363,43 @@ class AppLocalizationsNl extends AppLocalizations { 'Weet u zeker dat u dit geografische punt wilt verwijderen?'; @override - String get serviceLabel => 'Service:'; + String get serviceLabel => 'Service'; @override - String get centerPointLabel => 'Middelpunt:'; + String get geopointConfirmPosition => 'Positie bevestigen'; @override - String get iconLabel => 'Pictogram:'; + String get clearFilter => 'Alles tonen'; @override - String get listViewLabel => 'Lijstweergave:'; + String get mapGroupMap => 'Kaart'; @override - String get typeLabel => 'Type:'; + String get mapGroupPoints => 'Interessepunten'; @override - String get zoomLabel => 'Zoom:'; + String get displayLabel => 'Weergave'; @override - String get categoriesLabel => 'Categorieën:'; + String get listViewOption => 'Lijstweergave aanbieden'; + + @override + String get centerPointLabel => 'Middelpunt'; + + @override + String get iconLabel => 'Standaardpictogram'; + + @override + String get listViewLabel => 'Lijstweergave'; + + @override + String get typeLabel => 'Kaarttype'; + + @override + String get zoomLabel => 'Beginzoom'; + + @override + String get categoriesLabel => 'Categorieën'; @override String get startDateLabel => 'Startdatum'; @@ -1981,6 +1999,26 @@ class AppLocalizationsNl extends AppLocalizations { String get usersQuotaHint => 'Verwijder een gebruiker om er een andere uit te nodigen.'; + @override + String get userCreatedSuccess => + 'Gebruiker aangemaakt, de uitnodiging is per e-mail verstuurd'; + + @override + String get userUpdatedSuccess => 'Gebruiker bijgewerkt'; + + @override + String get userDeletedSuccess => 'Gebruiker verwijderd'; + + @override + String userUpdateError(String message) { + return 'Bijwerken mislukt: $message'; + } + + @override + String userDeleteError(String message) { + return 'Verwijderen mislukt: $message'; + } + @override String userCreateError(String message) { return 'Aanmaken mislukt: $message'; @@ -3075,6 +3113,22 @@ class AppLocalizationsNl extends AppLocalizations { @override String get sectionHiddenBadge => 'Verborgen'; + @override + String get sectionHideAction => 'Verbergen in de bezoekersapp'; + + @override + String get sectionShowAction => 'Tonen in de bezoekersapp'; + + @override + String get sectionHiddenSuccess => 'Sectie verborgen'; + + @override + String get sectionShownSuccess => 'Sectie zichtbaar'; + + @override + String get sectionVisibilityError => + 'Kan de zichtbaarheid van de sectie niet wijzigen'; + @override String get addSectionTile => 'Een sectie toevoegen'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index c43ae37..d382567 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -581,13 +581,19 @@ "searchLabel": "Zoeken:", "geopointsLoadError": "Fout bij het laden van geografische punten", "geopointDeleteConfirm": "Weet u zeker dat u dit geografische punt wilt verwijderen?", - "serviceLabel": "Service:", - "centerPointLabel": "Middelpunt:", - "iconLabel": "Pictogram:", - "listViewLabel": "Lijstweergave:", - "typeLabel": "Type:", - "zoomLabel": "Zoom:", - "categoriesLabel": "Categorieën:", + "serviceLabel": "Service", + "geopointConfirmPosition": "Positie bevestigen", + "clearFilter": "Alles tonen", + "mapGroupMap": "Kaart", + "mapGroupPoints": "Interessepunten", + "displayLabel": "Weergave", + "listViewOption": "Lijstweergave aanbieden", + "centerPointLabel": "Middelpunt", + "iconLabel": "Standaardpictogram", + "listViewLabel": "Lijstweergave", + "typeLabel": "Kaarttype", + "zoomLabel": "Beginzoom", + "categoriesLabel": "Categorieën", "startDateLabel": "Startdatum", "notDefined": "Niet gedefinieerd", "endDateLabel": "Einddatum", @@ -834,6 +840,25 @@ } }, "usersQuotaHint": "Verwijder een gebruiker om er een andere uit te nodigen.", + "userCreatedSuccess": "Gebruiker aangemaakt, de uitnodiging is per e-mail verstuurd", + "userUpdatedSuccess": "Gebruiker bijgewerkt", + "userDeletedSuccess": "Gebruiker verwijderd", + "userUpdateError": "Bijwerken mislukt: {message}", + "@userUpdateError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, + "userDeleteError": "Verwijderen mislukt: {message}", + "@userDeleteError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, "userCreateError": "Aanmaken mislukt: {message}", "@userCreateError": { "placeholders": { @@ -1341,6 +1366,11 @@ } }, "sectionHiddenBadge": "Verborgen", + "sectionHideAction": "Verbergen in de bezoekersapp", + "sectionShowAction": "Tonen in de bezoekersapp", + "sectionHiddenSuccess": "Sectie verborgen", + "sectionShownSuccess": "Sectie zichtbaar", + "sectionVisibilityError": "Kan de zichtbaarheid van de sectie niet wijzigen", "addSectionTile": "Een sectie toevoegen", "resourceTypeImage": "Afbeelding", "resourceTypeImageUrl": "Afbeelding (URL)", diff --git a/lib/main.dart b/lib/main.dart index 3b9132b..e3bd53a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -125,7 +125,12 @@ Future main() async { path: '/main/:view', builder: (context, state) { final view = state.pathParameters['view']; + // La clé porte l'instance : sans elle, un SuperAdmin + // qui change d'instance en restant sur la même vue + // gardait le State de tous les écrans en dessous, donc + // les données de l'instance précédente. return MainScreen( + key: ValueKey('${managerAppContext.instanceId}/$view'), instance: managerAppContext.instanceDTO!, view: view, ); diff --git a/manager_api_new/lib/api/section_api.dart b/manager_api_new/lib/api/section_api.dart index b710e8a..d679c1f 100644 --- a/manager_api_new/lib/api/section_api.dart +++ b/manager_api_new/lib/api/section_api.dart @@ -1306,4 +1306,57 @@ class SectionApi { } return null; } + + /// Performs an HTTP 'PUT /api/Section/{id}/visibility' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [bool] isActive (required): + Future sectionSetVisibilityWithHttpInfo( + String id, + bool isActive, + ) async { + // ignore: prefer_const_declarations + final path = r'/api/Section/{id}/visibility'.replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'isActive', isActive)); + + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [bool] isActive (required): + Future sectionSetVisibility( + String id, + bool isActive, + ) async { + final response = await sectionSetVisibilityWithHttpInfo( + id, + isActive, + ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } }