Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas Fransolet
842f69ebc9 Web mobile : le clavier ne s'ouvrait pas sur l'ecran de login
web/index.html n'avait aucune balise <meta name="viewport">. Les navigateurs
mobiles appliquaient donc un viewport virtuel de ~980px et dezoomaient la page,
ce qui decalait les <input> DOM caches par lesquels Flutter web gere la saisie :
le tap donnait bien le focus cote Flutter, mais le navigateur n'ouvrait pas le
clavier et tentait de zoomer sur l'element — l'ecran semblait juste se rafraichir.
Effet de bord, le LayoutBuilder du login voyait maxWidth ~980, donc isMobile
etait toujours faux et la mise en page mobile ne s'appliquait jamais.

maximum-scale=1.0 evite en plus le zoom automatique de Safari iOS au focus.

Au passage :
- suppression de window.flutterWebRenderer = "html", sans effet depuis que le
  renderer HTML a ete retire de Flutter (3.29, on est en 3.44) ;
- badge de version sur l'ecran de login. kGitSha est injecte au build
  (--dart-define=GIT_SHA) et affiche a cote du numero de pubspec, pour relier
  un bundle deploye au commit exact qui l'a produit ;
- la Future de PackageInfo etait recreee a chaque build() du login, ce qui
  faisait clignoter le libelle : elle est desormais construite une seule fois.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:31:01 +02:00
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
25 changed files with 1222 additions and 295 deletions

View File

@ -11,10 +11,14 @@ FROM ghcr.io/cirruslabs/flutter:3.44.0 AS build
# docker build --build-arg API_BASE_URL=https://api.mymuseum.be -t ... . # docker build --build-arg API_BASE_URL=https://api.mymuseum.be -t ... .
ARG API_BASE_URL=http://localhost:5000 ARG API_BASE_URL=http://localhost:5000
# Empreinte du commit, affichee sur l'ecran de login (voir kGitSha).
# docker build --build-arg GIT_SHA=$(git rev-parse --short HEAD) ...
ARG GIT_SHA=dev
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN flutter pub get RUN flutter pub get
RUN flutter build web --release --dart-define=API_BASE_URL=$API_BASE_URL RUN flutter build web --release --dart-define=API_BASE_URL=$API_BASE_URL --dart-define=GIT_SHA=$GIT_SHA
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@ -15,13 +15,8 @@ class QuotaBarsWidget extends StatefulWidget {
class _QuotaBarsWidgetState extends State<QuotaBarsWidget> { class _QuotaBarsWidgetState extends State<QuotaBarsWidget> {
InstanceQuotaDTO? _quota; InstanceQuotaDTO? _quota;
bool _loading = true; bool _loading = false;
String? _loadedInstanceId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetchQuota());
}
Future<void> _fetchQuota() async { Future<void> _fetchQuota() async {
final managerContext = Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext; final managerContext = Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
@ -72,6 +67,17 @@ class _QuotaBarsWidgetState extends State<QuotaBarsWidget> {
final isAssistant = managerContext.instanceDTO?.isAssistant ?? false; final isAssistant = managerContext.instanceDTO?.isAssistant ?? false;
final l10n = AppLocalizations.of(context)!; 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) { if (_loading) {
return const SizedBox( return const SizedBox(
height: 8, height: 8,

View File

@ -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<CategorieDTO> categories;
final List<int> selectedIds;
final ValueChanged<List<int>> 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<int>.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),
),
),
),
);
}
}

View File

@ -45,10 +45,34 @@ class GeoPointEditor extends StatefulWidget {
State<GeoPointEditor> createState() => _GeoPointEditorState(); State<GeoPointEditor> 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<GeoPointEditor> { class _GeoPointEditorState extends State<GeoPointEditor> {
int? selectedId; int? selectedId;
bool isMapExpanded = false; 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<int, _PendingGeometry> _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 { GeoPointDTO? get _selected {
if (widget.visiblePoints.isEmpty) return null; if (widget.visiblePoints.isEmpty) return null;
return widget.visiblePoints.firstWhere( return widget.visiblePoints.firstWhere(
@ -265,12 +289,34 @@ class _GeoPointEditorState extends State<GeoPointEditor> {
Widget _buildCanvas(AppLocalizations l, GeoPointDTO? selected, Widget _buildCanvas(AppLocalizations l, GeoPointDTO? selected,
{required double height}) { {required double height}) {
final pending = selected == null ? null : _pending[selected.id];
return Padding( return Padding(
padding: const EdgeInsets.all(kSpace4), 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'), key: ValueKey(selected?.id ?? 'none'),
geometry: selected?.geometry, geometry: pending?.geometry ?? selected?.geometry,
color: selected?.polyColor, color: pending?.color ?? selected?.polyColor,
height: height, height: height,
// Les points d'une carte sont stockés en GeoJSON — voir // Les points d'une carte sont stockés en GeoJSON — voir
// `visitapp-web/src/lib/geo.ts`, qui les lit en `[lng, lat]`. // `visitapp-web/src/lib/geo.ts`, qui les lit en `[lng, lat]`.
@ -282,17 +328,17 @@ class _GeoPointEditorState extends State<GeoPointEditor> {
: _label(l, selected, widget.visiblePoints.indexOf(selected)), : _label(l, selected, widget.visiblePoints.indexOf(selected)),
ghosts: [ ghosts: [
for (final point in widget.visiblePoints) 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( MapCanvasGhost(
geometry: point.geometry!, color: point.polyColor), geometry: _pending[point.id]?.geometry ?? point.geometry!,
color: _pending[point.id]?.color ?? point.polyColor),
], ],
onChanged: (geometry, color) { onChanged: (geometry, color) {
if (selected == null) return; if (selected == null) return;
selected.geometry = geometry; setState(() =>
selected.polyColor = color; _pending[selected.id!] = _PendingGeometry(geometry, color));
widget.onPointChanged(selected);
}, },
),
); );
} }
@ -322,6 +368,54 @@ class _GeoPointEditorState extends State<GeoPointEditor> {
} }
} }
/// 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`. /// Les champs d'un point, repris de `showNewOrUpdateGeoPoint`.
/// ///
/// Un point porte neuf champs traduits. Les quatre du haut sont presque /// Un point porte neuf champs traduits. Les quatre du haut sont presque

View File

@ -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/Screens/Configurations/Section/SubSection/Map/category_input_container.dart';
import 'package:manager_app/Components/dropDown_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/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/single_select_container.dart';
import 'package:manager_app/Components/check_input_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/slider_input_container.dart';
@ -226,10 +226,10 @@ class _MapConfigState extends State<MapConfig> {
} }
Widget _buildPointsToolbar() { Widget _buildPointsToolbar() {
return Wrap( final categories = mapDTO.categories ?? [];
spacing: kSpace5,
runSpacing: kSpace4, return Row(
crossAxisAlignment: WrapCrossAlignment.end, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
width: 240, width: 240,
@ -238,45 +238,25 @@ class _MapConfigState extends State<MapConfig> {
onChanged: (String value) => searchNotifier.value = value, onChanged: (String value) => searchNotifier.value = value,
), ),
), ),
if (mapDTO.categories != null && mapDTO.categories!.isNotEmpty) if (categories.isNotEmpty) ...[
SizedBox( const SizedBox(width: kSpace6),
width: 320, // Prend la largeur restante pour que les puces reviennent à la ligne
child: MultiSelectContainer( // au lieu de sortir du cadre.
label: AppLocalizations.of(context)!.categoriesLabel, Expanded(
color: kSecond, child: ValueListenableBuilder<List<int>?>(
initialValue: mapDTO.categories! valueListenable: selectedCategoriesNotifier,
.where((cat) => builder: (context, selected, _) => CategoryFilter(
selectedCategoriesNotifier.value!.contains(cat.id)) categories: categories,
.map((categorie) => _categoryLabel(categorie)) selectedIds: selected ?? const [],
.toList(), onChanged: (ids) => selectedCategoriesNotifier.value = ids,
isMultiple: true, ),
isHTMLLabel: true,
values: mapDTO.categories!
.map((categorie) => _categoryLabel(categorie))
.toList(),
onChanged: (value) {
var tempOutput = new List<String>.from(value);
selectedCategoriesNotifier.value = mapDTO.categories!
.where((c) => tempOutput.contains(_categoryLabel(c)))
.map((cat) => cat.id!)
.toList();
},
), ),
), ),
], ],
],
); );
} }
String _categoryLabel(CategorieDTO categorie) {
final labels = categorie.label ?? [];
if (labels.isEmpty) return "";
return labels
.firstWhere((element) => element.language == 'FR',
orElse: () => labels.first)
.value ??
"";
}
/// Un point vit côté serveur : chaque champ validé et chaque déplacement sur /// Un point vit côté serveur : chaque champ validé et chaque déplacement sur
/// la carte part tout de suite. /// la carte part tout de suite.
Future<void> _savePoint(GeoPointDTO point) async { Future<void> _savePoint(GeoPointDTO point) async {
@ -353,28 +333,23 @@ class _MapConfigState extends State<MapConfig> {
} }
Widget _buildMapHeader(Size size, String mapProviderIn) { Widget _buildMapHeader(Size size, String mapProviderIn) {
return Container( final l = AppLocalizations.of(context)!;
padding: kCardPadding,
margin: const EdgeInsets.only(bottom: kSpace5), // Décision du 2026-08-11 (lot E, W1) : visitapp-web reste sur Leaflet. Le
decoration: BoxDecoration( // champ ne peut pas être masqué pour le web il est porté par la section,
color: kSurface, // et une même configuration est servie au mobile comme au web. La note vit
borderRadius: BorderRadius.circular(kRadiusCard), // sous le champ qu'elle qualifie, au lieu de flotter en pleine largeur.
border: Border.all(color: kLine), final service = Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: kSpace6,
runSpacing: kSpace5,
crossAxisAlignment: WrapCrossAlignment.start,
children: [ children: [
SingleSelectContainer( SingleSelectContainer(
label: AppLocalizations.of(context)!.serviceLabel, label: l.serviceLabel,
color: Colors.black, color: Colors.black,
initialValue: mapProviderIn, initialValue: mapProviderIn,
inputValues: map_providers, inputValues: map_providers,
onChanged: (String value) { onChanged: (String value) {
setState(() {
switch (value) { switch (value) {
case "Google": case "Google":
mapDTO.mapProvider = MapProvider.Google; mapDTO.mapProvider = MapProvider.Google;
@ -384,26 +359,64 @@ class _MapConfigState extends State<MapConfig> {
break; break;
} }
widget.onChanged(mapDTO); widget.onChanged(mapDTO);
});
}), }),
GeolocInputContainer( const SizedBox(height: kSpace2),
label: AppLocalizations.of(context)!.centerPointLabel, Text(l.mapProviderMobileOnlyNote, style: kTextHint),
initialValue: mapDTO.centerLatitude != null && ],
mapDTO.centerLongitude != null );
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!), ? LatLong(double.parse(mapDTO.centerLatitude!),
double.parse(mapDTO.centerLongitude!)) double.parse(mapDTO.centerLongitude!))
: null, : null,
color: kPrimaryColor, color: kPrimaryColor,
onChanged: (LatLong? localisation) { onChanged: (LatLong? localisation) {
if (localisation != null) { if (localisation != null) {
mapDTO.centerLongitude = mapDTO.centerLongitude = localisation.longitude.toString();
localisation.longitude.toString();
mapDTO.centerLatitude = localisation.latitude.toString(); mapDTO.centerLatitude = localisation.latitude.toString();
} }
widget.onChanged(mapDTO); widget.onChanged(mapDTO);
}, },
isSmall: true), isSmall: true);
ResourceInputContainer(
label: AppLocalizations.of(context)!.iconLabel, 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, initialValue: mapDTO.iconResourceId,
color: kPrimaryColor, color: kPrimaryColor,
imageFit: BoxFit.contain, imageFit: BoxFit.contain,
@ -417,29 +430,35 @@ class _MapConfigState extends State<MapConfig> {
} }
widget.onChanged(mapDTO); widget.onChanged(mapDTO);
}, },
isSmall: true), isSmall: true);
],
), final categories = CategoryInputContainer(
const SizedBox(height: kSpace3), label: l.categoriesLabel,
// Décision du 2026-08-11 (lot E, W1) : visitapp-web reste sur Leaflet. initialValue: mapDTO.categories ?? [],
// Le champ ne peut pas être masqué pour le web il est porté par la color: kPrimaryColor,
// section, et une même configuration est servie au mobile comme au web. onChanged: (List<CategorieDTO>? value) {
// On le dit donc au client au lieu de le laisser croire le contraire. if (value == null) return;
Align( mapDTO.categories = value;
alignment: Alignment.centerLeft, mapDTO.points?.forEach((p) {
child: Text( if (p.categorieId != null &&
AppLocalizations.of(context)!.mapProviderMobileOnlyNote, !mapDTO.categories!
style: kTextHint, .map((c) => c.id)
), .any((e) => e != null && e == p.categorieId)) {
), p.categorieId = null;
const SizedBox(height: kSpace6), }
Wrap( });
spacing: kSpace6, widget.onChanged(mapDTO);
runSpacing: kSpace5, },
crossAxisAlignment: WrapCrossAlignment.start, );
final display = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(l.displayLabel, style: kLabelField),
const SizedBox(height: kSpace2),
CheckInputContainer( CheckInputContainer(
label: AppLocalizations.of(context)!.listViewLabel, label: l.listViewOption,
isChecked: mapDTO.isListViewEnabled ?? false, isChecked: mapDTO.isListViewEnabled ?? false,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -448,63 +467,81 @@ class _MapConfigState extends State<MapConfig> {
}); });
}, },
), ),
if (mapDTO.mapProvider == MapProvider.Google)
DropDownInputContainer(
label: AppLocalizations.of(context)!.typeLabel,
values: map_types,
initialValue: mapType,
onChange: (String? value) {
mapDTO.mapType = MapTypeApp.fromJson(value);
widget.onChanged(mapDTO);
},
),
if (mapDTO.mapProvider == MapProvider.MapBox)
DropDownInputContainer(
label: AppLocalizations.of(context)!.typeLabel,
values: map_types_mapBox,
initialValue: mapTypeMapBox,
onChange: (String? value) {
mapDTO.mapTypeMapbox = MapTypeMapBox.fromJson(value);
widget.onChanged(mapDTO);
},
),
SliderInputContainer(
label: AppLocalizations.of(context)!.zoomLabel,
initialValue:
mapDTO.zoom != null ? mapDTO.zoom!.toDouble() : 18,
color: kPrimaryColor,
min: 0,
max: 30,
onChanged: (double value) {
mapDTO.zoom = value.toInt();
widget.onChanged(mapDTO);
},
),
SizedBox(
width: 220,
child: CategoryInputContainer(
label: AppLocalizations.of(context)!.categoriesLabel,
initialValue:
mapDTO.categories != null ? mapDTO.categories! : [],
color: kPrimaryColor,
onChanged: (List<CategorieDTO>? value) {
if (value != null) {
mapDTO.categories = value;
if (mapDTO.points != null) {
mapDTO.points!.forEach((p) {
if (p.categorieId != null &&
!mapDTO.categories!.map((c) => c.id).any(
(e) => e != null && e == p.categorieId)) {
p.categorieId = null;
}
});
}
widget.onChanged(mapDTO);
}
},
),
)
], ],
);
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),
],
);
},
), ),
], ],
), ),

View File

@ -143,6 +143,26 @@ class _MenuConfigState extends State<MenuConfig> {
} }
} }
Future<void> _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 /// Ouvre l'écran de la sous-section. Il lit `selectedSubSectionRawData`, donc
/// on lui passe le JSON brut correspondant, pas seulement le DTO. /// on lui passe le JSON brut correspondant, pas seulement le DTO.
void _openSubSection(SectionDTO section, int index) { void _openSubSection(SectionDTO section, int index) {
@ -167,6 +187,7 @@ class _MenuConfigState extends State<MenuConfig> {
onReorder: _onReorder, onReorder: _onReorder,
onAdd: _createSubSection, onAdd: _createSubSection,
onTap: _openSubSection, onTap: _openSubSection,
onToggleVisibility: _toggleVisibility,
); );
} }
} }

View File

@ -58,6 +58,12 @@ class _SectionDetailScreenState extends State<SectionDetailScreen> {
final GlobalKey globalKey = GlobalKey(); final GlobalKey globalKey = GlobalKey();
late Future<Object?> _sectionFuture; late Future<Object?> _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<Object?> _loadSection() { Future<Object?> _loadSection() {
final appContext = Provider.of<AppContext>(context, listen: false); final appContext = Provider.of<AppContext>(context, listen: false);
return getSection( return getSection(
@ -170,6 +176,8 @@ class _SectionDetailScreenState extends State<SectionDetailScreen> {
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: kPagePadding, padding: kPagePadding,
child: KeyedSubtree(
key: ValueKey(_formRevision),
child: EditorColumns( child: EditorColumns(
main: [ main: [
_cardIdentity(appContext, l), _cardIdentity(appContext, l),
@ -181,6 +189,7 @@ class _SectionDetailScreenState extends State<SectionDetailScreen> {
), ),
), ),
), ),
),
], ],
); );
} }
@ -354,10 +363,18 @@ class _SectionDetailScreenState extends State<SectionDetailScreen> {
.sectionApi! .sectionApi!
.sectionGetDetail(sectionDTO.id!); .sectionGetDetail(sectionDTO.id!);
var nullableSection = SectionDTO.fromJson(rawData); var nullableSection = SectionDTO.fromJson(rawData);
if (nullableSection != null) { if (nullableSection == null) return;
managerAppContext.selectedSection = nullableSection!;
managerAppContext.selectedSection = nullableSection;
appContext.setContext(managerAppContext); appContext.setContext(managerAppContext);
}
if (!mounted) return;
setState(() {
sectionDetailDTO = null;
lastLoadedSectionId = null;
_sectionFuture = Future.value(rawData);
_formRevision++;
});
} }
Future<void> delete(AppContext appContext) async { Future<void> delete(AppContext appContext) async {

View File

@ -38,6 +38,15 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
Future<ConfigurationDTO?>? _configFuture; Future<ConfigurationDTO?>? _configFuture;
Future<List<SectionDTO>?>? _sectionsFuture; Future<List<SectionDTO>?>? _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context); final appContext = Provider.of<AppContext>(context);
@ -107,6 +116,8 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: kPagePadding, padding: kPagePadding,
child: KeyedSubtree(
key: ValueKey(_formRevision),
child: EditorColumns( child: EditorColumns(
main: [ main: [
_cardGeneral(config, l), _cardGeneral(config, l),
@ -118,6 +129,7 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
), ),
), ),
), ),
),
], ],
); );
} }
@ -277,9 +289,12 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
return SectionReorderList( return SectionReorderList(
sectionsIn: sections!, sectionsIn: sections!,
configurationId: config.id!, configurationId: config.id!,
onChangedOrder: (List<SectionDTO> 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<SectionDTO> sectionsOut) {
sections = sectionsOut; sections = sectionsOut;
await managerCtx.clientAPI!.sectionApi!.sectionUpdateOrder(sections!); _orderDirty = true;
}, },
askReload: () => setState(() { askReload: () => setState(() {
_sectionsFuture = null; _sectionsFuture = null;
@ -327,6 +342,15 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
ConfigurationDTO? configuration = await managerAppContext.clientAPI!.configurationApi!.configurationGetDetail(config.id!); ConfigurationDTO? configuration = await managerAppContext.clientAPI!.configurationApi!.configurationGetDetail(config.id!);
managerAppContext.selectedConfiguration = configuration; managerAppContext.selectedConfiguration = configuration;
appContext.setContext(managerAppContext); appContext.setContext(managerAppContext);
if (!mounted) return;
setState(() {
_configFuture = Future.value(configuration);
_sectionsFuture = null;
sections = null;
_orderDirty = false;
_formRevision++;
});
} }
Future<void> delete(ConfigurationDTO config, AppContext appContext) async { Future<void> delete(ConfigurationDTO config, AppContext appContext) async {
@ -346,6 +370,12 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
Future<void> save(ConfigurationDTO config, AppContext appContext) async { Future<void> save(ConfigurationDTO config, AppContext appContext) async {
ManagerAppContext managerAppContext = appContext.getContext(); ManagerAppContext managerAppContext = appContext.getContext();
if (_orderDirty && sections != null) {
await managerAppContext.clientAPI!.sectionApi!.sectionUpdateOrder(sections!);
_orderDirty = false;
}
ConfigurationDTO? configuration = await managerAppContext.clientAPI!.configurationApi!.configurationUpdate(config); ConfigurationDTO? configuration = await managerAppContext.clientAPI!.configurationApi!.configurationUpdate(config);
managerAppContext.selectedConfiguration = configuration; managerAppContext.selectedConfiguration = configuration;
appContext.setContext(managerAppContext); appContext.setContext(managerAppContext);

View File

@ -17,6 +17,7 @@ class SectionCard extends StatelessWidget {
required this.onTap, required this.onTap,
this.dragHandle, this.dragHandle,
this.isDropTarget = false, this.isDropTarget = false,
this.onToggleVisibility,
}) : super(key: key); }) : super(key: key);
final SectionDTO section; final SectionDTO section;
@ -25,6 +26,9 @@ class SectionCard extends StatelessWidget {
final Widget? dragHandle; final Widget? dragHandle;
final bool isDropTarget; final bool isDropTarget;
/// Null pour un utilisateur qui ne peut pas éditer : la tuile perd le bouton.
final VoidCallback? onToggleVisibility;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
@ -101,6 +105,20 @@ class SectionCard extends StatelessWidget {
fontSize: 10, color: kInk3)), 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) if (dragHandle != null)
Positioned(top: 2, right: 2, child: dragHandle!), 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 ». /// 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 /// 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.onReorder,
required this.onTap, required this.onTap,
required this.onAdd, required this.onAdd,
this.onToggleVisibility,
}) : super(key: key); }) : super(key: key);
final List<SectionDTO> sections; final List<SectionDTO> sections;
final void Function(int oldIndex, int newIndex) onReorder; final void Function(int oldIndex, int newIndex) onReorder;
final void Function(SectionDTO section, int index) onTap; final void Function(SectionDTO section, int index) onTap;
final VoidCallback onAdd; final VoidCallback onAdd;
final void Function(SectionDTO section)? onToggleVisibility;
@override @override
State<SectionGrid> createState() => _SectionGridState(); State<SectionGrid> createState() => _SectionGridState();
@ -212,6 +267,9 @@ class _SectionGridState extends State<SectionGrid> {
isDropTarget: dropTargetIndex == index, isDropTarget: dropTargetIndex == index,
dragHandle: SectionDragHandle(index: index, width: width), dragHandle: SectionDragHandle(index: index, width: width),
onTap: () => widget.onTap(widget.sections[index], index), onTap: () => widget.onTap(widget.sections[index], index),
onToggleVisibility: widget.onToggleVisibility == null
? null
: () => widget.onToggleVisibility!(widget.sections[index]),
), ),
), ),
), ),

View File

@ -79,10 +79,37 @@ class _SectionReorderListState extends State<SectionReorderList> {
} }
} }
/// 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context); final appContext = Provider.of<AppContext>(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final canEdit = (appContext.getContext() as ManagerAppContext).canEdit;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -102,6 +129,9 @@ class _SectionReorderListState extends State<SectionReorderList> {
sections: sections, sections: sections,
onReorder: _onReorder, onReorder: _onReorder,
onAdd: () => _createSection(appContext), onAdd: () => _createSection(appContext),
onToggleVisibility: canEdit
? (section) => _toggleVisibility(appContext, section)
: null,
onTap: (section, index) { onTap: (section, index) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() { setState(() {

View File

@ -88,12 +88,29 @@ class _MainScreenState extends State<MainScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_buildMenu(widget.instance); _buildMenu(widget.instance);
_renderedInstanceId = widget.instance.id;
currentPosition.value ??= _defaultPosition(widget.instance); currentPosition.value ??= _defaultPosition(widget.instance);
selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance);
} }
/// L'instance sur laquelle le menu et l'écran courant ont é 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 /// 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 /// 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 /// l'instance précédente, et la position courante peut désigner une
@ -103,14 +120,7 @@ class _MainScreenState extends State<MainScreen> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.instance.id == widget.instance.id) return; if (oldWidget.instance.id == widget.instance.id) return;
_buildMenu(widget.instance); _applyInstance(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);
}
selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance); selectedElement = initElementToShow(context, widget.view, currentPosition.value!, menu, widget.instance);
} }
@ -275,7 +285,17 @@ class _MainScreenState extends State<MainScreen> {
setState(() { setState(() {
managerCtx.instanceId = newInstance.id; managerCtx.instanceId = newInstance.id;
managerCtx.instanceDTO = newInstance; 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<AppContext>(context, listen: false)
.setContext(managerCtx);
final view = newInstance.isMobile! ? 'mobile' final view = newInstance.isMobile! ? 'mobile'
: newInstance.isTablet! ? 'kiosk' : newInstance.isTablet! ? 'kiosk'
: newInstance.isWeb! ? 'web' : newInstance.isWeb! ? 'web'
@ -543,6 +563,16 @@ class _MainScreenState extends State<MainScreen> {
final appContext = Provider.of<AppContext>(context); final appContext = Provider.of<AppContext>(context);
ManagerAppContext managerAppContext = appContext.getContext(); 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 // Synchronise les items de menu sensibles au rôle à chaque rebuild
final role = managerAppContext.role; final role = managerAppContext.role;
final hasAdminItems = menu.sections!.any((s) => s.menuId == 8); final hasAdminItems = menu.sections!.any((s) => s.menuId == 8);
@ -608,10 +638,16 @@ class _MainScreenState extends State<MainScreen> {
child: ValueListenableBuilder<int?>( child: ValueListenableBuilder<int?>(
valueListenable: currentPosition, valueListenable: currentPosition,
builder: (context, value, _) { 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( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: selectedElement, child: KeyedSubtree(
key: ValueKey(instance.id),
child: selectedElement!,
),
); );
} }
), ),

View File

@ -5,6 +5,7 @@ import 'package:manager_app/l10n/app_localizations.dart';
import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/app_context.dart'; import 'package:manager_app/app_context.dart';
import 'package:manager_app/Components/common_loader.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:manager_app/constants.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -59,11 +60,14 @@ class _UsersScreenState extends State<UsersScreen> {
String firstName, String lastName, int roleValue) async { String firstName, String lastName, int roleValue) async {
// No password sent: the backend generates an invitation token and emails // No password sent: the backend generates an invitation token and emails
// a "set your password" link to the new user instead. // 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 = { final body = {
'email': email, 'email': email,
'firstName': firstName, 'firstName': firstName,
'lastName': lastName, 'lastName': lastName,
'role': roleValue, 'role': roleValue,
'instanceId': ctx.instanceId,
}; };
final response = await ctx.clientAPI!.apiApi!.invokeAPI( final response = await ctx.clientAPI!.apiApi!.invokeAPI(
'/api/User', 'POST', [], body, {}, {}, 'application/json'); '/api/User', 'POST', [], body, {}, {}, 'application/json');
@ -71,14 +75,16 @@ class _UsersScreenState extends State<UsersScreen> {
// `invokeAPI` ne lève pas sur un code d'erreur : sans ce test, un e-mail déjà // `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 // utilisé (409) ou un rôle refusé (403) laissaient la liste inchangée sans
// qu'aucun message n'explique pourquoi. // qu'aucun message n'explique pourquoi.
if (response.statusCode != 200 && mounted) { if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(l.userCreateError(utf8.decode(response.bodyBytes))), if (response.statusCode != 200) {
backgroundColor: kError, showNotification(kError, kWhite,
)); l.userCreateError(utf8.decode(response.bodyBytes)), context, null);
return; return;
} }
showNotification(kSuccess, kWhite, l.userCreatedSuccess, context, null);
await _loadUsers(ctx); await _loadUsers(ctx);
} }
@ -90,13 +96,36 @@ class _UsersScreenState extends State<UsersScreen> {
'lastName': lastName, 'lastName': lastName,
'role': roleValue, 'role': roleValue,
}; };
await ctx.clientAPI!.apiApi!.invokeAPI( final response = await ctx.clientAPI!.apiApi!.invokeAPI(
'/api/User', 'PUT', [], body, {}, {}, 'application/json'); '/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); await _loadUsers(ctx);
} }
Future<void> _deleteUser(ManagerAppContext ctx, String id) async { Future<void> _deleteUser(ManagerAppContext ctx, String id) async {
final l = AppLocalizations.of(context)!;
try {
await ctx.clientAPI!.userApi!.userDeleteUser(id); 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); await _loadUsers(ctx);
} }
@ -206,15 +235,17 @@ class _UsersScreenState extends State<UsersScreen> {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
showDialog( showDialog(
context: context, 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), title: Text(l.deleteUserTitle),
content: Text(l.deleteUserConfirm(user['email'] as String? ?? '')), content: Text(l.deleteUserConfirm(user['email'] as String? ?? '')),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l.cancel)), TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(l.cancel)),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(dialogContext);
await _deleteUser(ctx, user['id'] as String); await _deleteUser(ctx, user['id'] as String);
}, },
child: Text(l.delete, style: const TextStyle(color: Colors.white)), child: Text(l.delete, style: const TextStyle(color: Colors.white)),

View File

@ -42,6 +42,7 @@ class _LoginScreenState extends State<LoginScreen> {
String? instanceId; String? instanceId;
String? pinCode; String? pinCode;
Storage localStorage = window.localStorage; Storage localStorage = window.localStorage;
late final Future<String> appVersion = getAppVersion();
void authenticateTRY(AppContext appContext, bool fromClick) async { void authenticateTRY(AppContext appContext, bool fromClick) async {
clientAPI = Client(this.host!); clientAPI = Client(this.host!);
@ -290,7 +291,7 @@ class _LoginScreenState extends State<LoginScreen> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
FutureBuilder( FutureBuilder(
future: getAppVersion(), future: appVersion,
builder: (context, AsyncSnapshot<String> snapshot) { builder: (context, AsyncSnapshot<String> snapshot) {
if (snapshot.connectionState == ConnectionState.done && if (snapshot.connectionState == ConnectionState.done &&
snapshot.data != null) { snapshot.data != null) {
@ -389,6 +390,6 @@ class _LoginScreenState extends State<LoginScreen> {
Future<String> getAppVersion() async { Future<String> getAppVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform(); PackageInfo packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version; return 'v${packageInfo.version} \u00b7 $kGitSha';
} }
} }

View File

@ -23,6 +23,13 @@ const kSuccess = Color(0xFF8bc34a);
const kApiBaseUrl = String.fromEnvironment('API_BASE_URL', const kApiBaseUrl = String.fromEnvironment('API_BASE_URL',
defaultValue: 'http://localhost:5000'); defaultValue: 'http://localhost:5000');
// Empreinte du build, injectee elle aussi au build :
// --dart-define=GIT_SHA=$(git rev-parse --short HEAD)
// Elle est affichee sous le titre de l'ecran de login et sert a relier un
// bundle deploye au commit exact qui l'a produit : le numero de version du
// pubspec ne suffisait pas, il ne bougeait pas d'un deploiement a l'autre.
const kGitSha = String.fromEnvironment('GIT_SHA', defaultValue: 'dev');
// Responsive // Responsive
const kBreakpointMobile = 850.0; const kBreakpointMobile = 850.0;

View File

@ -581,13 +581,19 @@
"searchLabel": "Search:", "searchLabel": "Search:",
"geopointsLoadError": "Error loading geographic points", "geopointsLoadError": "Error loading geographic points",
"geopointDeleteConfirm": "Are you sure you want to delete this geographic point?", "geopointDeleteConfirm": "Are you sure you want to delete this geographic point?",
"serviceLabel": "Service:", "serviceLabel": "Service",
"centerPointLabel": "Center point:", "geopointConfirmPosition": "Confirm position",
"iconLabel": "Icon:", "clearFilter": "Show all",
"listViewLabel": "List view:", "mapGroupMap": "Map",
"typeLabel": "Type:", "mapGroupPoints": "Points of interest",
"zoomLabel": "Zoom:", "displayLabel": "Display",
"categoriesLabel": "Categories:", "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", "startDateLabel": "Start date",
"notDefined": "Not defined", "notDefined": "Not defined",
"endDateLabel": "End date", "endDateLabel": "End date",
@ -834,6 +840,25 @@
} }
}, },
"usersQuotaHint": "Delete a user to invite another one.", "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": "Creation failed: {message}",
"@userCreateError": { "@userCreateError": {
"placeholders": { "placeholders": {
@ -1341,6 +1366,11 @@
} }
}, },
"sectionHiddenBadge": "Hidden", "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", "addSectionTile": "Add a section",
"resourceTypeImage": "Image", "resourceTypeImage": "Image",
"resourceTypeImageUrl": "Image (URL)", "resourceTypeImageUrl": "Image (URL)",

View File

@ -581,13 +581,19 @@
"searchLabel": "Recherche :", "searchLabel": "Recherche :",
"geopointsLoadError": "Une erreur est survenue lors de la récupération des points géographiques", "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 ?", "geopointDeleteConfirm": "Êtes-vous sûr de vouloir supprimer ce point géographique ?",
"serviceLabel": "Service :", "serviceLabel": "Service",
"centerPointLabel": "Point de centrage :", "geopointConfirmPosition": "Valider la position",
"iconLabel": "Icône :", "clearFilter": "Tout afficher",
"listViewLabel": "Vue liste :", "mapGroupMap": "Carte",
"typeLabel": "Type :", "mapGroupPoints": "Points d'intérêt",
"zoomLabel": "Zoom :", "displayLabel": "Affichage",
"categoriesLabel": "Catégories :", "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", "startDateLabel": "Date de début",
"notDefined": "Non définie", "notDefined": "Non définie",
"endDateLabel": "Date de fin", "endDateLabel": "Date de fin",
@ -834,6 +840,25 @@
} }
}, },
"usersQuotaHint": "Supprimez un utilisateur pour en inviter un autre.", "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": "La création a échoué : {message}",
"@userCreateError": { "@userCreateError": {
"placeholders": { "placeholders": {
@ -1341,6 +1366,11 @@
} }
}, },
"sectionHiddenBadge": "Masquée", "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", "addSectionTile": "Ajouter une section",
"resourceTypeImage": "Image", "resourceTypeImage": "Image",
"resourceTypeImageUrl": "Image (URL)", "resourceTypeImageUrl": "Image (URL)",

View File

@ -2527,43 +2527,79 @@ abstract class AppLocalizations {
/// No description provided for @serviceLabel. /// No description provided for @serviceLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Service :'** /// **'Service'**
String get serviceLabel; 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. /// No description provided for @centerPointLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Point de centrage :'** /// **'Point de centrage'**
String get centerPointLabel; String get centerPointLabel;
/// No description provided for @iconLabel. /// No description provided for @iconLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Icône :'** /// **'Icône par défaut'**
String get iconLabel; String get iconLabel;
/// No description provided for @listViewLabel. /// No description provided for @listViewLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Vue liste :'** /// **'Vue liste'**
String get listViewLabel; String get listViewLabel;
/// No description provided for @typeLabel. /// No description provided for @typeLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Type :'** /// **'Type de carte'**
String get typeLabel; String get typeLabel;
/// No description provided for @zoomLabel. /// No description provided for @zoomLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Zoom :'** /// **'Zoom initial'**
String get zoomLabel; String get zoomLabel;
/// No description provided for @categoriesLabel. /// No description provided for @categoriesLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'Catégories :'** /// **'Catégories'**
String get categoriesLabel; String get categoriesLabel;
/// No description provided for @startDateLabel. /// No description provided for @startDateLabel.
@ -3694,6 +3730,36 @@ abstract class AppLocalizations {
/// **'Supprimez un utilisateur pour en inviter un autre.'** /// **'Supprimez un utilisateur pour en inviter un autre.'**
String get usersQuotaHint; 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. /// No description provided for @userCreateError.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
@ -5632,6 +5698,36 @@ abstract class AppLocalizations {
/// **'Masquée'** /// **'Masquée'**
String get sectionHiddenBadge; 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. /// No description provided for @addSectionTile.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:

View File

@ -1349,25 +1349,43 @@ class AppLocalizationsEn extends AppLocalizations {
'Are you sure you want to delete this geographic point?'; 'Are you sure you want to delete this geographic point?';
@override @override
String get serviceLabel => 'Service:'; String get serviceLabel => 'Service';
@override @override
String get centerPointLabel => 'Center point:'; String get geopointConfirmPosition => 'Confirm position';
@override @override
String get iconLabel => 'Icon:'; String get clearFilter => 'Show all';
@override @override
String get listViewLabel => 'List view:'; String get mapGroupMap => 'Map';
@override @override
String get typeLabel => 'Type:'; String get mapGroupPoints => 'Points of interest';
@override @override
String get zoomLabel => 'Zoom:'; String get displayLabel => 'Display';
@override @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 @override
String get startDateLabel => 'Start date'; String get startDateLabel => 'Start date';
@ -1960,6 +1978,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get usersQuotaHint => 'Delete a user to invite another one.'; 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 @override
String userCreateError(String message) { String userCreateError(String message) {
return 'Creation failed: $message'; return 'Creation failed: $message';
@ -3048,6 +3086,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get sectionHiddenBadge => 'Hidden'; 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 @override
String get addSectionTile => 'Add a section'; String get addSectionTile => 'Add a section';

View File

@ -1381,25 +1381,43 @@ class AppLocalizationsFr extends AppLocalizations {
'Êtes-vous sûr de vouloir supprimer ce point géographique ?'; 'Êtes-vous sûr de vouloir supprimer ce point géographique ?';
@override @override
String get serviceLabel => 'Service :'; String get serviceLabel => 'Service';
@override @override
String get centerPointLabel => 'Point de centrage :'; String get geopointConfirmPosition => 'Valider la position';
@override @override
String get iconLabel => 'Icône :'; String get clearFilter => 'Tout afficher';
@override @override
String get listViewLabel => 'Vue liste :'; String get mapGroupMap => 'Carte';
@override @override
String get typeLabel => 'Type :'; String get mapGroupPoints => 'Points d\'intérêt';
@override @override
String get zoomLabel => 'Zoom :'; String get displayLabel => 'Affichage';
@override @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 @override
String get startDateLabel => 'Date de début'; String get startDateLabel => 'Date de début';
@ -2000,6 +2018,26 @@ class AppLocalizationsFr extends AppLocalizations {
String get usersQuotaHint => String get usersQuotaHint =>
'Supprimez un utilisateur pour en inviter un autre.'; '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 @override
String userCreateError(String message) { String userCreateError(String message) {
return 'La création a échoué : $message'; return 'La création a échoué : $message';
@ -3099,6 +3137,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get sectionHiddenBadge => 'Masquée'; 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 @override
String get addSectionTile => 'Ajouter une section'; String get addSectionTile => 'Ajouter une section';

View File

@ -1363,25 +1363,43 @@ class AppLocalizationsNl extends AppLocalizations {
'Weet u zeker dat u dit geografische punt wilt verwijderen?'; 'Weet u zeker dat u dit geografische punt wilt verwijderen?';
@override @override
String get serviceLabel => 'Service:'; String get serviceLabel => 'Service';
@override @override
String get centerPointLabel => 'Middelpunt:'; String get geopointConfirmPosition => 'Positie bevestigen';
@override @override
String get iconLabel => 'Pictogram:'; String get clearFilter => 'Alles tonen';
@override @override
String get listViewLabel => 'Lijstweergave:'; String get mapGroupMap => 'Kaart';
@override @override
String get typeLabel => 'Type:'; String get mapGroupPoints => 'Interessepunten';
@override @override
String get zoomLabel => 'Zoom:'; String get displayLabel => 'Weergave';
@override @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 @override
String get startDateLabel => 'Startdatum'; String get startDateLabel => 'Startdatum';
@ -1981,6 +1999,26 @@ class AppLocalizationsNl extends AppLocalizations {
String get usersQuotaHint => String get usersQuotaHint =>
'Verwijder een gebruiker om er een andere uit te nodigen.'; '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 @override
String userCreateError(String message) { String userCreateError(String message) {
return 'Aanmaken mislukt: $message'; return 'Aanmaken mislukt: $message';
@ -3075,6 +3113,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get sectionHiddenBadge => 'Verborgen'; 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 @override
String get addSectionTile => 'Een sectie toevoegen'; String get addSectionTile => 'Een sectie toevoegen';

View File

@ -581,13 +581,19 @@
"searchLabel": "Zoeken:", "searchLabel": "Zoeken:",
"geopointsLoadError": "Fout bij het laden van geografische punten", "geopointsLoadError": "Fout bij het laden van geografische punten",
"geopointDeleteConfirm": "Weet u zeker dat u dit geografische punt wilt verwijderen?", "geopointDeleteConfirm": "Weet u zeker dat u dit geografische punt wilt verwijderen?",
"serviceLabel": "Service:", "serviceLabel": "Service",
"centerPointLabel": "Middelpunt:", "geopointConfirmPosition": "Positie bevestigen",
"iconLabel": "Pictogram:", "clearFilter": "Alles tonen",
"listViewLabel": "Lijstweergave:", "mapGroupMap": "Kaart",
"typeLabel": "Type:", "mapGroupPoints": "Interessepunten",
"zoomLabel": "Zoom:", "displayLabel": "Weergave",
"categoriesLabel": "Categorieën:", "listViewOption": "Lijstweergave aanbieden",
"centerPointLabel": "Middelpunt",
"iconLabel": "Standaardpictogram",
"listViewLabel": "Lijstweergave",
"typeLabel": "Kaarttype",
"zoomLabel": "Beginzoom",
"categoriesLabel": "Categorieën",
"startDateLabel": "Startdatum", "startDateLabel": "Startdatum",
"notDefined": "Niet gedefinieerd", "notDefined": "Niet gedefinieerd",
"endDateLabel": "Einddatum", "endDateLabel": "Einddatum",
@ -834,6 +840,25 @@
} }
}, },
"usersQuotaHint": "Verwijder een gebruiker om er een andere uit te nodigen.", "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": "Aanmaken mislukt: {message}",
"@userCreateError": { "@userCreateError": {
"placeholders": { "placeholders": {
@ -1341,6 +1366,11 @@
} }
}, },
"sectionHiddenBadge": "Verborgen", "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", "addSectionTile": "Een sectie toevoegen",
"resourceTypeImage": "Afbeelding", "resourceTypeImage": "Afbeelding",
"resourceTypeImageUrl": "Afbeelding (URL)", "resourceTypeImageUrl": "Afbeelding (URL)",

View File

@ -125,7 +125,12 @@ Future<void> main() async {
path: '/main/:view', path: '/main/:view',
builder: (context, state) { builder: (context, state) {
final view = state.pathParameters['view']; 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( return MainScreen(
key: ValueKey('${managerAppContext.instanceId}/$view'),
instance: managerAppContext.instanceDTO!, instance: managerAppContext.instanceDTO!,
view: view, view: view,
); );

View File

@ -1306,4 +1306,57 @@ class SectionApi {
} }
return null; return null;
} }
/// Performs an HTTP 'PUT /api/Section/{id}/visibility' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [bool] isActive (required):
Future<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'isActive', isActive));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [bool] isActive (required):
Future<void> sectionSetVisibility(
String id,
bool isActive,
) async {
final response = await sectionSetVisibilityWithHttpInfo(
id,
isActive,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
} }

View File

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.0.0+10 version: 3.1.3+11
environment: environment:
sdk: ">=3.1.0 <4.0.0" sdk: ">=3.1.0 <4.0.0"

View File

@ -14,6 +14,7 @@
<base href="/"> <base href="/">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter application."> <meta name="description" content="A new Flutter application.">
@ -112,9 +113,6 @@
loadMainDartJs(); loadMainDartJs();
} }
</script> </script>
<script type="text/javascript">
window.flutterWebRenderer = "html";
</script>
<script type="module"> <script type="module">
// Import the functions you need from the SDKs you need // Import the functions you need from the SDKs you need
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js"; import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";