feat: export ICS

This commit is contained in:
ElPoyo
2025-12-20 15:56:57 +01:00
parent df9e24d3b3
commit fa1d6a4295
8 changed files with 466 additions and 8 deletions

View File

@@ -82,7 +82,7 @@ class EventFormController extends ChangeNotifier {
}
}
Future<void> initialize([EventModel? existingEvent]) async {
Future<void> 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();
}

View File

@@ -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<String> 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<String> _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<List<String>> _getWorkforceDetails(List<DocumentReference> workforce) async {
final List<String> workforceNames = [];
for (final ref in workforce) {
try {
final doc = await ref.get();
if (doc.exists) {
final data = doc.data() as Map<String, dynamic>?;
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<List<Map<String, dynamic>>> _getOptionsDetails(List<Map<String, dynamic>> options) async {
final List<Map<String, dynamic>> 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<String> workforce,
List<Map<String, dynamic>> 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';
}
}

View File

@@ -130,7 +130,9 @@ class _CalendarPageState extends State<CalendarPage> {
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const EventAddEditPage(),
builder: (context) => EventAddEditPage(
selectedDate: _selectedDay ?? DateTime.now(),
),
),
);
},

View File

@@ -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<EventAddEditPage> createState() => _EventAddEditPageState();
@@ -27,7 +29,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
void initState() {
super.initState();
_controller = EventFormController();
_controller.initialize(widget.event);
_controller.initialize(
existingEvent: widget.event,
selectedDate: widget.selectedDate,
);
}
@override

View File

@@ -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<EventDetailsHeader> {
),
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<LocalUserProvider>(context, listen: false)
.hasPermission('edit_event')) ...[
const SizedBox(width: 8),
@@ -112,6 +121,63 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
);
}
Future<void> _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;

View File

@@ -181,16 +181,24 @@ class EventBasicInfoSection extends StatelessWidget {
}
Future<void> _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<void> _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(