Files
EM2_ERP/em2rp/lib/services/ics_export_service.dart
ElPoyo a182f1b922 refactor: Passage à la pagination côté serveur pour les équipements et containers
Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.

**Changements Backend (Cloud Functions) :**

-   **Nouveaux Endpoints Paginés :**
    -   `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
    -   Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
    -   La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
-   **Optimisation de `getContainersPaginated` :**
    -   Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
-   **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
-   **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.

**Changements Frontend (Flutter) :**

-   **`EquipmentProvider` et `ContainerProvider` :**
    -   La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
    -   Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
    -   Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
    -   Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
-   **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
    -   Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
    -   Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
    -   Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
-   **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
    -   Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
    -   Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
    -   La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
-   **Optimisations diverses :**
    -   Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
    -   Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.

**Correction mineure :**

-   **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
2026-01-18 12:40:23 +01:00

277 lines
8.8 KiB
Dart

import 'package:em2rp/config/app_version.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class IcsExportService {
/// Génère un fichier ICS à partir d'un événement
///
/// [eventTypeName] : Nom du type d'événement (optionnel, sera résolu si non fourni)
/// [userNames] : Map des IDs utilisateurs vers leurs noms complets (optionnel)
/// [optionNames] : Map des IDs options vers leurs noms (optionnel)
static Future<String> generateIcsContent(
EventModel event, {
String? eventTypeName,
Map<String, String>? userNames,
Map<String, String>? optionNames,
}) async {
final now = DateTime.now().toUtc();
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
// Récupérer les informations supplémentaires
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
final workforce = await _getWorkforceDetails(event.workforce, userNames: userNames);
final optionsWithNames = await _getOptionsDetails(event.options, optionNames: optionNames);
// 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, resolvedEventTypeName, 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(resolvedEventTypeName)}
END:VEVENT
END:VCALENDAR''';
return icsContent;
}
/// Récupère le nom du type d'événement depuis EventModel (déjà chargé)
/// Note: Les eventTypes sont maintenant chargés via Cloud Function dans l'EventModel
static Future<String> _getEventTypeName(String eventTypeId) async {
if (eventTypeId.isEmpty) return 'Non spécifié';
// Les eventTypes sont publics et déjà chargés dans l'app via Cloud Function
// On retourne simplement l'ID, le nom sera résolu par l'app
return eventTypeId;
}
/// Récupère les détails de la main d'œuvre
/// Si userNames est fourni, utilise les noms déjà résolus pour de meilleures performances
static Future<List<String>> _getWorkforceDetails(
List<dynamic> workforce, {
Map<String, String>? userNames,
}) async {
final List<String> workforceNames = [];
for (final ref in workforce) {
try {
// Si c'est déjà une Map avec les données, l'utiliser directement
if (ref is Map<String, dynamic>) {
final firstName = ref['firstName'] ?? '';
final lastName = ref['lastName'] ?? '';
if (firstName.isNotEmpty || lastName.isNotEmpty) {
workforceNames.add('$firstName $lastName'.trim());
}
continue;
}
// Si c'est un String (UID) et qu'on a les noms résolus, les utiliser
if (ref is String) {
if (userNames != null && userNames.containsKey(ref)) {
workforceNames.add(userNames[ref]!);
} else {
workforceNames.add('Utilisateur $ref');
}
continue;
}
// Si c'est une DocumentReference
if (ref is DocumentReference) {
final userId = ref.id;
if (userNames != null && userNames.containsKey(userId)) {
workforceNames.add(userNames[userId]!);
} else {
workforceNames.add('Utilisateur $userId');
}
}
} catch (e) {
print('Erreur lors du traitement des détails utilisateur: $e');
}
}
return workforceNames;
}
/// Récupère les détails des options
/// Si optionNames est fourni, utilise les noms déjà résolus
static Future<List<Map<String, dynamic>>> _getOptionsDetails(
List<Map<String, dynamic>> options, {
Map<String, String>? optionNames,
}) async {
final List<Map<String, dynamic>> optionsWithNames = [];
for (final option in options) {
try {
String optionName = option['name'] ?? 'Option inconnue';
// Si on a l'ID de l'option et les noms résolus, utiliser le nom résolu
final optionId = option['id'] ?? option['optionId'];
if (optionId != null && optionNames != null && optionNames.containsKey(optionId)) {
optionName = optionNames[optionId]!;
} else if (optionName == 'Option inconnue' && optionId != null) {
optionName = 'Option $optionId';
}
optionsWithNames.add({
'name': optionName,
'quantity': option['quantity'],
'price': option['price'],
});
} catch (e) {
print('Erreur lors du traitement des options: $e');
}
}
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énéré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr');
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';
}
}