From fa1d6a4295f2b4628e79635bfa264572d659c341 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Sat, 20 Dec 2025 15:56:57 +0100 Subject: [PATCH] feat: export ICS --- em2rp/docs/EXPORT_CALENDAR.md | 70 +++++ em2rp/docs/example_event.ics | 18 ++ .../controllers/event_form_controller.dart | 16 +- em2rp/lib/services/ics_export_service.dart | 267 ++++++++++++++++++ em2rp/lib/views/calendar_page.dart | 4 +- em2rp/lib/views/event_add_page.dart | 9 +- .../event_details_header.dart | 66 +++++ .../event_form/event_basic_info_section.dart | 24 +- 8 files changed, 466 insertions(+), 8 deletions(-) create mode 100644 em2rp/docs/EXPORT_CALENDAR.md create mode 100644 em2rp/docs/example_event.ics create mode 100644 em2rp/lib/services/ics_export_service.dart diff --git a/em2rp/docs/EXPORT_CALENDAR.md b/em2rp/docs/EXPORT_CALENDAR.md new file mode 100644 index 0000000..378343d --- /dev/null +++ b/em2rp/docs/EXPORT_CALENDAR.md @@ -0,0 +1,70 @@ +# Export vers Google Calendar + +## Fonctionnalité + +L'application permet d'exporter un événement au format ICS (iCalendar), compatible avec Google Calendar, Apple Calendar, Outlook et la plupart des applications de calendrier. + +## Utilisation + +1. Ouvrir les détails d'un événement +2. Cliquer sur l'icône de calendrier 📅 dans l'en-tête +3. Le fichier `.ics` sera automatiquement téléchargé +4. Ouvrir le fichier pour l'importer dans votre application de calendrier + +## Informations exportées + +Le fichier ICS contient : + +### Informations principales +- **Titre** : Nom de l'événement +- **Date de début** : Date et heure de début +- **Date de fin** : Date et heure de fin +- **Lieu** : Adresse de l'événement +- **Statut** : Confirmé / Annulé / En attente + +### Description détaillée +- Type d'événement +- Description complète +- Jauge (nombre de personnes) +- Email de contact +- Téléphone de contact +- Temps d'installation et démontage +- Liste de la main d'œuvre +- Options sélectionnées (avec quantités) +- Prix de base + +## Format du fichier + +Le fichier généré suit le standard **RFC 5545** (iCalendar) et est nommé selon le format : +``` +event_[nom_evenement]_[date].ics +``` + +Exemple : `event_Concert_Mairie_20251225.ics` + +## Compatibilité + +✅ Google Calendar +✅ Apple Calendar (macOS, iOS) +✅ Microsoft Outlook +✅ Thunderbird +✅ Autres applications supportant le format ICS + +## Import dans Google Calendar + +1. Télécharger le fichier `.ics` +2. Ouvrir Google Calendar +3. Cliquer sur l'icône ⚙️ (Paramètres) +4. Sélectionner "Importation et exportation" +5. Cliquer sur "Sélectionner un fichier sur votre ordinateur" +6. Choisir le fichier `.ics` téléchargé +7. Sélectionner le calendrier de destination +8. Cliquer sur "Importer" + +## Notes techniques + +- Les dates sont converties en UTC pour assurer la compatibilité internationale +- Les caractères spéciaux sont correctement échappés selon le standard ICS +- Un UID unique est généré pour chaque événement (`em2rp-[eventId]@em2rp.app`) +- Le fichier est encodé en UTF-8 + diff --git a/em2rp/docs/example_event.ics b/em2rp/docs/example_event.ics new file mode 100644 index 0000000..1c01ea4 --- /dev/null +++ b/em2rp/docs/example_event.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//EM2RP//Event Manager//FR +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:em2rp-example123@em2rp.app +DTSTAMP:20251220T120000Z +DTSTART:20251225T190000Z +DTEND:20251225T230000Z +SUMMARY:Concert de Noël +DESCRIPTION:TYPE: Concert\n\nDESCRIPTION:\nConcert de Noël avec orchestre symphonique et chorale.\n\nJAUGE: 500 personnes\nEMAIL DE CONTACT: contact@example.com\nTÉLÉPHONE DE CONTACT: 06 12 34 56 78\n\nADRESSE: Salle des fêtes\, Place de la Mairie\, 75001 Paris\n\nINSTALLATION: 4h\nDÉMONTAGE: 2h\n\nMAIN D'ŒUVRE:\n - Jean Dupont\n - Marie Martin\n - Pierre Durand\n\nOPTIONS:\n - Système son professionnel\n - Éclairage scénique (x2)\n\nPRIX DE BASE: 2500.00€\n\n---\nGéré par EM2RP Event Manager +LOCATION:Salle des fêtes\, Place de la Mairie\, 75001 Paris +STATUS:CONFIRMED +CATEGORIES:Concert +END:VEVENT +END:VCALENDAR + diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 39b9cbe..6e5ee2a 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -82,7 +82,7 @@ class EventFormController extends ChangeNotifier { } } - Future initialize([EventModel? existingEvent]) async { + Future initialize({EventModel? existingEvent, DateTime? selectedDate}) async { await Future.wait([ _fetchUsers(), _fetchEventTypes(), @@ -92,6 +92,20 @@ class EventFormController extends ChangeNotifier { _populateFromEvent(existingEvent); } else { _selectedStatus = EventStatus.waitingForApproval; + + // Préremplir les dates si une date est sélectionnée dans le calendrier + if (selectedDate != null) { + // Date de début : selectedDate à 20h00 + _startDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + 20, + 0, + ); + // Date de fin : selectedDate + 4 heures + _endDateTime = _startDateTime!.add(const Duration(hours: 4)); + } } notifyListeners(); } diff --git a/em2rp/lib/services/ics_export_service.dart b/em2rp/lib/services/ics_export_service.dart new file mode 100644 index 0000000..f1ca6ed --- /dev/null +++ b/em2rp/lib/services/ics_export_service.dart @@ -0,0 +1,267 @@ +import 'package:em2rp/models/event_model.dart'; +import 'package:intl/intl.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; + +class IcsExportService { + /// Génère un fichier ICS à partir d'un événement + static Future generateIcsContent(EventModel event) async { + final now = DateTime.now().toUtc(); + final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z'; + + // Récupérer les informations supplémentaires + final eventTypeName = await _getEventTypeName(event.eventTypeId); + final workforce = await _getWorkforceDetails(event.workforce); + final optionsWithNames = await _getOptionsDetails(event.options); + + // Formater les dates au format ICS (UTC) + final startDate = _formatDateForIcs(event.startDateTime); + final endDate = _formatDateForIcs(event.endDateTime); + + // Construire la description détaillée + final description = _buildDescription(event, eventTypeName, workforce, optionsWithNames); + + // Générer un UID unique basé sur l'ID de l'événement + final uid = 'em2rp-${event.id}@em2rp.app'; + + // Construire le contenu ICS + final icsContent = '''BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//EM2RP//Event Manager//FR +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:$uid +DTSTAMP:$timestamp +DTSTART:$startDate +DTEND:$endDate +SUMMARY:${_escapeIcsText(event.name)} +DESCRIPTION:${_escapeIcsText(description)} +LOCATION:${_escapeIcsText(event.address)} +STATUS:${_getEventStatus(event.status)} +CATEGORIES:${_escapeIcsText(eventTypeName)} +END:VEVENT +END:VCALENDAR'''; + + return icsContent; + } + + /// Récupère le nom du type d'événement + static Future _getEventTypeName(String eventTypeId) async { + if (eventTypeId.isEmpty) return 'Non spécifié'; + + try { + final doc = await FirebaseFirestore.instance + .collection('eventTypes') + .doc(eventTypeId) + .get(); + + if (doc.exists) { + return doc.data()?['name'] as String? ?? eventTypeId; + } + } catch (e) { + print('Erreur lors de la récupération du type d\'événement: $e'); + } + + return eventTypeId; + } + + /// Récupère les détails de la main d'œuvre + static Future> _getWorkforceDetails(List workforce) async { + final List workforceNames = []; + + for (final ref in workforce) { + try { + final doc = await ref.get(); + if (doc.exists) { + final data = doc.data() as Map?; + if (data != null) { + final firstName = data['firstName'] ?? ''; + final lastName = data['lastName'] ?? ''; + if (firstName.isNotEmpty || lastName.isNotEmpty) { + workforceNames.add('$firstName $lastName'.trim()); + } + } + } + } catch (e) { + print('Erreur lors de la récupération des détails utilisateur: $e'); + } + } + + return workforceNames; + } + + /// Récupère les détails des options + static Future>> _getOptionsDetails(List> options) async { + final List> optionsWithNames = []; + + for (final option in options) { + try { + final optionId = option['id'] ?? option['optionId']; + if (optionId == null || optionId.toString().isEmpty) { + // Si pas d'ID, garder le nom tel quel + optionsWithNames.add({ + 'name': option['name'] ?? 'Option inconnue', + 'quantity': option['quantity'], + }); + continue; + } + + // Récupérer le nom depuis Firestore + final doc = await FirebaseFirestore.instance + .collection('options') + .doc(optionId.toString()) + .get(); + + if (doc.exists) { + final data = doc.data(); + optionsWithNames.add({ + 'name': data?['name'] ?? option['name'] ?? 'Option inconnue', + 'quantity': option['quantity'], + }); + } else { + // Document n'existe pas, garder le nom de l'option + optionsWithNames.add({ + 'name': option['name'] ?? 'Option inconnue', + 'quantity': option['quantity'], + }); + } + } catch (e) { + print('Erreur lors de la récupération des détails option: $e'); + optionsWithNames.add({ + 'name': option['name'] ?? 'Option inconnue', + 'quantity': option['quantity'], + }); + } + } + + return optionsWithNames; + } + + /// Construit la description détaillée de l'événement + static String _buildDescription( + EventModel event, + String eventTypeName, + List workforce, + List> optionsWithNames, + ) { + final buffer = StringBuffer(); + + // Type d'événement + buffer.writeln('TYPE: $eventTypeName'); + buffer.writeln(''); + + // Description + if (event.description.isNotEmpty) { + buffer.writeln('DESCRIPTION:'); + buffer.writeln(event.description); + buffer.writeln(''); + } + + // Jauge + if (event.jauge != null) { + buffer.writeln('JAUGE: ${event.jauge} personnes'); + } + + // Contact email + if (event.contactEmail != null && event.contactEmail!.isNotEmpty) { + buffer.writeln('EMAIL DE CONTACT: ${event.contactEmail}'); + } + + // Contact téléphone + if (event.contactPhone != null && event.contactPhone!.isNotEmpty) { + buffer.writeln('TÉLÉPHONE DE CONTACT: ${event.contactPhone}'); + } + + // Adresse + if (event.address.isNotEmpty) { + buffer.writeln(''); + buffer.writeln('ADRESSE: ${event.address}'); + } + + // Temps d'installation et démontage + if (event.installationTime > 0 || event.disassemblyTime > 0) { + buffer.writeln(''); + if (event.installationTime > 0) { + buffer.writeln('INSTALLATION: ${event.installationTime}h'); + } + if (event.disassemblyTime > 0) { + buffer.writeln('DÉMONTAGE: ${event.disassemblyTime}h'); + } + } + + // Main d'œuvre + if (workforce.isNotEmpty) { + buffer.writeln(''); + buffer.writeln('MAIN D\'ŒUVRE:'); + for (final name in workforce) { + buffer.writeln(' - $name'); + } + } + + // Options + if (optionsWithNames.isNotEmpty) { + buffer.writeln(''); + buffer.writeln('OPTIONS:'); + for (final option in optionsWithNames) { + final optionName = option['name'] ?? 'Option inconnue'; + final quantity = option['quantity']; + if (quantity != null && quantity > 1) { + buffer.writeln(' - $optionName (x$quantity)'); + } else { + buffer.writeln(' - $optionName'); + } + } + } + + // Prix + if (event.basePrice > 0) { + buffer.writeln(''); + buffer.writeln('PRIX DE BASE: ${event.basePrice.toStringAsFixed(2)}€'); + } + + // Lien vers l'application + buffer.writeln(''); + buffer.writeln('---'); + buffer.writeln('Géré par EM2RP Event Manager'); + + return buffer.toString(); + } + + /// Formate une date au format ICS (yyyyMMddTHHmmssZ) + static String _formatDateForIcs(DateTime dateTime) { + final utcDate = dateTime.toUtc(); + return DateFormat('yyyyMMddTHHmmss').format(utcDate) + 'Z'; + } + + /// Échappe les caractères spéciaux pour le format ICS + static String _escapeIcsText(String text) { + return text + .replaceAll('\\', '\\\\') + .replaceAll(',', '\\,') + .replaceAll(';', '\\;') + .replaceAll('\n', '\\n') + .replaceAll('\r', ''); + } + + /// Convertit le statut de l'événement en statut ICS + static String _getEventStatus(EventStatus status) { + switch (status) { + case EventStatus.confirmed: + return 'CONFIRMED'; + case EventStatus.canceled: + return 'CANCELLED'; + case EventStatus.waitingForApproval: + return 'TENTATIVE'; + } + } + + /// Génère le nom du fichier ICS + static String generateFileName(EventModel event) { + final safeName = event.name + .replaceAll(RegExp(r'[^\w\s-]'), '') + .replaceAll(RegExp(r'\s+'), '_'); + final dateStr = DateFormat('yyyyMMdd').format(event.startDateTime); + return 'event_${safeName}_$dateStr.ics'; + } +} + diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index 0500d26..3fefaa3 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -130,7 +130,9 @@ class _CalendarPageState extends State { onPressed: () { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => const EventAddEditPage(), + builder: (context) => EventAddEditPage( + selectedDate: _selectedDay ?? DateTime.now(), + ), ), ); }, diff --git a/em2rp/lib/views/event_add_page.dart b/em2rp/lib/views/event_add_page.dart index 87acc1c..4344e6c 100644 --- a/em2rp/lib/views/event_add_page.dart +++ b/em2rp/lib/views/event_add_page.dart @@ -11,7 +11,9 @@ import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart'; class EventAddEditPage extends StatefulWidget { final EventModel? event; - const EventAddEditPage({super.key, this.event}); + final DateTime? selectedDate; + + const EventAddEditPage({super.key, this.event, this.selectedDate}); @override State createState() => _EventAddEditPageState(); @@ -27,7 +29,10 @@ class _EventAddEditPageState extends State { void initState() { super.initState(); _controller = EventFormController(); - _controller.initialize(widget.event); + _controller.initialize( + existingEvent: widget.event, + selectedDate: widget.selectedDate, + ); } @override diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart index e821ecd..6ffccf3 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart @@ -5,6 +5,9 @@ import 'package:em2rp/utils/colors.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/views/event_add_page.dart'; +import 'package:em2rp/services/ics_export_service.dart'; +import 'dart:html' as html; +import 'dart:convert'; class EventDetailsHeader extends StatefulWidget { final EventModel event; @@ -93,6 +96,12 @@ class _EventDetailsHeaderState extends State { ), const SizedBox(width: 12), _buildStatusIcon(widget.event.status), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.calendar_today, color: AppColors.rouge), + tooltip: 'Exporter vers Google Calendar', + onPressed: _exportToCalendar, + ), if (Provider.of(context, listen: false) .hasPermission('edit_event')) ...[ const SizedBox(width: 8), @@ -112,6 +121,63 @@ class _EventDetailsHeaderState extends State { ); } + Future _exportToCalendar() async { + try { + // Afficher un indicateur de chargement + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 16), + Text('Génération du fichier ICS...'), + ], + ), + duration: Duration(seconds: 2), + ), + ); + + // Générer le contenu ICS + final icsContent = await IcsExportService.generateIcsContent(widget.event); + final fileName = IcsExportService.generateFileName(widget.event); + + // Créer un blob et télécharger le fichier + final bytes = utf8.encode(icsContent); + final blob = html.Blob([bytes], 'text/calendar'); + final url = html.Url.createObjectUrlFromBlob(blob); + html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Événement exporté : $fileName'), + backgroundColor: Colors.green, + action: SnackBarAction( + label: 'OK', + textColor: Colors.white, + onPressed: () {}, + ), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors de l\'export : $e'), + backgroundColor: Colors.red, + ), + ); + } + } + Widget _buildStatusIcon(EventStatus status) { Color color; IconData icon; diff --git a/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart b/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart index 72a7adc..2ae7c15 100644 --- a/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart @@ -181,16 +181,24 @@ class EventBasicInfoSection extends StatelessWidget { } Future _selectStartDateTime(BuildContext context) async { + // Utiliser la date actuelle de l'événement ou aujourd'hui + final initialDate = startDateTime ?? DateTime.now(); + final picked = await showDatePicker( context: context, - initialDate: DateTime.now(), + initialDate: initialDate, firstDate: DateTime(2020), lastDate: DateTime(2099), ); if (picked != null) { + // Utiliser l'heure actuelle de l'événement ou l'heure actuelle + final initialTime = startDateTime != null + ? TimeOfDay(hour: startDateTime!.hour, minute: startDateTime!.minute) + : TimeOfDay.now(); + final time = await showTimePicker( context: context, - initialTime: TimeOfDay.now(), + initialTime: initialTime, ); if (time != null) { final newDateTime = DateTime( @@ -206,16 +214,24 @@ class EventBasicInfoSection extends StatelessWidget { } Future _selectEndDateTime(BuildContext context) async { + // Utiliser la date actuelle de fin ou date de début + 1h + final initialDate = endDateTime ?? startDateTime!.add(const Duration(hours: 1)); + final picked = await showDatePicker( context: context, - initialDate: startDateTime!.add(const Duration(hours: 1)), + initialDate: initialDate, firstDate: startDateTime!, lastDate: DateTime(2099), ); if (picked != null) { + // Utiliser l'heure actuelle de l'événement ou l'heure actuelle + final initialTime = endDateTime != null + ? TimeOfDay(hour: endDateTime!.hour, minute: endDateTime!.minute) + : TimeOfDay.now(); + final time = await showTimePicker( context: context, - initialTime: TimeOfDay.now(), + initialTime: initialTime, ); if (time != null) { final newDateTime = DateTime(