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

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

Fournisseur de carte (W1) — décision : visitapp-web reste sur Leaflet. Le
champ ne peut pas être masqué pour le web, il est porté par la section et
une même configuration est servie au mobile comme au web ; il est donc
conservé et documenté dans l'interface comme réglage mobile/tablette.

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

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

533 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/Components/check_input_container.dart';
import 'package:manager_app/Components/multi_string_input_container.dart';
import 'package:manager_app/Components/single_select_container.dart';
import 'package:manager_app/Components/string_input_container.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/confirmation_dialog.dart';
import 'package:provider/provider.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'showNewOrUpdateEventAgenda.dart';
enum _AgendaDateFilter { all, thisMonth, thisYear, past }
class AgendaConfig extends StatefulWidget {
final String? color;
final String? label;
final AgendaDTO initialValue;
final ValueChanged<AgendaDTO> onChanged;
const AgendaConfig({
Key? key,
this.color,
this.label,
required this.initialValue,
required this.onChanged,
}) : super(key: key);
@override
_AgendaConfigState createState() => _AgendaConfigState();
}
class _AgendaConfigState extends State<AgendaConfig> {
late AgendaDTO agendaDTO;
List<EventAgendaDTO> events = [];
String _searchFilter = '';
_AgendaDateFilter _dateFilter = _AgendaDateFilter.all;
List<EventAgendaDTO> get _filteredEvents {
final now = DateTime.now();
var filtered = events.where((event) {
switch (_dateFilter) {
case _AgendaDateFilter.thisMonth:
return event.dateFrom != null &&
event.dateFrom!.year == now.year &&
event.dateFrom!.month == now.month;
case _AgendaDateFilter.thisYear:
return event.dateFrom != null && event.dateFrom!.year == now.year;
case _AgendaDateFilter.past:
return event.dateFrom != null && event.dateFrom!.isBefore(now);
case _AgendaDateFilter.all:
return true;
}
}).toList();
if (_searchFilter.isNotEmpty) {
filtered = filtered.where((event) {
final label = _labelFor(event);
return label.toLowerCase().contains(_searchFilter.toLowerCase());
}).toList();
}
filtered.sort((a, b) {
if (a.dateFrom == null && b.dateFrom == null) return 0;
if (a.dateFrom == null) return 1;
if (b.dateFrom == null) return -1;
return a.dateFrom!.compareTo(b.dateFrom!);
});
return filtered;
}
String _labelFor(EventAgendaDTO event) {
if (event.label != null && event.label!.isNotEmpty) {
return (event.label!.firstWhere((t) => t.language == 'FR',
orElse: () => event.label![0]))
.value ??
'';
}
return '';
}
/// Groups events synced from the same external agenda that share the same
/// address and dates: the sync creates one EventAgendaDTO per language
/// instead of one multilingual event, so this recombines them for display.
String? _syncGroupKey(EventAgendaDTO event) {
if (event.isSynced != true) return null;
final dates =
"${event.dateFrom?.toIso8601String()}|${event.dateTo?.toIso8601String()}";
final address = event.address?.address?.trim();
if (address != null && address.isNotEmpty) {
return "addr:$address|$dates";
}
// No address to match on (common for these synced events): fall back to
// an identical title, which only happens when the source repeats the
// same untranslated name per language pass.
final label = _labelFor(event).trim();
if (label.isNotEmpty) {
return "label:$label|$dates";
}
return null;
}
List<List<EventAgendaDTO>> get _displayGroups {
final groups = <List<EventAgendaDTO>>[];
final groupByKey = <String, List<EventAgendaDTO>>{};
for (final event in _filteredEvents) {
final key = _syncGroupKey(event);
if (key == null) {
groups.add([event]);
continue;
}
final existing = groupByKey[key];
if (existing != null) {
existing.add(event);
} else {
final group = [event];
groupByKey[key] = group;
groups.add(group);
}
}
return groups;
}
@override
void initState() {
super.initState();
agendaDTO = widget.initialValue;
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
}
Future<void> _loadFromApi() async {
if (agendaDTO.id == null || !mounted) return;
final appContext = Provider.of<AppContext>(context, listen: false);
final api = (appContext.getContext() as ManagerAppContext)
.clientAPI!
.sectionAgendaApi!;
try {
final fetched =
await api.sectionAgendaGetAllEventAgendaFromSection(agendaDTO.id!);
if (fetched == null || !mounted) return;
setState(() {
events = List.from(fetched);
});
} catch (e) {
// Silently keep empty on error
}
}
SectionAgendaApi _api(BuildContext ctx) {
final appContext = Provider.of<AppContext>(ctx, listen: false);
return (appContext.getContext() as ManagerAppContext)
.clientAPI!
.sectionAgendaApi!;
}
@override
Widget build(BuildContext context) {
Size size = MediaQuery.of(context).size;
var mapProviderIn = "";
switch (agendaDTO.agendaMapProvider) {
case MapProvider.Google:
mapProviderIn = "Google";
break;
case MapProvider.MapBox:
mapProviderIn = "MapBox";
break;
default:
mapProviderIn = "Google";
break;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildAgendaHeader(size, mapProviderIn),
...[
const Divider(height: 32),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(AppLocalizations.of(context)!.agendaEventsLabel,
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.addEvent),
onPressed: agendaDTO.id == null
? null
: () {
showNewOrUpdateEventAgenda(
context,
null,
agendaDTO.id!,
(newEvent) async {
try {
final created = await _api(context)
.sectionAgendaCreateEventAgenda(
agendaDTO.id!, newEvent);
if (created != null && mounted) {
setState(() => events.add(created));
showNotification(kSuccess, kWhite,
AppLocalizations.of(context)!.agendaEventCreatedSuccess, context, null);
}
} catch (e) {
showNotification(
kError,
kWhite,
AppLocalizations.of(context)!.agendaEventCreateError,
context,
null);
rethrow;
}
},
);
},
style: ElevatedButton.styleFrom(
backgroundColor: kSuccess,
foregroundColor: kWhite,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 280,
child: StringInputContainer(
label: AppLocalizations.of(context)!.searchLabel,
onChanged: (v) => setState(() => _searchFilter = v),
),
),
const SizedBox(width: 16),
Wrap(
spacing: 8,
children: [
_buildDateFilterChip(
_AgendaDateFilter.all,
AppLocalizations.of(context)!.agendaFilterAll),
_buildDateFilterChip(
_AgendaDateFilter.thisMonth,
AppLocalizations.of(context)!.agendaFilterThisMonth),
_buildDateFilterChip(
_AgendaDateFilter.thisYear,
AppLocalizations.of(context)!.agendaFilterThisYear),
_buildDateFilterChip(
_AgendaDateFilter.past,
AppLocalizations.of(context)!.agendaFilterPast),
],
),
],
),
),
Container(
height: 600,
padding: const EdgeInsets.symmetric(vertical: 8),
child: events.isEmpty
? Center(
child: Text(AppLocalizations.of(context)!.noEvents,
style: TextStyle(fontStyle: FontStyle.italic)))
: Builder(builder: (context) {
final groups = _displayGroups;
return ListView.builder(
itemCount: groups.length,
itemBuilder: (context, index) {
final group = groups[index];
final primary = group.firstWhere(
(e) => e.label?.any((t) => t.language == 'FR') ??
false,
orElse: () => group.first);
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: kPrimaryColor.withOpacity(0.1),
child: const Icon(Icons.event,
color: kPrimaryColor),
),
title: HtmlWidget(
(primary.label != null &&
primary.label!.isNotEmpty)
? (primary.label!.firstWhere(
(t) => t.language == 'FR',
orElse: () => primary.label![0]))
.value ??
"${AppLocalizations.of(context)!.agendaEventFallback} $index"
: "${AppLocalizations.of(context)!.agendaEventFallback} $index",
textStyle:
const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text([
_formatEventDate(primary),
primary.address?.address ??
AppLocalizations.of(context)!.noAddress,
].where((s) => s.isNotEmpty).join('')),
),
trailing: group.length > 1
? _buildLanguageChips(group)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit,
color: kPrimaryColor),
onPressed: () =>
_editEvent(context, primary),
),
IconButton(
icon: const Icon(Icons.delete,
color: kError),
onPressed: () =>
_deleteEvent(context, primary),
),
],
),
),
);
},
);
}),
),
],
],
);
}
void _editEvent(BuildContext context, EventAgendaDTO event) {
showNewOrUpdateEventAgenda(
context,
event,
agendaDTO.id ?? "",
(updatedEvent) async {
try {
final result =
await _api(context).sectionAgendaUpdateEventAgenda(updatedEvent);
if (result != null && mounted) {
setState(() =>
events[events.indexWhere((e) => e.id == event.id)] = result);
showNotification(kSuccess, kWhite,
AppLocalizations.of(context)!.agendaEventUpdatedSuccess, context, null);
}
} catch (e) {
showNotification(kError, kWhite,
AppLocalizations.of(context)!.agendaEventUpdateError, context, null);
rethrow;
}
},
);
}
void _deleteEvent(BuildContext context, EventAgendaDTO event) {
showConfirmationDialog(
AppLocalizations.of(context)!.agendaEventDeleteConfirm,
() {},
() async {
try {
if (event.id != null) {
await _api(context).sectionAgendaDeleteEventAgenda(event.id!);
}
if (mounted) {
setState(() => events.removeWhere((e) => e.id == event.id));
showNotification(kSuccess, kWhite,
AppLocalizations.of(context)!.agendaEventDeletedSuccess, context, null);
}
} catch (e) {
showNotification(kError, kWhite,
AppLocalizations.of(context)!.agendaEventDeleteError, context, null);
}
},
context,
);
}
Widget _buildLanguageChips(List<EventAgendaDTO> group) {
return Wrap(
spacing: 4,
children: group.map((event) {
final language = (event.label != null && event.label!.isNotEmpty)
? event.label![0].language ?? '?'
: '?';
return InputChip(
label: Text(language.toUpperCase(),
style: const TextStyle(fontSize: 12)),
onPressed: () => _editEvent(context, event),
onDeleted: () => _deleteEvent(context, event),
deleteIconColor: kError,
);
}).toList(),
);
}
Widget _buildDateFilterChip(_AgendaDateFilter filter, String label) {
return ChoiceChip(
label: Text(label),
selected: _dateFilter == filter,
selectedColor: kPrimaryColor.withOpacity(0.2),
onSelected: (_) => setState(() => _dateFilter = filter),
);
}
String _formatEventDate(EventAgendaDTO event) {
if (event.dateFrom == null) return '';
final dateFormat = DateFormat('dd/MM/yyyy');
final from = dateFormat.format(event.dateFrom!.toLocal());
if (event.dateTo == null || event.dateTo!.isAtSameMomentAs(event.dateFrom!)) {
return from;
}
return "$from - ${dateFormat.format(event.dateTo!.toLocal())}";
}
Widget _buildAgendaHeader(Size size, String mapProviderIn) {
return Container(
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
border: Border.all(color: kPrimaryColor.withOpacity(0.2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
CheckInputContainer(
label: AppLocalizations.of(context)!.onlineLabel,
isChecked: agendaDTO.isOnlineAgenda ?? true,
onChanged: (value) {
setState(() {
agendaDTO.isOnlineAgenda = value;
widget.onChanged(agendaDTO);
});
},
),
CheckInputContainer(
label: AppLocalizations.of(context)!.mapViewLabel,
isChecked: agendaDTO.agendaMapProvider != null,
onChanged: (value) {
setState(() {
if (value) {
agendaDTO.agendaMapProvider = MapProvider.Google;
} else {
agendaDTO.agendaMapProvider = null;
}
widget.onChanged(agendaDTO);
});
},
),
if (agendaDTO.agendaMapProvider != null)
SingleSelectContainer(
label: AppLocalizations.of(context)!.mapServiceLabel,
color: Colors.black,
initialValue: mapProviderIn,
inputValues: const ["Google", "MapBox"],
onChanged: (String value) {
setState(() {
switch (value) {
case "Google":
agendaDTO.agendaMapProvider = MapProvider.Google;
break;
case "MapBox":
agendaDTO.agendaMapProvider = MapProvider.MapBox;
break;
}
widget.onChanged(agendaDTO);
});
},
),
if (agendaDTO.agendaMapProvider != null)
// Voir map_config.dart : réglage mobile/tablette, le web garde Leaflet.
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 8),
child: Text(
AppLocalizations.of(context)!.mapProviderMobileOnlyNote,
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
),
if (agendaDTO.isOnlineAgenda == true)
MultiStringInputContainer(
label: AppLocalizations.of(context)!.jsonFilesLabel,
resourceTypes: const [ResourceType.Json, ResourceType.JsonUrl],
modalLabel: AppLocalizations.of(context)!.jsonLabel,
color: kPrimaryColor,
initialValue: agendaDTO.resourceIds ?? [],
isTitle: false,
onGetResult: (value) {
setState(() {
agendaDTO.resourceIds = value;
widget.onChanged(agendaDTO);
});
},
maxLines: 1,
),
],
),
);
}
}