Compare commits

..

45 Commits

Author SHA1 Message Date
ElPoyo
bc93f3fa9a feat: Mise à jour à la version 1.1.14 et refonte du support Audio/TTS pour le Web
- Mise à jour de la version de l'application à `1.1.14` dans `app_version.dart` et `version.json`.
- Migration de `AudioFeedbackService` vers l'API Web native (`dart:js_interop`, `package:web`) pour corriger les problèmes d'autoplay et supprimer la dépendance `audioplayers`.
- Réécriture de `TextToSpeechService` utilisant `window.speechSynthesis` en remplacement de `flutter_tts` pour une meilleure compatibilité Web (notamment sous Linux).
- Suppression des dépendances obsolètes `audioplayers` et `flutter_tts` du `pubspec.yaml`.
- Ajout d'une gestion de file d'attente (`_scanQueue`) dans `EventPreparationPage` pour traiter les scans de codes-barres de manière séquentielle.
- Intégration d'un bouton de diagnostic (`AudioDiagnosticButton`) pour tester manuellement l'audio et la synthèse vocale.
- Ajout d'un script de test JavaScript `test_audio_tts.js` pour faciliter le débogage dans la console du navigateur.
- Ajout de directives de style et d'architecture Dart/Flutter dans `.github/agents/`.
2026-03-08 19:51:13 +01:00
ElPoyo
6d320bedc9 ajout des sons 2026-02-24 14:16:57 +01:00
ElPoyo
cc7abba373 feat: Mise à jour de la version de l'application à 1.1.7 et ajout de la gestion des sons pour le web 2026-02-24 14:15:25 +01:00
ElPoyo
890449d5e3 feat: Ajout de la gestion des maintenances et intégration de la synthèse vocale 2026-02-24 13:39:44 +01:00
ElPoyo
506225ac62 refactor: Optimisation de la gestion des événements dans le cache et suppression des appels API redondants 2026-02-18 15:22:14 +01:00
ElPoyo
bc6d7d4542 feat: Mise à jour des dépendances et réorganisation du fichier pubspec.yaml 2026-02-18 13:37:53 +01:00
ElPoyo
5b9ca568f8 feat: Ajout de l'exportation des événements au format CSV avec filtres personnalisables 2026-02-18 13:25:14 +01:00
ElPoyo
7cbb48e679 perf: Implémentation du lazy loading pour le calendrier
Cette mise à jour refactorise en profondeur le chargement des événements sur la page Calendrier pour améliorer drastiquement les performances et la réactivité de l'application, en particulier pour les utilisateurs avec un grand nombre d'événements. Le système de chargement initial de tous les événements est remplacé par un mécanisme de lazy loading qui ne récupère que les données du mois affiché.

**Changements majeurs :**

-   **Lazy Loading Côté Client (`EventProvider`) :**
    -   Une nouvelle méthode `loadMonthEvents` a été introduite pour charger uniquement les événements d'un mois spécifique (`year`, `month`).
    -   Un cache par mois (`_eventsByMonth`) a été mis en place pour éviter les rechargements inutiles lors de la navigation entre des mois déjà consultés.
    -   Ajout d'une fonction `preloadAdjacentMonths` qui charge en arrière-plan et silencieusement les mois précédent et suivant, assurant une navigation fluide dans le calendrier.

-   **Nouveau Endpoint Backend (`getEventsByMonth`) :**
    -   Création d'un nouvel endpoint Cloud Function `getEventsByMonth` optimisé pour ne requêter que les événements dans une plage de dates (début et fin du mois).
    -   La fonction récupère les utilisateurs associés de manière optimisée en parallélisant les requêtes Firestore (Promise.all).
    -   La limite du nombre d'IDs par requête 'in' a été augmentée de 10 à 30 pour réduire le nombre d'appels à la base de données.

-   **Intégration au Calendrier (`CalendarPage`) :**
    -   La page charge désormais les événements pour le mois courant au démarrage via `_loadCurrentMonthEvents`.
    -   Lorsqu'un utilisateur change de mois (`onPageChanged`), la page déclenche le chargement des données pour le nouveau mois, avec un préchargement des mois adjacents pour anticiper la navigation.
    -   Le chargement initial de tous les événements (`_loadEventsAsync`) a été déprécié.

-   **Correction de la Séquence de Démarrage (`main.dart`) :**
    -   L'appel à `_autoLogin` est maintenant enveloppé dans `WidgetsBinding.instance.addPostFrameCallback`. Cela garantit que la navigation ne se produit qu'après le premier rendu de l'interface, évitant ainsi des erreurs potentielles de build/navigation concurrentes et fiabilisant le chargement initial des données utilisateur.
2026-02-09 11:20:08 +01:00
ElPoyo
8cd4854924 refactor: Amélioration des performances et migration des Cloud Functions
Cette mise à jour majeure vise à améliorer significativement les performances de l'application, en particulier au démarrage, et à standardiser l'infrastructure backend. Les principaux changements incluent la migration de toutes les Cloud Functions vers une région européenne (`europe-west9`), l'optimisation du chargement des données, et l'introduction d'un moniteur de performance pour le débogage.

**Changements Backend (Cloud Functions) :**

-   **Migration de la Région :**
    -   Toutes les Cloud Functions ont été déplacées de `us-central1` à `europe-west9` (Paris) pour réduire la latence pour les utilisateurs européens. Cela concerne les appels depuis le frontend (ex: `api_config.dart`, `email_service.dart`) et les définitions des fonctions elles-mêmes (`index.js`, etc.).
-   **Standardisation des Fonctions :**
    -   La plupart des fonctions `onCall` (v1) ont été migrées vers le format `onRequest` (v2) avec une gestion d'authentification et de CORS unifiée, améliorant la robustesse et la cohérence.
    -   Les triggers Firestore (`onDocumentCreated`, `onDocumentUpdated`) et les tâches planifiées (`onSchedule`) ont été mis à jour pour spécifier explicitement la région `europe-west9`.
-   **Mise à jour des Index Firestore :**
    -   Les index `firestore.indexes.json` ont été mis à jour pour supporter les nouvelles requêtes de l'application et optimiser les performances de filtrage.

**Améliorations des Performances Frontend :**

-   **Chargement Asynchrone et Mis en Cache :**
    -   Le chargement des données utilisateur (`LocalUserProvider`) et des événements (`EventProvider`) a été optimisé pour utiliser un cache local à court terme (5 minutes pour l'utilisateur, 30 secondes pour les événements).
    -   Les données ne sont rechargées que si le cache a expiré ou si un rechargement est forcé, évitant des appels réseau redondants et accélérant la navigation.
-   **Démarrage de l'Application Optimisé :**
    -   Le processus de connexion automatique (`main.dart`) a été revu. L'application navigue désormais immédiatement vers la page demandée sans attendre la fin du chargement des données utilisateur, qui s'effectue en arrière-plan.
    -   Un écran de chargement plus esthétique avec le logo de l'entreprise a été ajouté, remplaçant l'indicateur de chargement simple.
-   **Chargement de la Page Calendrier :**
    -   Le chargement et la sélection de l'événement par défaut sur la page `CalendarPage` sont maintenant entièrement asynchrones, rendant l'affichage de la page quasi instantané.

**Nouveaux Outils et Améliorations UX :**

-   **Moniteur de Performance :**
    -   Ajout d'un nouvel outil `PerformanceMonitor` (`lib/utils/performance_monitor.dart`) pour mesurer précisément le temps d'exécution des opérations critiques (appels API, parsing, etc.) en mode débogage. Il aide à identifier les goulots d'étranglement.
-   **Amélioration du Formulaire de Connexion :**
    -   Les champs "Email" et "Mot de passe" sur la page de connexion (`LoginPage`) supportent désormais l'autocomplétion du navigateur (`AutofillGroup`).
    -   Appuyer sur "Entrée" dans l'un des champs déclenche désormais la connexion, améliorant l'ergonomie.

**Mise à jour de la version :**

-   La version de l'application a été incrémentée à `1.0.9`.
2026-02-09 10:14:52 +01:00
ElPoyo
a7e5f91a21 feat: Scan et traitement intelligent des QR Codes en préparation d'événement
Cette mise à jour majeure introduit une fonctionnalité de scan et de saisie manuelle de codes QR directement depuis la page de préparation d'un événement. Ce système accélère et fiabilise le processus de validation des équipements et des containers pour chaque étape (préparation, chargement, etc.), tout en ajoutant des retours sonores, haptiques et visuels pour une expérience utilisateur améliorée.

**Fonctionnalités et améliorations principales :**

-   **Scan et saisie manuelle en préparation d'événement :**
    -   Ajout d'un champ de "Saisie manuelle" et d'un bouton "Scanner QR Code" sur la page de préparation (`EventPreparationPage`).
    -   Le scanner peut fonctionner en mode "multi-scan", permettant de valider plusieurs éléments à la suite sans fermer la caméra.
    -   Le système gère à la fois les équipements individuels et les containers (qui valident automatiquement tout leur contenu).

-   **Logique de traitement intelligente (`QRCodeProcessingService`) :**
    -   Un nouveau service centralise la logique de traitement des codes.
    -   Pour les équipements quantitatifs, chaque scan incrémente la quantité jusqu'à atteindre la cible requise pour l'étape en cours.
    -   Pour les équipements non quantitatifs, le premier scan valide l'élément.
    -   Les scans multiples d'un élément déjà validé ou dont la quantité est atteinte génèrent une erreur.

-   **Ajout dynamique d'équipements :**
    -   Si un code scanné n'est pas assigné à l'événement, une boîte de dialogue propose de rechercher l'équipement ou le container dans la base de données et de l'ajouter à l'événement en cours.

-   **Feedbacks utilisateur :**
    -   Création d'un `AudioFeedbackService` pour fournir des retours sonores (succès/erreur) et haptiques (vibration) lors de chaque scan.
    -   Des `Snackbars` claires (vertes pour succès, orange pour erreur) informent l'utilisateur du résultat de chaque action.

-   **Optimisation du chargement des données :**
    -   Nouvel endpoint backend `getEventWithDetails` qui charge un événement et toutes ses dépendances (équipements, containers et leurs enfants) en un seul appel, optimisant drastiquement les temps de chargement des pages de préparation et de modification d'événement.
    -   Le frontend (`EventPreparationPage`, `EventAssignedEquipmentSection`) utilise ce nouvel endpoint, éliminant les chargements multiples et fiabilisant l'affichage des données.

**Refactorisation et corrections :**

-   **Structure du code :**
    -   La logique de traitement des codes est extraite dans le `QRCodeProcessingService`.
    -   Création de widgets dédiés (`CodeNotFoundDialog`, `AddEquipmentToEventDialog`) pour gérer les nouveaux flux utilisateurs.
-   **Fiabilisation de l'état :**
    -   Mise à jour optimiste de l'UI lors du changement de statut d'un événement (`EventStatusButton`) pour une meilleure réactivité.
    -   Correction d'un bug dans la suppression d'un container d'un événement, qui pouvait retirer des équipements partagés avec d'autres containers.
    -   Correction d'un bug lors de l'ajout d'un container à un événement, qui n'ajoutait pas automatiquement ses équipements enfants.
-   **Optimisations des performances UI :**
    -   Amélioration de la fluidité du défilement infini sur la page de gestion des équipements grâce à `RepaintBoundary` et à une gestion optimisée du chargement.

**Déploiement et version :**

-   **Scripts de déploiement :** Ajout d'un script PowerShell (`deploy_hosting.ps1`) et amélioration du script Node.js pour automatiser et fiabiliser les déploiements sur Firebase Hosting.
-   **Configuration CORS :** Les en-têtes CORS sont désormais configurés pour `version.json`, assurant le bon fonctionnement du mécanisme de mise à jour de l'application.
-   **Version de l'application :** Incrémentée à `1.0.6`.
2026-01-20 14:33:37 +01:00
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
ElPoyo
b79791ff7a refactor: Ajout des sous-catégories et refonte de la gestion de l'appartenance
Cette mise à jour structurelle améliore la classification des équipements en introduisant la notion de sous-catégories et supprime la gestion directe de l'appartenance d'un équipement à une boîte (`parentBoxIds`). L'appartenance est désormais uniquement définie côté conteneur. Une nouvelle catégorie "Régie / Backline" est également ajoutée.

**Changements majeurs :**

-   **Suppression de `parentBoxIds` sur `EquipmentModel` :**
    -   Le champ `parentBoxIds` a été retiré du modèle de données `EquipmentModel` et de toutes les logiques associées (création, mise à jour, copie).
    -   La responsabilité de lier un équipement à un conteneur est désormais exclusivement gérée par le `ContainerModel` via sa liste `equipmentIds`.
    -   La logique de synchronisation complexe dans `EquipmentFormPage` qui mettait à jour les conteneurs lors de la modification d'un équipement a été entièrement supprimée, simplifiant considérablement le code.
    -   Le sélecteur de boîtes parentes (`ParentBoxesSelector`) a été retiré du formulaire d'équipement.

-   **Ajout des sous-catégories :**
    -   Un champ optionnel `subCategory` (String) a été ajouté au `EquipmentModel`.
    -   Le formulaire de création/modification d'équipement inclut désormais un nouveau champ "Sous-catégorie" avec autocomplétion.
    -   Ce champ est contextuel : il propose des suggestions basées sur les sous-catégories existantes pour la catégorie principale sélectionnée (ex: "Console", "Micro" pour la catégorie "Son").
    -   La sous-catégorie est maintenant affichée sur les fiches de détail des équipements et dans les listes de la page de gestion, améliorant la visibilité du classement.

**Nouvelle catégorie d'équipement :**

-   Une nouvelle catégorie `backline` ("Régie / Backline") a été ajoutée à `EquipmentCategory` avec une icône (`Icons.piano`) et une couleur associée.

**Refactorisation et nettoyage :**

-   Le `EquipmentProvider` et `EquipmentService` ont été mis à jour pour charger et filtrer les sous-catégories.
-   De nombreuses instanciations d'un `EquipmentModel` vide (`dummy`) à travers l'application ont été nettoyées pour retirer la référence à `parentBoxIds`.

-   **Version de l'application :**
    -   La version a été incrémentée à `1.0.4`.
2026-01-17 12:07:20 +01:00
ElPoyo
7e111ec041 refactor: Amélioration de la génération des étiquettes PDF
Cette mise à jour refactorise en profondeur la génération des étiquettes PDF (formats moyen et grand) pour correspondre précisément aux dimensions de planches d'étiquettes standards, remplaçant la mise en page approximative par un placement calculé au millimètre près. La version de l'application est également incrémentée à `1.0.4`.

**Changements principaux sur le `PDFService` :**

-   **Précision des formats d'étiquettes :**
    -   **Format Moyen :**
        -   Calibré pour des étiquettes de 49 x 26 mm, disposées en 4 colonnes et 10 lignes (40 par page).
        -   La mise en page est entièrement refaite : le QR code est à gauche et le texte (logo, ID, titre) est à droite, optimisant l'espace horizontal.
        -   Un calcul manuel des marges (`leftMargin`, `topMargin`) assure un alignement précis sur la page A4, avec une correction pour un centrage parfait.
    -   **Format Large :**
        -   Calibré pour des étiquettes de 105 x 57 mm, disposées en 2 colonnes et 5 lignes (10 par page).
        -   Utilise la moitié de la largeur d'une page A4 (`PdfPageFormat.a4.width / 2`) pour garantir un ajustement parfait des colonnes.
        -   La mise en page a été ajustée pour un meilleur centrage vertical du contenu dans l'étiquette.
        -   Suppression des bordures décoratives pour une impression directe sur planches prédécoupées.

-   **Améliorations générales :**
    -   Le logo de l'entreprise est désormais inclus également sur les étiquettes de format moyen.
    -   Les tailles de police et la troncature du texte ont été ajustées pour chaque format afin d'éviter les débordements et d'améliorer la lisibilité.
    -   Le code a été nettoyé, supprimant des commentaires et des paramètres de mise en page obsolètes (`pw.Center`, `spacing`, `runSpacing`).

-   **Mise à jour de la version :**
    -   La version de l'application est passée de `1.0.3` à `1.0.4`.
2026-01-16 19:23:57 +01:00
ElPoyo
4e7af9119a fix: Amélioration de l'expérience utilisateur lors de la génération de QR codes
Cette mise à jour améliore la génération de QR codes pour les équipements et les containers en ajoutant un retour visuel à l'utilisateur et une gestion des erreurs plus robuste.

**Changements :**
- **Ajout d'un indicateur de chargement :** Un `CircularProgressIndicator` est désormais affiché pendant que les données des équipements ou des containers sélectionnés sont récupérées, informant l'utilisateur qu'une opération est en cours.
- **Gestion des erreurs :** Un bloc `try...catch` a été ajouté autour de la logique de génération dans les pages de gestion des équipements (`EquipmentManagementPage`) et des containers (`ContainerManagementPage`).
- **Affichage des erreurs :** En cas d'échec, le chargement est stoppé et une `SnackBar` rouge apparaît pour notifier l'utilisateur de l'erreur, améliorant ainsi la robustesse de la fonctionnalité.
2026-01-16 01:20:59 +01:00
ElPoyo
1ea5cea6fc fix: Amélioration de l'expérience utilisateur lors de la génération de QR codes
Cette mise à jour améliore la génération de QR codes pour les équipements et les containers en ajoutant un retour visuel à l'utilisateur et une gestion des erreurs plus robuste.

**Changements :**
- **Ajout d'un indicateur de chargement :** Un `CircularProgressIndicator` est désormais affiché pendant que les données des équipements ou des containers sélectionnés sont récupérées, informant l'utilisateur qu'une opération est en cours.
- **Gestion des erreurs :** Un bloc `try...catch` a été ajouté autour de la logique de génération dans les pages de gestion des équipements (`EquipmentManagementPage`) et des containers (`ContainerManagementPage`).
- **Affichage des erreurs :** En cas d'échec, le chargement est stoppé et une `SnackBar` rouge apparaît pour notifier l'utilisateur de l'erreur, améliorant ainsi la robustesse de la fonctionnalité.
2026-01-16 00:42:16 +01:00
ElPoyo
06f394b728 feat: Ajout du scan de QR Code pour retrouver équipements et conteneurs
Cette mise à jour introduit une fonctionnalité de scan de QR codes directement depuis l'application, permettant aux utilisateurs de retrouver et d'accéder rapidement à la page de détail d'un équipement ou d'un conteneur.

**Features :**
- **Scan de QR Code :**
    - Un nouveau bouton "Scanner QR Code" est ajouté sur les pages de gestion des équipements et des conteneurs.
    - L'appui sur ce bouton ouvre une nouvelle boîte de dialogue (`QRCodeScannerDialog`) utilisant la caméra de l'appareil pour scanner un QR code.
    - Le scanner affiche un overlay visuel clair avec un cadre de détection et fournit un retour visuel (icône de validation) lorsqu'un code est détecté avec succès.
- **Recherche et Redirection Intelligente :**
    - Une fois un QR code scanné, l'application recherche l'ID correspondant d'abord dans les équipements, puis dans les conteneurs.
    - Si une correspondance est trouvée, l'utilisateur est automatiquement redirigé vers la page de détail de l'élément correspondant (`EquipmentDetailPage` ou `ContainerDetailPage`).
    - Un message informe l'utilisateur si aucun élément ne correspond à l'ID scanné.

**Changements Techniques :**
- **Dépendance :** Ajout de la bibliothèque `mobile_scanner` pour gérer la fonctionnalité de scan.
- **Nouveau Widget :** Création du widget `QRCodeScannerDialog`, un dialogue réutilisable et stylisé pour le scan, incluant un overlay personnalisé (`_ScannerOverlayPainter`).
- **Intégration UI :**
    - Le `ManagementSearchBar` accepte désormais une liste de `additionalActions` pour permettre l'ajout de boutons personnalisés comme celui du scanner.
    - Ajout du bouton de scan sur les écrans `EquipmentManagementPage` et `ContainerManagementPage`, à la fois en version bureau (icône) et mobile (bouton plein).
- **Logique de Recherche :** Implémentation de la fonction `_scanQRCode` dans les deux pages de gestion pour orchestrer l'ouverture du scanner, la recherche dans les `EquipmentProvider` et `ContainerProvider`, et la navigation.
2026-01-16 00:35:10 +01:00
ElPoyo
67b85d323c refactor: Amélioration et stabilisation du sélecteur d'équipement
Cette mise à jour refactorise en profondeur la boîte de dialogue de sélection d'équipement pour améliorer ses performances, sa stabilité et son ergonomie. Le chargement des données a été entièrement revu pour être plus robuste et les interactions utilisateur sont désormais plus fluides et intuitives.

**Améliorations de performance et de stabilité :**
- **Chargement de données asynchrone et robuste :** Remplacement de la logique de chargement dans `initState` par une nouvelle méthode `_initializeData`. Cette méthode force le chargement des équipements et des conteneurs via `ensureLoaded()` et attend activement leur complétion avant de poursuivre, garantissant ainsi que toutes les données sont disponibles avant l'affichage.
- **Mise en cache locale :** Les données des équipements et conteneurs sont mises en cache (`_cachedEquipment`, `_cachedContainers`) après le chargement initial. Toute la boîte de dialogue utilise désormais ce cache, éliminant les appels répétés aux `Stream` et réduisant les rebuilds inutiles.
- **Fiabilisation des `setState` :** Les modifications de la sélection (`_selectedItems`) sont maintenant systématiquement wrappées dans des appels `setState` pour assurer que l'interface graphique se met à jour de manière fiable, corrigeant des bugs où la sélection n'était pas reflétée visuellement.

**Nouvelles fonctionnalités et améliorations de l'UX :**
- **Sections dépliables :** Les listes "Boîtes" et "Tous les équipements" sont désormais dans des sections qui peuvent être repliées, permettant à l'utilisateur de se concentrer sur l'une ou l'autre et d'améliorer la lisibilité sur les petits écrans.
- **Option d'affichage des conflits :** Ajout d'une checkbox "Afficher les équipements déjà utilisés". Lorsqu'elle est décochée, tous les équipements et boîtes en conflit avec les dates de l'événement sont masqués, simplifiant la recherche de matériel disponible.
- **Meilleure gestion des filtres :** Le filtre par catégorie s'applique désormais aussi aux boîtes, n'affichant que celles qui contiennent au moins un équipement de la catégorie sélectionnée.
- **Notifications de sélection affinées :** Le compteur dans le pied de page (`_selectedItems.length`) est maintenant mis à jour en temps réel à chaque modification de la sélection grâce à un `ValueListenableBuilder`.

**Refactorisation mineure :**
- **`EquipmentProvider` :** Ajout d'un getter `allEquipment` pour fournir un accès à la liste complète des équipements, non filtrée, utilisée par la boîte de dialogue pour sa logique de cache et de filtrage.
2026-01-15 23:59:25 +01:00
ElPoyo
beaabceda4 feat: Intégration d'un système complet d'alertes et de notifications par email
Cette mise à jour majeure introduit un système de notifications robuste, centré sur la création d'alertes et l'envoi d'emails via des Cloud Functions. Elle inclut la gestion des préférences utilisateur, la création automatique d'alertes lors d'événements critiques et une nouvelle interface dédiée.

**Backend (Cloud Functions) :**
- **Nouveau service d'alerting (`createAlert`, `processEquipmentValidation`) :**
    - `createAlert` : Nouvelle fonction pour créer une alerte. Elle détermine les utilisateurs à notifier (admins, workforce d'événement) et gère la persistance dans Firestore.
    - `processEquipmentValidation` : Endpoint appelé lors de la validation du matériel (chargement/déchargement). Il analyse l'état de l'équipement (`LOST`, `MISSING`, `DAMAGED`) et crée automatiquement les alertes correspondantes.
- **Système d'envoi d'emails (`sendAlertEmail`, `sendDailyDigest`) :**
    - `sendAlertEmail` : Cloud Function `onCall` pour envoyer un email d'alerte individuel. Elle respecte les préférences de notification de l'utilisateur (canal email, type d'alerte).
    - `sendDailyDigest` : Tâche planifiée (tous les jours à 8h) qui envoie un email récapitulatif des alertes non lues des dernières 24 heures aux utilisateurs concernés.
    - Ajout de templates HTML (`base-template`, `alert-individual`, `alert-digest`) avec `Handlebars` pour des emails riches.
    - Configuration centralisée du SMTP via des variables d'environnement (`.env`).
- **Triggers Firestore (`onEventCreated`, `onEventUpdated`) :**
    - Des triggers créent désormais des alertes d'information lorsqu'un événement est créé ou que de nouveaux membres sont ajoutés à la workforce.
- **Règles Firestore :**
    - Mises à jour pour autoriser les utilisateurs authentifiés à créer et modifier leurs propres alertes (marquer comme lue, supprimer), tout en sécurisant les accès.

**Frontend (Flutter) :**
- **Nouvel `AlertService` et `EmailService` :**
    - `AlertService` : Centralise la logique de création, lecture et gestion des alertes côté client en appelant les nouvelles Cloud Functions.
    - `EmailService` : Service pour déclencher l'envoi d'emails via la fonction `sendAlertEmail`. Il contient la logique pour déterminer si une notification doit être immédiate (critique) ou différée (digest).
- **Nouvelle page de Notifications (`/alerts`) :**
    - Interface dédiée pour lister toutes les alertes de l'utilisateur, avec des onglets pour filtrer par catégorie (Toutes, Événement, Maintenance, Équipement).
    - Permet de marquer les alertes comme lues, de les supprimer et de tout marquer comme lu.
- **Intégration dans l'UI :**
    - Ajout d'un badge de notification dans la `CustomAppBar` affichant le nombre d'alertes non lues en temps réel.
    - Le `AutoLoginWrapper` gère désormais la redirection vers des routes profondes (ex: `/alerts`) depuis une URL.
- **Gestion des Préférences de Notification :**
    - Ajout d'un widget `NotificationPreferencesWidget` dans la page "Mon Compte".
    - Les utilisateurs peuvent désormais activer/désactiver les notifications par email, ainsi que filtrer par type d'alerte (événements, maintenance, etc.).
    - Le `UserModel` et `LocalUserProvider` ont été étendus pour gérer ce nouveau modèle de préférences.
- **Création d'alertes contextuelles :**
    - Le service `EventFormService` crée maintenant automatiquement une alerte lorsqu'un événement est créé ou modifié.
    - La page de préparation d'événement (`EventPreparationPage`) appelle `processEquipmentValidation` à la fin de chaque étape pour une détection automatisée des anomalies.

**Dépendances et CI/CD :**
- Ajout des dépendances `cloud_functions` et `timeago` (Flutter), et `nodemailer`, `handlebars`, `dotenv` (Node.js).
- Ajout de scripts de déploiement PowerShell (`deploy_functions.ps1`, `deploy_firestore_rules.ps1`) pour simplifier les mises en production.
2026-01-15 23:15:25 +01:00
ElPoyo
60d0e1c6c4 feat: Refonte de la checklist de préparation avec gestion des manquants et des containers
Cette mise à jour refond entièrement l'interface et la logique de la checklist de préparation d'événement. Elle introduit la notion d'équipements "manquants", une gestion visuelle des containers et de leur contenu, et une logique plus fine pour le suivi des quantités et des statuts à chaque étape.

**Features et Améliorations :**

-   **Gestion des Équipements Manquants :**
    -   Le modèle `EventEquipment` a été enrichi pour tracer si un équipement est manquant à chaque étape (`isMissingAtPreparation`, `isMissingAtLoading`, etc.).
    -   Un équipement non validé lors de la confirmation d'une étape est désormais marqué comme "manquant" pour les étapes suivantes.
    -   Les équipements qui étaient manquants à l'étape précédente sont maintenant visuellement mis en évidence avec une bordure et une icône orange, et une confirmation est demandée pour les valider.

-   **Refonte de la Checklist (UI/UX) :**
    -   **Groupement par Container :** La checklist affiche désormais les containers comme des en-têtes de groupe. Les équipements qu'ils contiennent sont listés en dessous, avec une indentation visuelle.
    -   **Validation Groupée :** Il est possible de valider tous les équipements d'un container en un seul clic sur l'en-tête du container.
    -   **Nouveau Widget `ContainerChecklistItem` :** Créé pour afficher un container et ses équipements enfants dans la checklist.
    -   **Refonte de `EquipmentChecklistItem` :** Le widget a été entièrement revu pour un design plus clair, une meilleure gestion des états (validé, manquant), et un affichage compact pour les équipements enfants.

-   **Logique de Suivi Améliorée :**
    -   **Quantités par Étape :** Le modèle `EventEquipment` et l'interface de préparation permettent maintenant de suivre les quantités réelles à chaque étape (`quantityAtPreparation`, `quantityAtLoading`, etc.), au lieu d'une seule quantité de retour.
    -   **Marquage Automatique des "Perdus" :** À l'étape finale du retour, un équipement qui était présent au départ mais qui est maintenant manquant sera automatiquement marqué avec le statut "lost" dans la base de données.
    -   **Flux de Validation :** Le processus de confirmation distingue désormais la validation de tous les équipements et la confirmation de l'état actuel (y compris les manquants).

-   **Export ICS Enrichi :**
    -   L'export ICS inclut désormais les noms résolus des utilisateurs (main d'œuvre) pour plus de clarté, en plus des détails de l'événement.
    -   Le contenu généré mentionne la version de l'application.
2026-01-15 12:05:37 +01:00
ElPoyo
b30ae0f10a feat: Sécurisation Firestore, gestion des prix HT/TTC et refactorisation majeure
Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.

### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.

### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
    - Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
    - Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.

### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.

### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
    - La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
    - Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
    - La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
2026-01-14 17:32:58 +01:00
ElPoyo
fb3f41df4d feat: refactor de la gestion des utilisateurs et migration de la logique métier vers les Cloud Functions
Cette mise à jour majeure refactorise entièrement la gestion des utilisateurs pour la faire passer par des Cloud Functions sécurisées et migre une part importante de la logique métier (gestion des événements, maintenances, containers) du client vers le backend.

**Gestion des Utilisateurs (Backend & Frontend):**
- **Nouvelle fonction `createUserWithInvite` :**
    - Crée l'utilisateur dans Firebase Auth avec un mot de passe temporaire.
    - Crée le document utilisateur correspondant dans Firestore.
    - Envoie automatiquement un e-mail de réinitialisation de mot de passe (via l'API REST de Firebase et `axios`) pour que l'utilisateur définisse son propre mot de passe, améliorant la sécurité et l'expérience d'intégration.
- **Refactorisation de `updateUser` et `deleteUser` :**
    - Les anciennes fonctions `onCall` sont remplacées par des fonctions `onRequest` (HTTP) standards, alignées avec le reste de l'API.
    - La logique de suppression gère désormais la suppression dans Auth et Firestore.
- **Réinitialisation de Mot de Passe (UI) :**
    - Ajout d'un bouton "Réinitialiser le mot de passe" sur la carte utilisateur, permettant aux administrateurs d'envoyer un e-mail de réinitialisation à n'importe quel utilisateur.
- **Amélioration de l'UI :**
    - Boîte de dialogue de confirmation améliorée pour la suppression d'un utilisateur.
    - Notifications (Snackbars) pour les opérations de création, suppression et réinitialisation de mot de passe.

**Migration de la Logique Métier vers les Cloud Functions:**
- **Gestion de la Préparation d'Événements :**
    - Migration complète de la logique de validation des étapes (préparation, chargement, déchargement, retour) du client vers de nouvelles Cloud Functions (`validateEquipmentPreparation`, `validateAllLoading`, etc.).
    - Le backend gère désormais la mise à jour des statuts de l'événement (`inProgress`, `completed`) et des équipements (`inUse`, `available`).
    - Le code frontend (`EventPreparationService`) a été simplifié pour appeler ces nouvelles fonctions au lieu d'effectuer des écritures directes sur Firestore.
- **Création de Maintenance :**
    - La fonction `createMaintenance` gère maintenant la mise à jour des équipements associés (`maintenanceIds`) et la création d'alertes (`maintenanceDue`) si une maintenance est prévue prochainement. La logique client a été supprimée.
- **Suppression de Container :**
    - La fonction `deleteContainer` a été améliorée pour nettoyer automatiquement les références (`parentBoxIds`) dans tous les équipements contenus avant de supprimer le container.

**Refactorisation et Corrections (Backend & Frontend) :**
- **Fiabilisation des Appels API (Frontend) :**
    - Le `ApiService` a été renforcé pour convertir de manière plus robuste les données (notamment les `Map` de type `_JsonMap`) en JSON standard avant de les envoyer aux Cloud Functions, évitant ainsi des erreurs de sérialisation.
- **Correction des Références (Backend) :**
    - La fonction `updateUser` convertit correctement les `roleId` (string) en `DocumentReference` Firestore.
    - Sécurisation de la vérification de l'assignation d'un utilisateur à un événement (`workforce`) pour éviter les erreurs sur des références nulles.
- **Dépendance (Backend) :**
    - Ajout de la librairie `axios` pour effectuer des appels à l'API REST de Firebase.
2026-01-14 12:05:03 +01:00
ElPoyo
4e4573f57b feat: refactor de la gestion des utilisateurs et migration de la logique métier vers les Cloud Functions
Cette mise à jour majeure refactorise entièrement la gestion des utilisateurs pour la faire passer par des Cloud Functions sécurisées et migre une part importante de la logique métier (gestion des événements, maintenances, containers) du client vers le backend.

**Gestion des Utilisateurs (Backend & Frontend):**
- **Nouvelle fonction `createUserWithInvite` :**
    - Crée l'utilisateur dans Firebase Auth avec un mot de passe temporaire.
    - Crée le document utilisateur correspondant dans Firestore.
    - Envoie automatiquement un e-mail de réinitialisation de mot de passe (via l'API REST de Firebase et `axios`) pour que l'utilisateur définisse son propre mot de passe, améliorant la sécurité et l'expérience d'intégration.
- **Refactorisation de `updateUser` et `deleteUser` :**
    - Les anciennes fonctions `onCall` sont remplacées par des fonctions `onRequest` (HTTP) standards, alignées avec le reste de l'API.
    - La logique de suppression gère désormais la suppression dans Auth et Firestore.
- **Réinitialisation de Mot de Passe (UI) :**
    - Ajout d'un bouton "Réinitialiser le mot de passe" sur la carte utilisateur, permettant aux administrateurs d'envoyer un e-mail de réinitialisation à n'importe quel utilisateur.
- **Amélioration de l'UI :**
    - Boîte de dialogue de confirmation améliorée pour la suppression d'un utilisateur.
    - Notifications (Snackbars) pour les opérations de création, suppression et réinitialisation de mot de passe.

**Migration de la Logique Métier vers les Cloud Functions:**
- **Gestion de la Préparation d'Événements :**
    - Migration complète de la logique de validation des étapes (préparation, chargement, déchargement, retour) du client vers de nouvelles Cloud Functions (`validateEquipmentPreparation`, `validateAllLoading`, etc.).
    - Le backend gère désormais la mise à jour des statuts de l'événement (`inProgress`, `completed`) et des équipements (`inUse`, `available`).
    - Le code frontend (`EventPreparationService`) a été simplifié pour appeler ces nouvelles fonctions au lieu d'effectuer des écritures directes sur Firestore.
- **Création de Maintenance :**
    - La fonction `createMaintenance` gère maintenant la mise à jour des équipements associés (`maintenanceIds`) et la création d'alertes (`maintenanceDue`) si une maintenance est prévue prochainement. La logique client a été supprimée.
- **Suppression de Container :**
    - La fonction `deleteContainer` a été améliorée pour nettoyer automatiquement les références (`parentBoxIds`) dans tous les équipements contenus avant de supprimer le container.

**Refactorisation et Corrections (Backend & Frontend) :**
- **Fiabilisation des Appels API (Frontend) :**
    - Le `ApiService` a été renforcé pour convertir de manière plus robuste les données (notamment les `Map` de type `_JsonMap`) en JSON standard avant de les envoyer aux Cloud Functions, évitant ainsi des erreurs de sérialisation.
- **Correction des Références (Backend) :**
    - La fonction `updateUser` convertit correctement les `roleId` (string) en `DocumentReference` Firestore.
    - Sécurisation de la vérification de l'assignation d'un utilisateur à un événement (`workforce`) pour éviter les erreurs sur des références nulles.
- **Dépendance (Backend) :**
    - Ajout de la librairie `axios` pour effectuer des appels à l'API REST de Firebase.
2026-01-14 11:18:49 +01:00
ElPoyo
4545bdba81 Fix : écran d'ajout de materiel, correction des conflits pour les cables et consommables 2026-01-13 18:50:46 +01:00
ElPoyo
272b4bc9c9 Fix : probleme de la détection d'utilisation par un autre événement 2026-01-13 18:07:16 +01:00
ElPoyo
0f7a886cf7 Fix : Mise a jour et création d'un événement 2026-01-13 17:26:09 +01:00
ElPoyo
2bcd1ca4c3 feat: Ajout de la gestion des utilisateurs et optimisation du chargement des données
Cette mise à jour introduit la gestion complète des utilisateurs (création, mise à jour, suppression) via des Cloud Functions et optimise de manière significative le chargement des données dans toute l'application.

**Features :**
- **Gestion des utilisateurs (Backend & Frontend) :**
    - Ajout des Cloud Functions `getUser`, `updateUser` et `deleteUser` pour gérer les utilisateurs de manière sécurisée, en respectant les permissions des rôles.
    - L'authentification passe désormais par `onCall` pour plus de sécurité.
- **Optimisation du chargement des données :**
    - Introduction de nouvelles Cloud Functions `getEquipmentsByIds` et `getContainersByIds` pour récupérer uniquement les documents nécessaires, réduisant ainsi la charge sur le client et Firestore.
    - Les fournisseurs (`EquipmentProvider`, `ContainerProvider`) ont été refactorisés pour utiliser un chargement à la demande (`ensureLoaded`) et mettre en cache les données récupérées.
    - Les écrans de détails et de préparation d'événements n'utilisent plus de `Stream` globaux, mais chargent les équipements et boites spécifiques via ces nouvelles fonctions, améliorant considérablement les performances.

**Refactorisation et Améliorations :**
- **Backend (Cloud Functions) :**
    - Le service de vérification de disponibilité (`checkEquipmentAvailability`) est désormais une Cloud Function, déplaçant la logique métier côté serveur.
    - La récupération des données (utilisateurs, événements, alertes) a été centralisée derrière des Cloud Functions, remplaçant les appels directs à Firestore depuis le client.
    - Amélioration de la sérialisation des données (timestamps, références) dans les réponses des fonctions.
- **Frontend (Flutter) :**
    - `LocalUserProvider` charge désormais les informations de l'utilisateur connecté via la fonction `getCurrentUser`, incluant son rôle et ses permissions en un seul appel.
    - `AlertProvider` utilise des fonctions pour charger et manipuler les alertes, abandonnant le `Stream` Firestore.
    - `EventAvailabilityService` utilise maintenant la Cloud Function `checkEquipmentAvailability` au lieu d'une logique client complexe.
    - Correction de la gestion des références de rôles (`roles/ADMIN`) et des `DocumentReference` pour les utilisateurs dans l'ensemble de l'application.
    - Le service d'export ICS (`IcsExportService`) a été simplifié, partant du principe que les données nécessaires (utilisateurs, options) sont déjà chargées dans l'application.
2026-01-13 01:40:28 +01:00
ElPoyo
f38d75362c refactor: Remplacement de l'accès direct à Firestore par des Cloud Functions
Migration complète du backend pour utiliser des Cloud Functions comme couche API sécurisée, en remplacement des appels directs à Firestore depuis le client.

**Backend (Cloud Functions):**
- **Centralisation CORS :** Ajout d'un middleware `withCors` et d'une configuration `httpOptions` pour gérer uniformément les en-têtes CORS et les requêtes `OPTIONS` sur toutes les fonctions.
- **Nouvelles Fonctions de Lecture (GET) :**
    - `getEquipments`, `getContainers`, `getEvents`, `getUsers`, `getOptions`, `getEventTypes`, `getRoles`, `getMaintenances`, `getAlerts`.
    - Ces fonctions gèrent les permissions côté serveur, masquant les données sensibles (ex: prix des équipements) pour les utilisateurs non-autorisés.
    - `getEvents` retourne également une map des utilisateurs (`usersMap`) pour optimiser le chargement des données de la main d'œuvre.
- **Nouvelle Fonction de Recherche :**
    - `getContainersByEquipment` : Endpoint dédié pour trouver efficacement tous les containers qui contiennent un équipement spécifique.
- **Nouvelles Fonctions d'Écriture (CRUD) :**
    - Fonctions CRUD complètes pour `eventTypes` (`create`, `update`, `delete`), incluant la validation (unicité du nom, vérification des événements futurs avant suppression).
- **Mise à jour de Fonctions Existantes :**
    - Toutes les fonctions CRUD existantes (`create/update/deleteEquipment`, `create/update/deleteContainer`, etc.) sont wrappées avec le nouveau gestionnaire CORS.

**Frontend (Flutter):**
- **Introduction du `DataService` :** Nouveau service centralisant tous les appels aux Cloud Functions, servant d'intermédiaire entre l'UI/Providers et l'API.
- **Refactorisation des Providers :**
    - `EquipmentProvider`, `ContainerProvider`, `EventProvider`, `UsersProvider`, `MaintenanceProvider` et `AlertProvider` ont été refactorisés pour utiliser le `DataService` au lieu d'accéder directement à Firestore.
    - Les `Stream` Firestore sont remplacés par des chargements de données via des méthodes `Future` (`loadEquipments`, `loadEvents`, etc.).
- **Gestion des Relations Équipement-Container :**
    - Le modèle `EquipmentModel` ne stocke plus `parentBoxIds`.
    - La relation est maintenant gérée par le `ContainerModel` qui contient `equipmentIds`.
    - Le `ContainerEquipmentService` est introduit pour utiliser la nouvelle fonction `getContainersByEquipment`.
    - L'affichage des boîtes parentes (`EquipmentParentContainers`) et le formulaire d'équipement (`EquipmentFormPage`) ont été mis à jour pour refléter ce nouveau modèle de données, synchronisant les ajouts/suppressions d'équipements dans les containers.
- **Amélioration de l'UI :**
    - Nouveau widget `ParentBoxesSelector` pour une sélection améliorée et visuelle des boîtes parentes dans le formulaire d'équipement.
    - Refonte visuelle de `EquipmentParentContainers` pour une meilleure présentation.
2026-01-12 20:38:46 +01:00
ElPoyo
13a890606d feat: ajout de la configuration des émulateurs Firebase et mise à jour des services pour utiliser le backend sécurisé 2026-01-06 23:43:36 +01:00
ElPoyo
fb6a271f66 feat: add current events section for equipment with dynamic status calculation 2026-01-06 12:13:09 +01:00
ElPoyo
25d395b41a feat: ajout de la gestion de la préparation d'un événement avec page permettant de le gérer 2026-01-06 10:53:23 +01:00
ElPoyo
fa1d6a4295 feat: export ICS 2025-12-20 15:56:57 +01:00
ElPoyo
df9e24d3b3 feat: ajout de champs jauge et contact mail et télphone.
Changement des icons de l'app
2025-12-20 15:38:27 +01:00
ElPoyo
28d9e008af feat: Ajout de la gestion de la quantité pour les options d'événement 2025-12-16 19:23:48 +01:00
ElPoyo
08f046c89c feat: Refactor event equipment management with advanced selection and conflict detection
This commit introduces a complete overhaul of how equipment is assigned to events, focusing on an enhanced user experience, advanced selection capabilities, and robust conflict detection.

**Key Features & Enhancements:**

-   **Advanced Equipment Selection UI (`EquipmentSelectionDialog`):**
    -   New full-screen dialog to select equipment and containers ("boîtes") for an event.
    -   Hierarchical view showing containers and a flat list of all individual equipment.
    -   Real-time search and filtering by equipment category.
    -   Side panel summarizing the current selection and providing recommendations for containers based on selected equipment.
    -   Supports quantity selection for consumables and cables.

-   **Conflict Detection & Management (`EventAvailabilityService`):**
    -   A new service (`EventAvailabilityService`) checks for equipment availability against other events based on the selected date range.
    -   The selection dialog visually highlights equipment and containers with scheduling conflicts (e.g., already used, partially unavailable).
    -   A dedicated conflict resolution dialog (`EquipmentConflictDialog`) appears if conflicting items are selected, allowing the user to either remove them or force the assignment.

-   **Integrated Event Form (`EventAssignedEquipmentSection`):**
    -   The event creation/editing form now includes a new section for managing assigned equipment.
    -   It clearly displays assigned containers and standalone equipment, showing the composition of each container.
    -   Integrates the new selection dialog, ensuring all assignments are checked for conflicts before being saved.

-   **Event Preparation & Return Workflow (`EventPreparationPage`):**
    -   New page (`EventPreparationPage`) for managing the check-out (preparation) and check-in (return) of equipment for an event.
    -   Provides a checklist of all assigned equipment.
    -   Users can validate each item, with options to "validate all" or finalize with missing items.
    -   Includes a dialog (`MissingEquipmentDialog`) to handle discrepancies.
    -   Supports tracking returned quantities for consumables.

**Data Model and Other Changes:**

-   The `EventModel` now includes `assignedContainers` to explicitly link containers to an event.
-   `EquipmentAssociatedEventsSection` on the equipment detail page is now functional, displaying current, upcoming, and past events for that item.
-   Added deployment and versioning scripts (`scripts/deploy.js`, `scripts/increment_version.js`, `scripts/toggle_env.js`) to automate the release process.
-   Introduced an application version display in the main drawer (`AppVersion`).
2025-11-30 20:33:03 +01:00
ElPoyo
e59e3e6316 feat: Enhance container management UI with new management components and improved QR code generation flow 2025-10-30 20:06:13 +01:00
ElPoyo
6abb8f1d14 feat: Introduce PDFService for optimized PDF generation and caching in container and equipment management 2025-10-30 18:45:50 +01:00
ElPoyo
822d4443f9 Refactor de la page de détails de l'équipement et ajouts de widgets communs
Refactor de la page `equipment_detail_page` en la décomposant en plusieurs widgets de section réutilisables pour une meilleure lisibilité et maintenance :
- `EquipmentHeaderSection` : En-tête avec titre et informations principales.
- `EquipmentMainInfoSection` : Informations sur la catégorie, la marque, le modèle et le statut.
- `EquipmentNotesSection` : Affichage des notes.
- `EquipmentDatesSection` : Gestion de l'affichage des dates (achat, maintenance, création, etc.).
- `EquipmentPriceSection` : Section dédiée aux prix.
- `EquipmentMaintenanceHistorySection` : Historique des maintenances.
- `EquipmentAssociatedEventsSection` : Placeholder pour les événements à venir.
- `EquipmentReferencingContainers` : Affiche les boites (containers) qui contiennent cet équipement.

Ajout de plusieurs widgets communs et utilitaires :
- Widgets UI : `SearchBarWidget`, `SelectionAppBar`, `CustomFilterChip`, `EmptyState`, `InfoChip`, `StatusBadge`, `QuantityDisplay`.
- Dialogues : `RestockDialog` pour les consommables et `DialogUtils` pour les confirmations génériques.

Autres modifications :
- Mise à jour de la terminologie "Container" en "Boite" dans l'interface utilisateur.
- Amélioration de la sélection d'équipements dans le formulaire des boites.
- Ajout d'instructions pour Copilot (`copilot-instructions.md`).
- Mise à jour de certaines icônes pour les types de boites.
2025-10-30 17:40:28 +01:00
ElPoyo
df6d54a007 Refactor: Centralisation des labels et icônes pour les enums
Centralise la gestion des libellés, couleurs et icônes pour `EquipmentStatus`, `EquipmentCategory`, et `ContainerType` en utilisant des extensions Dart.

- Ajout de nouvelles icônes SVG pour `flight-case`, `truss` et `tape`.
- Refactorisation des vues pour utiliser les nouvelles extensions, supprimant ainsi la logique d'affichage dupliquée.
- Mise à jour des `ChoiceChip` et des listes de filtres pour afficher les icônes à côté des labels.
2025-10-29 18:43:24 +01:00
ElPoyo
3fab69cb00 feat: Gestion complète des containers et refactorisation du matériel
Ajout de la gestion des containers (création, édition, suppression, affichage des détails).
Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple.

**Features:**
- **Gestion des Containers :**
    - Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres.
    - Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique.
    - Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques.
    - Ajout des routes et du provider (`ContainerProvider`) nécessaires.
- **Modèle de Données :**
    - Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc.
    - Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions).
- **QR Codes :**
    - Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité.
    - Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité.
    - Amélioration de la performance de la génération de QR codes en masse.
- **Interface Utilisateur (UI/UX) :**
    - Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`).
    - Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion.
    - Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`).
    - Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel.

**Refactorisation :**
- L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers.
- Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes.
- Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.).
2025-10-29 10:57:42 +01:00
ElPoyo
ae3a1b7227 Add equipment management features (and qr generation support) 2025-10-21 16:32:18 +02:00
ElPoyo
ef638d8c8c Mise en place du permission gate sur les pages de data managment 2025-10-17 15:59:53 +02:00
ElPoyo
5057bf9a77 Refactor event type handling and add data management page (options and event types) 2025-10-15 19:01:09 +02:00
ElPoyo
f10a608801 split et refacto de event_details.dart 2025-10-15 14:09:44 +02:00
ElPoyo
4128ddc34a Modif de l'affichage des données d'un événement et de l'afichage de la création/édition
Options sont maintenant géres dans firebase
2025-10-10 19:20:38 +02:00
ElPoyo
aae68f8ab7 V1 calendrier 2025-10-10 14:58:05 +02:00
299 changed files with 44349 additions and 2824 deletions

View File

@@ -0,0 +1,49 @@
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
favicon.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
icons/Icon-maskable-512.png,1766235851206,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
icons/Icon-maskable-192.png,1766235851135,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
icons/Icon-512.png,1766235851087,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
icons/Icon-192.png,1766235851013,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
canvaskit/skwasm_heavy.wasm,1759914809247,509ac05ee7c60aaee61d52bad4527f40e1ce79511ca29908237472a1cd476180
canvaskit/skwasm_heavy.js.symbols,1759914809219,612ffa6a568de0500758c132cd0ea7d7c4f389157d618fe2b4255e73f3068e8f
canvaskit/skwasm_heavy.js,1759914809214,5552644d0313045f87d52097dd1e86a75f64b9e048a450ce2c885e313ed1b4c5
canvaskit/skwasm.wasm,1759914809212,85c6ff573c3f76f2d84f5553fab09bf0d0f715519c679f7626722ac0fb501640
canvaskit/skwasm.js.symbols,1759914809190,83718024df2bd4902e4c0fdfa47ea7e9ca401dcf7f31f4061c6da8478f12987f
canvaskit/skwasm.js,1759914809185,2e251855d712f083d8c6aa79bf49f6d2a8e15311f161115eb8a39bcf0688c878
canvaskit/canvaskit.wasm,1759914809134,52dedf2cd2d6bf150262bf145ffde2fc80e296d98a9d3764961eb6f84c8ce988
canvaskit/canvaskit.js.symbols,1759914809092,a3577bf24071e07f599ac61535dbee4ae4d37c5cc6ee6289379576773f9c336b
canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074dd2afc181f26b323d2dd1
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664
assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
assets/assets/sounds/ok.mp3,1771938119844,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
assets/assets/sounds/error.mp3,1771938125144,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
assets/assets/logos/RectangleLogoBlack.png,1760462340000,536ebd370e55736b3622a673c684a150e23f5d3b82c71283d7a3f4a93564c02c
assets/assets/logos/LowQRectangleLogoBlack.png,1761139425319,ae4f8e428dd3634a14b45421a3c9b30fea8592ff33ff21f6962ed548e7db242b
assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb6399444016c67afe9e223fddf4ecdac1dad822198
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1772532792027,2b3f91e827bc27a1901342a048b1bd81d0aabc50935717f9851e1a3ad6cb7411
test_audio_tts.js,1772532705302,d7b70556456d3b5e7832506b2dafe31480d94db8d0027b89c1633cc9b5c5bdae
index.html,1772532797157,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_bootstrap.js,1772532797146,ca3df8691f4db5962ed165489bd051dfd31307628ab4f1ee68842dc747d39fd9
flutter_service_worker.js,1772532894886,9ce6b8d9f09c957b763a8d3db3baf03c96d4f84e805f6d629294749d9966cfad
assets/FontManifest.json,1772532889954,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1772532889954,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1772532889954,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1772532889954,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/shaders/ink_sparkle.frag,1772532890224,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1772532893514,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/fonts/MaterialIcons-Regular.otf,1772532893530,71c7128cf890cf3e18fffca405a98480f174bb3fa79d20c575b473d36c8c3093
assets/NOTICES,1772532889955,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74
main.dart.js,1772532888607,df89975075062e0983691b8997b9e4a1ae4b4d5dfe6c06ca5b42ffa5407fdd3f

View File

@@ -0,0 +1,450 @@
---
description: 'Instructions for writing Dart and Flutter code following the official recommendations.'
applyTo: '**/*.dart'
---
# Dart and Flutter
Best practices recommended by the Dart and Flutter teams. These instructions were taken from [Effective Dart](https://dart.dev/effective-dart) and [Architecture Recommendations](https://docs.flutter.dev/app-architecture/recommendations).
## Effective Dart
Over the past several years, we've written a ton of Dart code and learned a lot about what works well and what doesn't. We're sharing this with you so you can write consistent, robust, fast code too. There are two overarching themes:
1. **Be consistent.** When it comes to things like formatting, and casing, arguments about which is better are subjective and impossible to resolve. What we do know is that being *consistent* is objectively helpful.
If two pieces of code look different it should be because they *are* different in some meaningful way. When a bit of code stands out and catches your eye, it should do so for a useful reason.
2. **Be brief.** Dart was designed to be familiar, so it inherits many of the same statements and expressions as C, Java, JavaScript and other languages. But we created Dart because there is a lot of room to improve on what those languages offer. We added a bunch of features, from string interpolation to initializing formals, to help you express your intent more simply and easily.
If there are multiple ways to say something, you should generally pick the most concise one. This is not to say you should `code golf` yourself into cramming a whole program into a single line. The goal is code that is *economical*, not *dense*.
### The topics
We split the guidelines into a few separate topics for easy digestion:
* **Style** This defines the rules for laying out and organizing code, or at least the parts that `dart format` doesn't handle for you. The style topic also specifies how identifiers are formatted: `camelCase`, `using_underscores`, etc.
* **Documentation** This tells you everything you need to know about what goes inside comments. Both doc comments and regular, run-of-the-mill code comments.
* **Usage** This teaches you how to make the best use of language features to implement behavior. If it's in a statement or expression, it's covered here.
* **Design** This is the softest topic, but the one with the widest scope. It covers what we've learned about designing consistent, usable APIs for libraries. If it's in a type signature or declaration, this goes over it.
### How to read the topics
Each topic is broken into a few sections. Sections contain a list of guidelines. Each guideline starts with one of these words:
* **DO** guidelines describe practices that should always be followed. There will almost never be a valid reason to stray from them.
* **DON'T** guidelines are the converse: things that are almost never a good idea. Hopefully, we don't have as many of these as other languages do because we have less historical baggage.
* **PREFER** guidelines are practices that you *should* follow. However, there may be circumstances where it makes sense to do otherwise. Just make sure you understand the full implications of ignoring the guideline when you do.
* **AVOID** guidelines are the dual to "prefer": stuff you shouldn't do but where there may be good reasons to on rare occasions.
* **CONSIDER** guidelines are practices that you might or might not want to follow, depending on circumstances, precedents, and your own preference.
Some guidelines describe an **exception** where the rule does *not* apply. When listed, the exceptions may not be exhaustive—you might still need to use your judgement on other cases.
This sounds like the police are going to beat down your door if you don't have your laces tied correctly. Things aren't that bad. Most of the guidelines here are common sense and we're all reasonable people. The goal, as always, is nice, readable and maintainable code.
### Rules
#### Style
##### Identifiers
* DO name types using `UpperCamelCase`.
* DO name extensions using `UpperCamelCase`.
* DO name packages, directories, and source files using `lowercase_with_underscores`.
* DO name import prefixes using `lowercase_with_underscores`.
* DO name other identifiers using `lowerCamelCase`.
* PREFER using `lowerCamelCase` for constant names.
* DO capitalize acronyms and abbreviations longer than two letters like words.
* PREFER using wildcards for unused callback parameters.
* DON'T use a leading underscore for identifiers that aren't private.
* DON'T use prefix letters.
* DON'T explicitly name libraries.
##### Ordering
* DO place `dart:` imports before other imports.
* DO place `package:` imports before relative imports.
* DO specify exports in a separate section after all imports.
* DO sort sections alphabetically.
##### Formatting
* DO format your code using `dart format`.
* CONSIDER changing your code to make it more formatter-friendly.
* PREFER lines 80 characters or fewer.
* DO use curly braces for all flow control statements.
#### Documentation
##### Comments
* DO format comments like sentences.
* DON'T use block comments for documentation.
##### Doc comments
* DO use `///` doc comments to document members and types.
* PREFER writing doc comments for public APIs.
* CONSIDER writing a library-level doc comment.
* CONSIDER writing doc comments for private APIs.
* DO start doc comments with a single-sentence summary.
* DO separate the first sentence of a doc comment into its own paragraph.
* AVOID redundancy with the surrounding context.
* PREFER starting comments of a function or method with third-person verbs if its main purpose is a side effect.
* PREFER starting a non-boolean variable or property comment with a noun phrase.
* PREFER starting a boolean variable or property comment with "Whether" followed by a noun or gerund phrase.
* PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose.
* DON'T write documentation for both the getter and setter of a property.
* PREFER starting library or type comments with noun phrases.
* CONSIDER including code samples in doc comments.
* DO use square brackets in doc comments to refer to in-scope identifiers.
* DO use prose to explain parameters, return values, and exceptions.
* DO put doc comments before metadata annotations.
##### Markdown
* AVOID using markdown excessively.
* AVOID using HTML for formatting.
* PREFER backtick fences for code blocks.
##### Writing
* PREFER brevity.
* AVOID abbreviations and acronyms unless they are obvious.
* PREFER using "this" instead of "the" to refer to a member's instance.
#### Usage
##### Libraries
* DO use strings in `part of` directives.
* DON'T import libraries that are inside the `src` directory of another package.
* DON'T allow an import path to reach into or out of `lib`.
* PREFER relative import paths.
##### Null
* DON'T explicitly initialize variables to `null`.
* DON'T use an explicit default value of `null`.
* DON'T use `true` or `false` in equality operations.
* AVOID `late` variables if you need to check whether they are initialized.
* CONSIDER type promotion or null-check patterns for using nullable types.
##### Strings
* DO use adjacent strings to concatenate string literals.
* PREFER using interpolation to compose strings and values.
* AVOID using curly braces in interpolation when not needed.
##### Collections
* DO use collection literals when possible.
* DON'T use `.length` to see if a collection is empty.
* AVOID using `Iterable.forEach()` with a function literal.
* DON'T use `List.from()` unless you intend to change the type of the result.
* DO use `whereType()` to filter a collection by type.
* DON'T use `cast()` when a nearby operation will do.
* AVOID using `cast()`.
##### Functions
* DO use a function declaration to bind a function to a name.
* DON'T create a lambda when a tear-off will do.
##### Variables
* DO follow a consistent rule for `var` and `final` on local variables.
* AVOID storing what you can calculate.
##### Members
* DON'T wrap a field in a getter and setter unnecessarily.
* PREFER using a `final` field to make a read-only property.
* CONSIDER using `=>` for simple members.
* DON'T use `this.` except to redirect to a named constructor or to avoid shadowing.
* DO initialize fields at their declaration when possible.
##### Constructors
* DO use initializing formals when possible.
* DON'T use `late` when a constructor initializer list will do.
* DO use `;` instead of `{}` for empty constructor bodies.
* DON'T use `new`.
* DON'T use `const` redundantly.
##### Error handling
* AVOID catches without `on` clauses.
* DON'T discard errors from catches without `on` clauses.
* DO throw objects that implement `Error` only for programmatic errors.
* DON'T explicitly catch `Error` or types that implement it.
* DO use `rethrow` to rethrow a caught exception.
##### Asynchrony
* PREFER async/await over using raw futures.
* DON'T use `async` when it has no useful effect.
* CONSIDER using higher-order methods to transform a stream.
* AVOID using Completer directly.
* DO test for `Future<T>` when disambiguating a `FutureOr<T>` whose type argument could be `Object`.
#### Design
##### Names
* DO use terms consistently.
* AVOID abbreviations.
* PREFER putting the most descriptive noun last.
* CONSIDER making the code read like a sentence.
* PREFER a noun phrase for a non-boolean property or variable.
* PREFER a non-imperative verb phrase for a boolean property or variable.
* CONSIDER omitting the verb for a named boolean parameter.
* PREFER the "positive" name for a boolean property or variable.
* PREFER an imperative verb phrase for a function or method whose main purpose is a side effect.
* PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose.
* CONSIDER an imperative verb phrase for a function or method if you want to draw attention to the work it performs.
* AVOID starting a method name with `get`.
* PREFER naming a method `to...()` if it copies the object's state to a new object.
* PREFER naming a method `as...()` if it returns a different representation backed by the original object.
* AVOID describing the parameters in the function's or method's name.
* DO follow existing mnemonic conventions when naming type parameters.
##### Libraries
* PREFER making declarations private.
* CONSIDER declaring multiple classes in the same library.
##### Classes and mixins
* AVOID defining a one-member abstract class when a simple function will do.
* AVOID defining a class that contains only static members.
* AVOID extending a class that isn't intended to be subclassed.
* DO use class modifiers to control if your class can be extended.
* AVOID implementing a class that isn't intended to be an interface.
* DO use class modifiers to control if your class can be an interface.
* PREFER defining a pure `mixin` or pure `class` to a `mixin class`.
##### Constructors
* CONSIDER making your constructor `const` if the class supports it.
##### Members
* PREFER making fields and top-level variables `final`.
* DO use getters for operations that conceptually access properties.
* DO use setters for operations that conceptually change properties.
* DON'T define a setter without a corresponding getter.
* AVOID using runtime type tests to fake overloading.
* AVOID public `late final` fields without initializers.
* AVOID returning nullable `Future`, `Stream`, and collection types.
* AVOID returning `this` from methods just to enable a fluent interface.
##### Types
* DO type annotate variables without initializers.
* DO type annotate fields and top-level variables if the type isn't obvious.
* DON'T redundantly type annotate initialized local variables.
* DO annotate return types on function declarations.
* DO annotate parameter types on function declarations.
* DON'T annotate inferred parameter types on function expressions.
* DON'T type annotate initializing formals.
* DO write type arguments on generic invocations that aren't inferred.
* DON'T write type arguments on generic invocations that are inferred.
* AVOID writing incomplete generic types.
* DO annotate with `dynamic` instead of letting inference fail.
* PREFER signatures in function type annotations.
* DON'T specify a return type for a setter.
* DON'T use the legacy typedef syntax.
* PREFER inline function types over typedefs.
* PREFER using function type syntax for parameters.
* AVOID using `dynamic` unless you want to disable static checking.
* DO use `Future<void>` as the return type of asynchronous members that do not produce values.
* AVOID using `FutureOr<T>` as a return type.
##### Parameters
* AVOID positional boolean parameters.
* AVOID optional positional parameters if the user may want to omit earlier parameters.
* AVOID mandatory parameters that accept a special "no argument" value.
* DO use inclusive start and exclusive end parameters to accept a range.
##### Equality
* DO override `hashCode` if you override `==`.
* DO make your `==` operator obey the mathematical rules of equality.
* AVOID defining custom equality for mutable classes.
* DON'T make the parameter to `==` nullable.
---
## Flutter Architecture Recommendations
This page presents architecture best practices, why they matter, and
whether we recommend them for your Flutter application.
You should treat these recommendations as recommendations,
and not steadfast rules, and you should
adapt them to your app's unique requirements.
The best practices on this page have a priority,
which reflects how strongly the Flutter team recommends it.
* **Strongly recommend:** You should always implement this recommendation if
you're starting to build a new application. You should strongly consider
refactoring an existing app to implement this practice unless doing so would
fundamentally clash with your current approach.
* **Recommend**: This practice will likely improve your app.
* **Conditional**: This practice can improve your app in certain circumstances.
### Separation of concerns
You should separate your app into a UI layer and a data layer. Within those layers, you should further separate logic into classes by responsibility.
#### Use clearly defined data and UI layers.
**Strongly recommend**
Separation of concerns is the most important architectural principle.
The data layer exposes application data to the rest of the app, and contains most of the business logic in your application.
The UI layer displays application data and listens for user events from users. The UI layer contains separate classes for UI logic and widgets.
#### Use the repository pattern in the data layer.
**Strongly recommend**
The repository pattern is a software design pattern that isolates the data access logic from the rest of the application.
It creates an abstraction layer between the application's business logic and the underlying data storage mechanisms (databases, APIs, file systems, etc.).
In practice, this means creating Repository classes and Service classes.
#### Use ViewModels and Views in the UI layer. (MVVM)
**Strongly recommend**
Separation of concerns is the most important architectural principle.
This particular separation makes your code much less error prone because your widgets remain "dumb".
#### Use `ChangeNotifiers` and `Listenables` to handle widget updates.
**Conditional**
> There are many options to handle state-management, and ultimately the decision comes down to personal preference.
The `ChangeNotifier` API is part of the Flutter SDK, and is a convenient way to have your widgets observe changes in your ViewModels.
#### Do not put logic in widgets.
**Strongly recommend**
Logic should be encapsulated in methods on the ViewModel. The only logic a view should contain is:
* Simple if-statements to show and hide widgets based on a flag or nullable field in the ViewModel
* Animation logic that relies on the widget to calculate
* Layout logic based on device information, like screen size or orientation.
* Simple routing logic
#### Use a domain layer.
**Conditional**
> Use in apps with complex logic requirements.
A domain layer is only needed if your application has exceeding complex logic that crowds your ViewModels,
or if you find yourself repeating logic in ViewModels.
In very large apps, use-cases are useful, but in most apps they add unnecessary overhead.
### Handling data
Handling data with care makes your code easier to understand, less error prone, and
prevents malformed or unexpected data from being created.
#### Use unidirectional data flow.
**Strongly recommend**
Data updates should only flow from the data layer to the UI layer.
Interactions in the UI layer are sent to the data layer where they're processed.
#### Use `Commands` to handle events from user interaction.
**Recommend**
Commands prevent rendering errors in your app, and standardize how the UI layer sends events to the data layer.
#### Use immutable data models.
**Strongly recommend**
Immutable data is crucial in ensuring that any necessary changes occur only in the proper place, usually the data or domain layer.
Because immutable objects can't be modified after creation, you must create a new instance to reflect changes.
This process prevents accidental updates in the UI layer and supports a clear, unidirectional data flow.
#### Use freezed or built_value to generate immutable data models.
**Recommend**
You can use packages to help generate useful functionality in your data models, `freezed` or `built_value`.
These can generate common model methods like JSON ser/des, deep equality checking and copy methods.
These code generation packages can add significant build time to your applications if you have a lot of models.
#### Create separate API models and domain models.
**Conditional**
> Use in large apps.
Using separate models adds verbosity, but prevents complexity in ViewModels and use-cases.
### App structure
Well organized code benefits both the health of the app itself, and the team working on the code.
#### Use dependency injection.
**Strongly recommend**
Dependency injection prevents your app from having globally accessible objects, which makes your code less error prone.
We recommend you use the `provider` package to handle dependency injection.
#### Use `go_router` for navigation.
**Recommend**
Go_router is the preferred way to write 90% of Flutter applications.
There are some specific use-cases that go_router doesn't solve,
in which case you can use the `Flutter Navigator API` directly or try other packages found on `pub.dev`.
#### Use standardized naming conventions for classes, files and directories.
**Recommend**
We recommend naming classes for the architectural component they represent.
For example, you may have the following classes:
* HomeViewModel
* HomeScreen
* UserRepository
* ClientApiService
For clarity, we do not recommend using names that can be confused with objects from the Flutter SDK.
For example, you should put your shared widgets in a directory called `ui/core/`,
rather than a directory called `/widgets`.
#### Use abstract repository classes
**Strongly recommend**
Repository classes are the sources of truth for all data in your app,
and facilitate communication with external APIs.
Creating abstract repository classes allows you to create different implementations,
which can be used for different app environments, such as "development" and "staging".
### Testing
Good testing practices makes your app flexible.
It also makes it straightforward and low risk to add new logic and new UI.
#### Test architectural components separately, and together.
**Strongly recommend**
* Write unit tests for every service, repository and ViewModel class. These tests should test the logic of every method individually.
* Write widget tests for views. Testing routing and dependency injection are particularly important.
#### Make fakes for testing (and write code that takes advantage of fakes.)
**Strongly recommend**
Fakes aren't concerned with the inner workings of any given method as much
as they're concerned with inputs and outputs. If you have this in mind while writing application code,
you're forced to write modular, lightweight functions and classes with well defined inputs and outputs.
### Deploying Firebase
You should not use Firebase CLI. You have to ask the user for deploying or modifying something.

6
em2rp/.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,6 @@
CLEAN CODE très important: Toujours écrire du code propre, lisible et bien structuré. Utiliser des noms de variables et de fonctions explicites, éviter les répétitions inutiles et suivre les meilleures pratiques de codage.
Penser a créer des fonctions réutilisables pour éviter la duplication de code.
Verifier la présence de composant existants ou librairie existante avant de créer du code maison. Reutiliser le plus possible le code. Ne pas héister a analyser fréquemment la codebase existante.
Créer des fichiers séparés pour chaque composant, classe ou module afin de faciliter la maintenance et la réutilisation. Il faut eviter de dépasser 600 lignes par fichier.
Si quelque chose n'est pas clair, poser des questions pour clarifier les exigences avant de commencer à coder.
Ne pas générer de fichier résumant le code généré.

4
em2rp/.gitignore vendored
View File

@@ -41,3 +41,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Environment configuration with credentials
lib/config/env.dev.dart
functions/.env

43
em2rp/CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# Changelog - EM2RP
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 24/02/2026
Ajout de la gestion des maintenance et synthèse vocale
## 18/02/2026
Ajout de la fonctionnalité d'exportation des données au format CSV. Correction de bugs mineurs et amélioration des performances.
## 🚀 Nouveautés de la mise à jour
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.
* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.
* **Checklist de Préparation 2.0 :** L'interface de préparation a été repensée. Elle regroupe désormais les objets par conteneurs et permet de suivre visuellement les équipements manquants ou perdus à chaque étape (chargement, retour, etc.).
* **Sélecteur de Matériel Optimisé :** La recherche de matériel pour un événement est beaucoup plus rapide. Vous pouvez désormais masquer automatiquement les équipements déjà utilisés sur d'autres événements aux mêmes dates.
* **Gestion & Administration :** Affichage clair des prix HT/TTC partout dans l'application. Pour les administrateurs, l'ajout d'utilisateurs et la réinitialisation de mot de passe sont simplifiés via l'envoi d'emails automatiques.
### Ajouté
- Système de vérification automatique des mises à jour
- Dialog de notification de mise à jour avec notes de version
- Rechargement automatique du cache après mise à jour
## [1.0.0] - 2026-01-16
### Ajouté
- Scanner QR Code pour équipements et conteneurs
- Génération de QR codes pour conteneurs
- Indicateur de chargement pour génération QR
- Sections repliables dans le dialog de sélection d'équipement
- Filtrage des équipements en conflit
- Filtrage des boîtes par catégorie
### Amélioré
- Performance du dialog de sélection d'équipement
- Gestion du cache des équipements
- Interface utilisateur générale
### Corrigé
- Problème de cache avec les équipements non affichés
- Bouton de validation désactivé dans certains cas

View File

@@ -1,3 +0,0 @@
# em2rp
A new Flutter project.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1793 5032 c-17 -9 -82 -75 -144 -147 l-112 -130 511 -3 c282 -1 742
-1 1024 0 l511 3 -112 130 c-62 72 -127 138 -144 147 -29 16 -93 17 -767 17
-674 0 -738 -1 -767 -17z"/>
<path d="M76 4426 c-75 -45 -75 -46 -76 -443 l0 -353 410 0 410 0 0 34 c0 43
36 96 80 118 31 16 71 18 363 18 356 0 374 -2 416 -56 12 -15 26 -47 32 -71
l11 -43 842 0 842 0 2 32 c3 41 29 85 65 112 27 20 41 21 360 24 217 2 345 -1
369 -8 46 -13 85 -59 99 -116 l11 -44 404 0 404 0 0 351 c0 345 0 352 -22 391
-14 24 -38 48 -60 59 -36 19 -101 19 -2480 19 l-2443 0 -39 -24z"/>
<path d="M1120 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
<path d="M3710 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
<path d="M0 3088 l0 -243 240 240 c132 132 240 241 240 242 0 2 -108 3 -240 3
l-240 0 0 -242z"/>
<path d="M1718 3118 c-3 -234 -8 -255 -71 -302 -27 -20 -41 -21 -370 -24 -382
-3 -388 -2 -434 67 -22 32 -23 44 -23 205 l0 171 -410 -410 -410 -410 0 -460
0 -460 601 -608 601 -607 1350 0 1350 0 609 602 609 603 0 470 1 470 -403 397
-403 397 -5 -157 c-6 -172 -15 -202 -73 -246 -27 -20 -41 -21 -370 -24 -382
-3 -388 -2 -434 67 -22 33 -23 42 -23 252 l0 219 -844 0 -845 0 -3 -212z"/>
<path d="M4875 3090 c132 -132 241 -240 242 -240 2 0 3 108 3 240 l0 240 -242
0 -243 0 240 -240z"/>
<path d="M0 731 c0 -321 1 -335 21 -371 40 -71 71 -80 283 -80 l186 0 0 -45
c0 -100 56 -160 147 -160 85 0 143 57 150 147 l4 53 -395 395 -396 395 0 -334z"/>
<path d="M4723 668 c-384 -384 -393 -394 -393 -432 0 -63 34 -121 84 -145 54
-27 81 -26 136 2 54 27 80 72 80 140 l0 47 185 0 c214 0 243 8 283 78 22 40
22 44 20 371 l-3 331 -392 -392z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1979 5105 c-272 -46 -519 -179 -746 -403 -407 -401 -673 -1072 -701
-1767 -26 -644 82 -1147 345 -1610 131 -232 154 -409 69 -544 -39 -61 -145
-159 -201 -185 -104 -49 -330 -113 -585 -167 -58 -12 -106 -23 -108 -24 -4 -4
20 -99 27 -107 3 -4 59 0 124 7 160 19 167 16 231 -112 l36 -72 122 2 c140 2
157 -3 239 -80 l47 -44 116 26 c373 84 861 246 1020 339 94 55 141 166 139
331 -1 171 -47 317 -160 512 -254 440 -373 934 -373 1553 0 259 15 440 51 639
131 720 509 1334 1049 1703 24 16 8 17 -320 16 -231 0 -370 -4 -421 -13z"/>
<path d="M3276 5109 c-322 -47 -636 -237 -884 -534 -759 -909 -791 -2555 -68
-3530 86 -116 295 -319 401 -392 457 -309 952 -312 1410 -7 103 68 271 226
362 338 288 357 477 830 550 1381 24 182 24 628 0 810 -151 1141 -829 1953
-1622 1944 -49 -1 -116 -5 -149 -10z m399 -1047 c227 -79 439 -286 570 -557
282 -586 185 -1369 -222 -1796 -284 -297 -634 -357 -971 -167 -102 57 -260
212 -337 331 -337 521 -335 1288 7 1802 59 89 198 235 269 283 68 46 175 98
243 117 135 39 304 34 441 -13z"/>
<path d="M3307 3905 c-61 -15 -158 -60 -210 -97 -43 -30 -145 -127 -168 -159
l-19 -27 55 -113 c65 -138 107 -261 137 -411 19 -97 23 -144 23 -328 0 -184
-3 -231 -23 -328 -31 -152 -81 -302 -142 -423 -38 -76 -48 -105 -40 -118 5
-10 48 -54 95 -99 131 -125 263 -182 419 -182 350 0 651 328 756 825 27 130
37 373 21 514 -44 372 -212 694 -449 855 -132 90 -314 127 -455 91z"/>
<path d="M2327 628 c-3 -74 -11 -140 -20 -165 l-16 -43 226 0 226 0 -79 51
c-94 61 -164 118 -260 212 l-71 69 -6 -124z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M410 3418 c-70 -35 -90 -85 -90 -218 1 -152 35 -214 132 -235 l28 -7
0 -398 0 -398 -28 -7 c-97 -21 -131 -83 -132 -235 0 -133 20 -183 90 -217 l44
-23 2106 0 2106 0 44 23 c70 34 90 84 90 217 -1 152 -35 214 -132 235 l-28 7
0 398 0 398 28 7 c97 21 131 83 132 235 0 133 -20 183 -90 218 l-44 22 -2106
0 -2106 0 -44 -22z m4230 -218 l0 -80 -2080 0 -2080 0 0 80 0 80 2080 0 2080
0 0 -80z m-3770 -640 c-130 -260 -205 -400 -215 -400 -13 0 -15 51 -15 400 l0
400 215 0 215 0 -200 -400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393
201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197 -393 -201 -400 -230 -400
-29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200 -400z m640 0 l200 -400 -430
0 -430 0 200 400 c197 393 201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197
-393 -201 -400 -230 -400 -29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200
-400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393 201 400 230 400 29 0
33 -7 230 -400z m410 0 c0 -349 -2 -400 -15 -400 -10 0 -85 140 -215 400
l-200 400 215 0 215 0 0 -400z m160 -640 l0 -80 -2080 0 -2080 0 0 80 0 80
2080 0 2080 0 0 -80z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

BIN
em2rp/assets/sounds/ok.mp3 Normal file

Binary file not shown.

70
em2rp/deploy.bat Normal file
View File

@@ -0,0 +1,70 @@
@echo off
REM Script Windows pour incrémenter la version et déployer sur Firebase
echo ================================================
echo Déploiement Firebase Hosting avec EM2RP
echo ================================================
echo.
echo [0/4] Basculement en mode PRODUCTION...
node scripts\toggle_env.js prod
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors du basculement en mode production
pause
exit /b 1
)
echo.
echo [1/4] Incrémentation de la version...
node scripts\increment_version.js
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors de l'incrémentation de la version
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [1.5/4] Mise à jour du fichier version.json...
node scripts\update_version_json.js
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors de la mise à jour de version.json
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [2/4] Build Flutter Web...
call flutter build web --release
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors du build Flutter
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [3/4] Déploiement Firebase Hosting...
call firebase deploy --only hosting
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors du déploiement Firebase
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [4/4] Retour en mode DÉVELOPPEMENT...
node scripts\toggle_env.js dev
if %ERRORLEVEL% NEQ 0 (
echo ATTENTION: Impossible de rebascule en mode dev
echo Exécutez manuellement: node scripts\toggle_env.js dev
)
echo.
echo ================================================
echo Déploiement terminé avec succès!
echo ================================================
pause

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env pwsh
# Script de déploiement rapide - Corrections Alertes
Write-Host "=== DÉPLOIEMENT CORRECTIONS ALERTES ===" -ForegroundColor Cyan
Write-Host ""
# 1. Hot restart Flutter (si app en cours)
Write-Host "1. Hot restart recommandé (R dans le terminal Flutter)" -ForegroundColor Yellow
Write-Host ""
# 2. Pub get
Write-Host "2. Installation des dépendances..." -ForegroundColor Yellow
flutter pub get
# 3. Optionnel : Redéployer les fonctions si besoin
# Décommentez si vous avez modifié les Cloud Functions
# Write-Host "3. Déploiement Cloud Functions..." -ForegroundColor Yellow
# firebase deploy --only functions:sendAlertEmail
Write-Host ""
Write-Host "=== DÉPLOIEMENT TERMINÉ ===" -ForegroundColor Green
Write-Host ""
Write-Host "PROCHAINES ÉTAPES:" -ForegroundColor Cyan
Write-Host "1. Hot restart de l'application (R dans terminal Flutter)"
Write-Host "2. Vérifier que vous êtes connecté"
Write-Host "3. Créer un événement de test avec workforce"
Write-Host "4. Créer une alerte LOST (équipement perdu)"
Write-Host "5. Vérifier les logs (F12 → Console)"
Write-Host "6. Vérifier Firestore (Firebase Console)"
Write-Host ""
Write-Host "Voir CORRECTIONS_ALERTES_CIBLAGE.md pour détails" -ForegroundColor Yellow

View File

@@ -0,0 +1,25 @@
# Script de déploiement de la fonction onAlertCreated
Write-Host "=== Déploiement de onAlertCreated ===" -ForegroundColor Cyan
# Vérifier que nous sommes dans le bon répertoire
$currentPath = Get-Location
if ($currentPath.Path -notlike "*\em2rp") {
Write-Host "ERREUR: Ce script doit être exécuté depuis le répertoire em2rp" -ForegroundColor Red
exit 1
}
# S'assurer qu'on utilise le bon projet
Write-Host "`nVérification du projet Firebase..." -ForegroundColor Yellow
firebase use em2rp-951dc
# Déployer la fonction
Write-Host "`nDéploiement de la fonction..." -ForegroundColor Yellow
firebase deploy --only functions:onAlertCreated
if ($LASTEXITCODE -eq 0) {
Write-Host "`nDéploiement réussi!" -ForegroundColor Green
} else {
Write-Host "`nÉchec du déploiement" -ForegroundColor Red
exit 1
}

109
em2rp/deploy_backend.ps1 Normal file
View File

@@ -0,0 +1,109 @@
# Script de déploiement backend sécurisé
# Usage: .\deploy_backend.ps1 [test|prod]
param(
[Parameter(Mandatory=$true)]
[ValidateSet("test", "prod")]
[string]$mode
)
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Migration Backend - Déploiement" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Mode TEST : Lancer les émulateurs
if ($mode -eq "test") {
Write-Host "Mode: TEST (émulateurs)" -ForegroundColor Yellow
Write-Host "Lancement des émulateurs Firebase..." -ForegroundColor Yellow
Write-Host ""
firebase emulators:start
exit
}
# Mode PROD : Déploiement en production
Write-Host "Mode: PRODUCTION" -ForegroundColor Green
Write-Host ""
# Confirmation
Write-Host "ATTENTION: Vous allez déployer en PRODUCTION !" -ForegroundColor Red
$confirmation = Read-Host "Tapez 'OUI' pour confirmer"
if ($confirmation -ne "OUI") {
Write-Host "Déploiement annulé." -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 1/4 : Vérification du code" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# Vérifier que ApiConfig est en mode production
$apiConfigPath = "lib\config\api_config.dart"
$apiConfigContent = Get-Content $apiConfigPath -Raw
if ($apiConfigContent -match "isDevelopment = true") {
Write-Host "ERREUR: ApiConfig est en mode développement !" -ForegroundColor Red
Write-Host "Veuillez mettre 'isDevelopment = false' dans $apiConfigPath" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ ApiConfig en mode production" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 2/4 : Installation dépendances" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Push-Location functions
npm install
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Installation des dépendances échouée" -ForegroundColor Red
Pop-Location
exit 1
}
Pop-Location
Write-Host "✓ Dépendances installées" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 3/4 : Déploiement Cloud Functions" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
firebase deploy --only functions
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Déploiement des functions échoué" -ForegroundColor Red
exit 1
}
Write-Host "✓ Cloud Functions déployées" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 4/4 : Déploiement Firestore Rules" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
firebase deploy --only firestore:rules
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Déploiement des règles échoué" -ForegroundColor Red
exit 1
}
Write-Host "✓ Firestore Rules déployées" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
Write-Host "1. Tester les opérations CRUD (voir TESTING_PLAN.md)" -ForegroundColor Gray
Write-Host "2. Surveiller les logs: firebase functions:log" -ForegroundColor Gray
Write-Host "3. Vérifier les permissions utilisateurs" -ForegroundColor Gray
Write-Host ""
Write-Host "Console Firebase:" -ForegroundColor Cyan
Write-Host "https://console.firebase.google.com/project/em2rp-951dc/functions" -ForegroundColor Blue
Write-Host ""

View File

@@ -0,0 +1,85 @@
# Script de déploiement des règles Firestore
# Date : 15/01/2026
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " DÉPLOIEMENT RÈGLES FIRESTORE" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Vérifier que Firebase CLI est installé
Write-Host "Vérification Firebase CLI..." -ForegroundColor Yellow
$firebaseCmd = Get-Command firebase -ErrorAction SilentlyContinue
if ($null -eq $firebaseCmd) {
Write-Host "❌ Firebase CLI n'est pas installé !" -ForegroundColor Red
Write-Host ""
Write-Host "Installation requise :" -ForegroundColor Yellow
Write-Host " npm install -g firebase-tools" -ForegroundColor White
Write-Host ""
Write-Host "OU copier-coller manuellement dans Console Firebase" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ Firebase CLI trouvé" -ForegroundColor Green
Write-Host ""
# Vérifier que le fichier firestore.rules existe
if (-Not (Test-Path "firestore.rules")) {
Write-Host "❌ Fichier firestore.rules introuvable !" -ForegroundColor Red
Write-Host "Vérifiez que vous êtes dans le bon répertoire" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ Fichier firestore.rules trouvé" -ForegroundColor Green
Write-Host ""
# Afficher un aperçu des règles pour les alertes
Write-Host "Règles à déployer (extrait) :" -ForegroundColor Yellow
Write-Host "------------------------------" -ForegroundColor Gray
Get-Content "firestore.rules" | Select-String -Pattern "alerts" -Context 3 | Select-Object -First 10
Write-Host "------------------------------" -ForegroundColor Gray
Write-Host ""
# Demander confirmation
Write-Host "Déployer les règles Firestore ? (O/N)" -ForegroundColor Yellow -NoNewline
Write-Host " " -NoNewline
$confirmation = Read-Host
if ($confirmation -ne "O" -and $confirmation -ne "o") {
Write-Host "Déploiement annulé" -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "Déploiement en cours..." -ForegroundColor Cyan
# Déployer les règles
try {
firebase deploy --only firestore:rules
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " ✅ DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "Les règles Firestore ont été déployées avec succès." -ForegroundColor White
Write-Host ""
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
Write-Host " 1. Rafraîchir l'application (Ctrl+R)" -ForegroundColor White
Write-Host " 2. Créer un événement pour tester" -ForegroundColor White
Write-Host " 3. Vérifier qu'aucune erreur permission n'apparaît" -ForegroundColor White
Write-Host ""
} catch {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host ""
Write-Host "Erreur : $($_.Exception.Message)" -ForegroundColor Red
Write-Host ""
Write-Host "Solutions :" -ForegroundColor Yellow
Write-Host " 1. Vérifier connexion : firebase login" -ForegroundColor White
Write-Host " 2. Vérifier projet : firebase use" -ForegroundColor White
Write-Host " 3. OU déployer via Console Firebase" -ForegroundColor White
Write-Host ""
exit 1
}

View File

@@ -0,0 +1,66 @@
# EM2RP - Déploiement automatique du système d'alertes
# Ce script déploie les Cloud Functions et vérifie le déploiement
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " EM2RP - Déploiement Cloud Functions " -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Vérifier qu'on est dans le bon répertoire
if (-not (Test-Path ".\firebase.json")) {
Write-Host "❌ ERREUR: Vous devez lancer ce script depuis C:\src\EM2RP\em2rp\" -ForegroundColor Red
exit 1
}
# Vérifier que le fichier .env existe
if (-not (Test-Path ".\functions\.env")) {
Write-Host "❌ ERREUR: Le fichier functions\.env est manquant" -ForegroundColor Red
Write-Host " Créez ce fichier avec les identifiants SMTP" -ForegroundColor Yellow
exit 1
}
Write-Host "✅ Vérifications préliminaires OK" -ForegroundColor Green
Write-Host ""
# Déployer les fonctions
Write-Host "🚀 Déploiement des Cloud Functions en cours..." -ForegroundColor Cyan
Write-Host " (Cela peut prendre 3-5 minutes)" -ForegroundColor Gray
Write-Host ""
$deployResult = firebase deploy --only functions 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " ✅ DÉPLOIEMENT RÉUSSI" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
# Lister les fonctions déployées
Write-Host "📋 Fonctions déployées:" -ForegroundColor Cyan
firebase functions:list
Write-Host ""
Write-Host "🎯 Prochaines étapes:" -ForegroundColor Yellow
Write-Host " 1. Migrer les préférences utilisateurs: cd functions; node migrate_email_prefs.js" -ForegroundColor White
Write-Host " 2. Tester la création d'un événement avec workforce" -ForegroundColor White
Write-Host " 3. Vérifier les logs: firebase functions:log --limit 20" -ForegroundColor White
Write-Host ""
Write-Host "📚 Voir DEPLOY_NOW.md pour plus de détails" -ForegroundColor Gray
} else {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host ""
Write-Host "Erreur rencontrée:" -ForegroundColor Yellow
Write-Host $deployResult -ForegroundColor Red
Write-Host ""
Write-Host "💡 Solutions possibles:" -ForegroundColor Yellow
Write-Host " - Si 'Quota exceeded': Attendez 2 minutes et relancez" -ForegroundColor White
Write-Host " - Vérifiez que Firebase CLI est à jour: firebase --version" -ForegroundColor White
Write-Host " - Consultez les logs: firebase functions:log" -ForegroundColor White
exit 1
}

103
em2rp/deploy_hosting.ps1 Normal file
View File

@@ -0,0 +1,103 @@
# Script de déploiement du hosting Firebase
# Ce script construit l'application et la déploie sur Firebase Hosting
Write-Host "=== Déploiement Firebase Hosting ===" -ForegroundColor Cyan
Write-Host ""
# 1. Vérifier que nous sommes dans le bon dossier
if (!(Test-Path "pubspec.yaml")) {
Write-Host "ERREUR: Ce script doit être exécuté depuis la racine du projet Flutter" -ForegroundColor Red
exit 1
}
# 2. Construire l'application Flutter pour le web
Write-Host "Étape 1/3: Construction de l'application Flutter pour le web..." -ForegroundColor Yellow
flutter build web
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: La construction de l'application a échoué" -ForegroundColor Red
exit 1
}
Write-Host "✓ Application construite avec succès" -ForegroundColor Green
Write-Host ""
# 3. Vérifier que version.json existe
if (!(Test-Path "build/web/version.json")) {
Write-Host "AVERTISSEMENT: version.json n'a pas été copié dans build/web/" -ForegroundColor Yellow
# Copier manuellement si nécessaire
if (Test-Path "web/version.json") {
Write-Host " → Copie de web/version.json vers build/web/..." -ForegroundColor Yellow
Copy-Item "web/version.json" "build/web/version.json"
Write-Host "✓ Fichier copié" -ForegroundColor Green
} else {
Write-Host "ERREUR: web/version.json n'existe pas" -ForegroundColor Red
exit 1
}
}
Write-Host ""
# 4. Afficher la version qui va être déployée
$versionContent = Get-Content "build/web/version.json" | ConvertFrom-Json
Write-Host "Version à déployer: $($versionContent.version)" -ForegroundColor Cyan
Write-Host "Force update: $($versionContent.forceUpdate)" -ForegroundColor Cyan
Write-Host ""
# 5. Demander confirmation
$confirm = Read-Host "Voulez-vous déployer sur Firebase Hosting ? (o/n)"
if ($confirm -ne "o" -and $confirm -ne "O") {
Write-Host "Déploiement annulé" -ForegroundColor Yellow
exit 0
}
Write-Host ""
# 6. Déployer sur Firebase Hosting
Write-Host "Étape 2/3: Déploiement sur Firebase Hosting..." -ForegroundColor Yellow
firebase deploy --only hosting
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Le déploiement a échoué" -ForegroundColor Red
exit 1
}
Write-Host "✓ Déploiement réussi" -ForegroundColor Green
Write-Host ""
# 7. Vérifier que version.json est accessible
Write-Host "Étape 3/3: Vérification de l'accès à version.json..." -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri "https://app.em2events.fr/version.json" -Method GET -UseBasicParsing
if ($response.StatusCode -eq 200) {
Write-Host "✓ version.json est accessible" -ForegroundColor Green
# Vérifier les en-têtes CORS
if ($response.Headers["Access-Control-Allow-Origin"]) {
Write-Host "✓ En-têtes CORS configurés correctement" -ForegroundColor Green
} else {
Write-Host "⚠ ATTENTION: En-têtes CORS non détectés" -ForegroundColor Yellow
Write-Host " Les en-têtes peuvent prendre quelques minutes pour se propager" -ForegroundColor Yellow
}
# Afficher la version déployée
$deployedVersion = ($response.Content | ConvertFrom-Json).version
Write-Host "Version déployée: $deployedVersion" -ForegroundColor Cyan
} else {
Write-Host "⚠ Code de statut: $($response.StatusCode)" -ForegroundColor Yellow
}
} catch {
Write-Host "⚠ Impossible de vérifier l'accès à version.json" -ForegroundColor Yellow
Write-Host " Erreur: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Le fichier peut prendre quelques minutes pour être accessible" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "=== Déploiement terminé ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Les utilisateurs recevront une notification de mise à jour au prochain chargement de l'application." -ForegroundColor Green
Write-Host "URL de l'application: https://app.em2events.fr" -ForegroundColor Cyan

View File

@@ -1,3 +1,4 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- provider: true

15
em2rp/env_dev.bat Normal file
View File

@@ -0,0 +1,15 @@
@echo off
REM Script Windows pour basculer en mode DÉVELOPPEMENT
echo Basculement en mode DÉVELOPPEMENT...
node scripts\toggle_env.js dev
if %ERRORLEVEL% EQU 0 (
echo ✅ Mode DÉVELOPPEMENT activé
echo - isDevelopment = true
echo - Auto-login activé
) else (
echo ❌ Erreur lors du basculement
echo Vérifiez que le fichier env.dev.dart existe
)

16
em2rp/env_prod.bat Normal file
View File

@@ -0,0 +1,16 @@
@echo off
REM Script Windows pour basculer en mode PRODUCTION
echo Basculement en mode PRODUCTION...
node scripts\toggle_env.js prod
if %ERRORLEVEL% EQU 0 (
echo ✅ Mode PRODUCTION activé
echo - isDevelopment = false
echo - Credentials masqués
) else (
echo ❌ Erreur lors du basculement
)
pause

View File

@@ -34,5 +34,58 @@
"*.local"
]
}
]
],
"hosting": {
"public": "build/web",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"headers": [
{
"source": "version.json",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{
"key": "Access-Control-Allow-Methods",
"value": "GET, OPTIONS"
},
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
},
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"functions": {
"port": 5051
},
"firestore": {
"port": 8088
},
"auth": {
"port": 9199
},
"ui": {
"enabled": true,
"port": 4040
},
"singleProjectMode": true
}
}

View File

@@ -0,0 +1,119 @@
{
"indexes": [
{
"collectionGroup": "alerts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "assignedTo",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "isRead",
"order": "ASCENDING"
},
{
"fieldPath": "createdAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "alerts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "assignedTo",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "createdAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "containers",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "containers",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "type",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "containers",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "type",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "equipments",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "category",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "events",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "EndDateTime",
"order": "ASCENDING"
},
{
"fieldPath": "StartDateTime",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
}
]
}
],
"fieldOverrides": []
}

184
em2rp/firestore.rules Normal file
View File

@@ -0,0 +1,184 @@
rules_version = '2';
// ============================================================================
// RÈGLES FIRESTORE SÉCURISÉES - VERSION PRODUCTION
// ============================================================================
// Date de création : 14 janvier 2026
// Objectif : Bloquer tous les accès directs à Firestore depuis les clients
// Seules les Cloud Functions (côté serveur) peuvent lire/écrire les données
// ============================================================================
service cloud.firestore {
match /databases/{database}/documents {
// ========================================================================
// RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER
// ========================================================================
// Cette règle empêche tout accès direct depuis les clients (web/mobile)
// Les Cloud Functions ont un accès admin et ne sont pas affectées
match /{document=**} {
// ❌ REFUSER TOUS LES ACCÈS directs depuis les clients
allow read, write: if false;
}
// ========================================================================
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
// ========================================================================
// Si vous avez besoin de listeners en temps réel pour certaines collections,
// décommentez les règles ci-dessous.
//
// ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE.
// Toutes les ÉCRITURES doivent passer par les Cloud Functions.
// ========================================================================
/*
// Événements : Lecture seule pour utilisateurs authentifiés
match /events/{eventId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Équipements : Lecture seule pour utilisateurs authentifiés
match /equipments/{equipmentId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Conteneurs : Lecture seule pour utilisateurs authentifiés
match /containers/{containerId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Maintenances : Lecture seule pour utilisateurs authentifiés
match /maintenances/{maintenanceId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
*/
// Alertes : Lecture et création pour utilisateurs authentifiés
// Le trigger backend (onAlertCreated) s'occupe d'assigner les bonnes personnes
match /alerts/{alertId} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.createdBy == request.auth.uid; // Vérifier que l'utilisateur crée l'alerte en son nom
allow update: if request.auth != null
&& (
// L'utilisateur peut marquer comme lue uniquement s'il est assigné
(request.auth.uid in resource.data.assignedTo && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRead', 'readAt']))
// Ou le backend peut tout modifier (processed, assignedTo, etc.)
|| !('createdBy' in resource.data) // Le trigger backend n'a pas de createdBy
);
allow delete: if request.auth != null && request.auth.uid in resource.data.assignedTo;
}
/*
// Utilisateurs : Lecture de son propre profil uniquement
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow write: if false; // ❌ Écriture interdite
}
// Types d'événements : Lecture seule
match /eventTypes/{typeId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Options : Lecture seule
match /options/{optionId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Clients : Lecture seule
match /customers/{customerId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
*/
// ========================================================================
// RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL)
// ========================================================================
// Décommentez ces règles si vous voulez des permissions basées sur les rôles
// pour la lecture en temps réel
//
// ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans
// la collection users, ce qui peut impacter les performances et les coûts.
// ========================================================================
/*
// Fonction helper : Récupérer les permissions de l'utilisateur
function getUserPermissions() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions;
}
// Fonction helper : Vérifier si l'utilisateur a une permission
function hasPermission(permission) {
return request.auth != null && permission in getUserPermissions();
}
// Équipements : Lecture uniquement si permission view_equipment
match /equipments/{equipmentId} {
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
allow write: if false; // ❌ Écriture interdite
}
// Événements : Lecture selon permissions
match /events/{eventId} {
allow read: if hasPermission('view_events') || hasPermission('edit_event');
allow write: if false; // ❌ Écriture interdite
}
// Conteneurs : Lecture uniquement si permission view_equipment
match /containers/{containerId} {
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
allow write: if false; // ❌ Écriture interdite
}
// Maintenances : Lecture uniquement si permission view_equipment
match /maintenances/{maintenanceId} {
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
allow write: if false; // ❌ Écriture interdite
}
*/
}
}
// ============================================================================
// NOTES DE SÉCURITÉ
// ============================================================================
//
// 1. RÈGLE PAR DÉFAUT (allow read, write: if false)
// - Bloque TOUS les accès directs depuis les clients
// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin)
// - C'est la configuration la PLUS SÉCURISÉE
//
// 2. EXCEPTIONS DE LECTURE (commentées par défaut)
// - Permettent les listeners en temps réel pour certaines collections
// - UNIQUEMENT la LECTURE est autorisée
// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions)
//
// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut)
// - Permettent un contrôle plus fin basé sur les permissions utilisateur
// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users)
// - À utiliser uniquement si nécessaire
//
// 4. TESTS APRÈS DÉPLOIEMENT
// - Vérifier que les Cloud Functions fonctionnent toujours
// - Tester qu'un accès direct depuis la console échoue
// - Surveiller les logs : firebase functions:log
//
// 5. ROLLBACK EN CAS DE PROBLÈME
// - Remplacer temporairement par :
// match /{document=**} {
// allow read, write: if request.auth != null;
// }
// - Déployer rapidement : firebase deploy --only firestore:rules
//
// ============================================================================

View File

@@ -0,0 +1,20 @@
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/EM2_NsurB.jpg"
web:
generate: true
image_path: "assets/EM2_NsurB.jpg"
background_color: "#ffffff"
theme_color: "#0175C2"
windows:
generate: true
image_path: "assets/EM2_NsurB.jpg"
icon_size: 48
macos:
generate: true
image_path: "assets/EM2_NsurB.jpg"
linux:
generate: true
image_path: "assets/EM2_NsurB.jpg"

9
em2rp/functions/.env Normal file
View File

@@ -0,0 +1,9 @@
# Configuration SMTP pour l'envoi d'emails
SMTP_HOST="mail.em2events.fr"
SMTP_PORT=465
SMTP_USER="notify@em2events.fr"
SMTP_PASS="aL8@Rx8xqFrNij$a"
# URL de l'application
APP_URL="https://app.em2events.fr"

View File

@@ -1,2 +1,4 @@
node_modules/
*.local
.env
.env.local

View File

@@ -0,0 +1,271 @@
const {onRequest} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const logger = require('firebase-functions/logger');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
const auth = require('./utils/auth');
// Configuration CORS
const setCorsHeaders = (res, req) => {
// Utiliser l'origin de la requête pour permettre les credentials
const origin = req.headers.origin || '*';
res.set('Access-Control-Allow-Origin', origin);
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
if (origin !== '*') {
res.set('Access-Control-Allow-Credentials', 'true');
}
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
res.set('Access-Control-Max-Age', '3600');
};
const withCors = (handler) => {
return async (req, res) => {
setCorsHeaders(res, req);
// Gérer les requêtes preflight OPTIONS immédiatement
if (req.method === 'OPTIONS') {
res.status(204).send('');
return;
}
try {
await handler(req, res);
} catch (error) {
logger.error("Unhandled error:", error);
if (!res.headersSent) {
res.status(500).json({error: error.message});
}
}
};
};
/**
* Crée une alerte et envoie les notifications
* Gère tout le processus côté backend de A à Z
*/
exports.createAlert = onRequest({
cors: false,
invoker: 'public',
region: 'europe-west9'
}, withCors(async (req, res) => {
try {
// Vérifier l'authentification
const decodedToken = await auth.authenticateUser(req);
const data = req.body.data || req.body;
const {
type,
severity,
title,
message,
equipmentId,
eventId,
actionUrl,
metadata,
} = data;
// Validation des données
if (!type || !severity || !message) {
res.status(400).json({error: 'type, severity et message sont requis'});
return;
}
// 1. Déterminer les utilisateurs à notifier
const userIds = await determineTargetUsers(type, severity, eventId);
if (userIds.length === 0) {
res.status(400).json({error: 'Aucun utilisateur à notifier'});
return;
}
// 2. Créer l'alerte dans Firestore
const alertRef = admin.firestore().collection('alerts').doc();
const alertData = {
id: alertRef.id,
type,
severity,
title: title || getAlertTitle(type),
message,
equipmentId: equipmentId || null,
eventId: eventId || null,
actionUrl: actionUrl || null,
metadata: metadata || {},
assignedTo: userIds,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
isRead: false,
emailSent: false,
status: 'ACTIVE',
};
await alertRef.set(alertData);
// 3. Envoyer les emails si alerte critique
let emailResults = {};
if (severity === 'CRITICAL') {
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
// Mettre à jour le statut d'envoi
await alertRef.update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailResults,
});
}
res.status(200).json({
success: true,
alertId: alertRef.id,
usersNotified: userIds.length,
emailsSent: Object.values(emailResults).filter((v) => v).length,
});
} catch (error) {
logger.error('[createAlert] Erreur:', error);
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
}
}));
/**
* Détermine les utilisateurs à notifier selon le type d'alerte
*/
async function determineTargetUsers(alertType, severity, eventId) {
const db = admin.firestore();
const targetUserIds = new Set();
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
const allUsersSnapshot = await db.collection('users').get();
allUsersSnapshot.forEach((doc) => {
const user = doc.data();
if (user.role) {
// Le rôle peut être une référence Firestore ou une string
let rolePath = '';
if (typeof user.role === 'string') {
rolePath = user.role;
} else if (user.role.path) {
rolePath = user.role.path;
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
}
// Vérifier si c'est un admin (path = "roles/ADMIN")
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
targetUserIds.add(doc.id);
}
}
});
// 2. Si un événement est lié, ajouter tous les membres de la workforce
if (eventId) {
try {
const eventDoc = await db.collection('events').doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
const workforce = event.workforce || [];
workforce.forEach((member) => {
if (member.userId) {
targetUserIds.add(member.userId);
}
});
} else {
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
}
} catch (error) {
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
}
}
return Array.from(targetUserIds);
}
/**
* Envoie les emails d'alerte à tous les utilisateurs
*/
async function sendAlertEmails(alertId, alertData, userIds) {
const results = {};
const transporter = nodemailer.createTransporter(getSmtpConfig());
// Envoyer les emails en parallèle (batch de 5)
const batches = [];
for (let i = 0; i < userIds.length; i += 5) {
batches.push(userIds.slice(i, i + 5));
}
for (const batch of batches) {
const promises = batch.map(async (userId) => {
try {
const sent = await sendSingleEmail(transporter, alertId, alertData, userId);
results[userId] = sent;
} catch (error) {
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
results[userId] = false;
}
});
await Promise.all(promises);
}
return results;
}
/**
* Envoie un email à un utilisateur spécifique
*/
async function sendSingleEmail(transporter, alertId, alertData, userId) {
const db = admin.firestore();
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
if (!userDoc.exists) {
return false;
}
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
// Vérifier la préférence pour ce type d'alerte
if (!checkAlertPreference(alertData.type, prefs)) {
return false;
}
if (!user.email) {
return false;
}
try {
// Préparer les données du template
const templateData = await prepareTemplateData(alertData, user);
// Rendre le template
const html = await renderTemplate('alert-individual', templateData);
// Envoyer l'email
await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alertData),
html: html,
text: alertData.message,
});
return true;
} catch (error) {
logger.error(`[sendSingleEmail] Erreur envoi à ${userId}:`, error);
return false;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
/**
* Script de migration : Active les emails pour tous les utilisateurs existants
* À exécuter une seule fois après le déploiement
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
// AJOUTER CECI : Charger le fichier de clé
const serviceAccount = require('./serviceAccountKey.json');
// Initialiser Firebase Admin avec les credentials explicites
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
projectId: 'em2rp-951dc',
});
}
const db = admin.firestore();
/**
* Active les notifications par email pour tous les utilisateurs existants
*/
async function migrateEmailPreferences() {
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
try {
// 1. Récupérer tous les utilisateurs
const usersSnapshot = await db.collection('users').get();
console.log(`${usersSnapshot.size} utilisateurs trouvés\n`);
// 2. Préparer les updates
const updates = [];
let alreadyEnabled = 0;
let toUpdate = 0;
usersSnapshot.forEach((doc) => {
const user = doc.data();
const prefs = user.notificationPreferences || {};
// Vérifier si déjà activé
if (prefs.emailEnabled === true) {
alreadyEnabled++;
console.log(`${user.email || doc.id}: emails déjà activés`);
} else {
toUpdate++;
console.log(`${user.email || doc.id}: activation des emails`);
updates.push({
ref: doc.ref,
data: {
'notificationPreferences.emailEnabled': true,
},
});
}
});
console.log(`\n--- RÉSUMÉ ---`);
console.log(` Total utilisateurs: ${usersSnapshot.size}`);
console.log(` Déjà activés: ${alreadyEnabled}`);
console.log(` À mettre à jour: ${toUpdate}`);
// 3. Appliquer les mises à jour par batches de 500 (limite Firestore)
if (updates.length > 0) {
console.log(`\nApplication des mises à jour...`);
const batchSize = 500;
for (let i = 0; i < updates.length; i += batchSize) {
const batch = db.batch();
const currentBatch = updates.slice(i, i + batchSize);
currentBatch.forEach((update) => {
batch.update(update.ref, update.data);
});
await batch.commit();
console.log(` ✓ Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(updates.length / batchSize)} appliqué`);
}
console.log(`\n✓ Migration terminée avec succès !`);
console.log(` ${toUpdate} utilisateurs mis à jour\n`);
} else {
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
}
console.log('=== FIN MIGRATION ===');
return {
success: true,
total: usersSnapshot.size,
alreadyEnabled,
updated: toUpdate,
};
} catch (error) {
console.error('❌ ERREUR MIGRATION:', error);
throw error;
}
}
// Exécuter la migration si appelé directement
if (require.main === module) {
migrateEmailPreferences()
.then((result) => {
console.log('\n✓ Migration réussie:', result);
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Migration échouée:', error);
process.exit(1);
});
}
module.exports = { migrateEmailPreferences };

View File

@@ -0,0 +1,93 @@
/**
* Script de migration pour ajouter le champ 'id' aux équipements qui n'en ont pas
*
* Ce script parcourt tous les documents de la collection 'equipments' et ajoute
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
*/
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');
// Initialiser Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
const db = admin.firestore();
async function migrateEquipmentIds() {
console.log('🔧 Migration: Ajout du champ id aux équipements');
console.log('================================================\n');
try {
// Récupérer tous les équipements
const equipmentsSnapshot = await db.collection('equipments').get();
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
let missingIdCount = 0;
let updatedCount = 0;
let errorCount = 0;
const batch = db.batch();
let batchCount = 0;
for (const doc of equipmentsSnapshot.docs) {
const data = doc.data();
// Vérifier si le champ 'id' est manquant ou vide
if (!data.id || data.id === '') {
missingIdCount++;
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
// Ajouter au batch
batch.update(doc.ref, { id: doc.id });
batchCount++;
updatedCount++;
// Exécuter le batch tous les 500 documents (limite Firestore)
if (batchCount === 500) {
await batch.commit();
console.log(`✅ Batch de ${batchCount} documents mis à jour`);
batchCount = 0;
}
}
}
// Exécuter le dernier batch s'il reste des documents
if (batchCount > 0) {
await batch.commit();
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
}
console.log('\n================================================');
console.log('📊 RÉSUMÉ DE LA MIGRATION');
console.log('================================================');
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
console.log(`Équipements mis à jour: ${updatedCount}`);
console.log(`Erreurs: ${errorCount}`);
console.log('================================================\n');
if (missingIdCount === 0) {
console.log('✅ Tous les équipements ont déjà un champ id !');
} else if (updatedCount === missingIdCount) {
console.log('✅ Migration terminée avec succès !');
} else {
console.log('⚠️ Migration terminée avec des erreurs');
}
} catch (error) {
console.error('❌ Erreur lors de la migration:', error);
throw error;
}
}
// Exécuter la migration
migrateEquipmentIds()
.then(() => {
console.log('\n✅ Script terminé');
process.exit(0);
})
.catch(error => {
console.error('\n❌ Script échoué:', error);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,14 @@
},
"main": "index.js",
"dependencies": {
"@google-cloud/storage": "^7.18.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^6.0.1"
"firebase-functions": "^7.0.3",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
},
"devDependencies": {
"eslint": "^8.15.0",

View File

@@ -0,0 +1,418 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
/**
* Traite la validation du matériel d'un événement
* Appelée par le client lors du chargement/déchargement
* Crée automatiquement les alertes nécessaires
*/
exports.processEquipmentValidation = onCall({
cors: true,
region: 'europe-west9'
}, async (request) => {
try {
// L'authentification est automatique avec onCall
const {auth, data} = request;
if (!auth) {
throw new Error('L\'utilisateur doit être authentifié');
}
const {
eventId,
equipmentList, // [{equipmentId, status, quantity, etc.}]
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
} = data;
// Validation
if (!eventId || !equipmentList || !validationType) {
throw new Error('eventId, equipmentList et validationType sont requis');
}
const db = admin.firestore();
const alerts = [];
// 1. Récupérer les détails de l'événement
const eventRef = db.collection('events').doc(eventId);
const eventDoc = await eventRef.get();
if (!eventDoc.exists) {
throw new Error('Événement introuvable');
}
const event = eventDoc.data();
const eventName = event.Name || event.name || 'Événement inconnu';
const eventDate = formatEventDate(event);
// 2. Analyser les équipements et détecter les problèmes
for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Cas 1: Équipement PERDU
if (status === 'LOST') {
const alertData = await createAlertInFirestore({
type: 'LOST',
severity: 'CRITICAL',
title: 'Équipement perdu',
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
// Cas 2: Équipement MANQUANT
if (status === 'MISSING') {
const alertData = await createAlertInFirestore({
type: 'EQUIPMENT_MISSING',
severity: 'WARNING',
title: 'Équipement manquant',
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
// Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH',
severity: 'INFO',
title: 'Quantité incorrecte',
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
expected: expectedQuantity,
actual: quantity,
},
});
alerts.push(alertData);
}
// Cas 4: Équipement endommagé
if (status === 'DAMAGED') {
const alertData = await createAlertInFirestore({
type: 'DAMAGED',
severity: 'WARNING',
title: 'Équipement endommagé',
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
}
// 3. Mettre à jour les équipements de l'événement
await eventRef.update({
equipment: equipmentList,
lastValidation: {
type: validationType,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
by: auth.uid,
},
});
// 4. Envoyer les notifications pour les alertes critiques
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
if (criticalAlerts.length > 0) {
for (const alert of criticalAlerts) {
try {
await sendAlertNotifications(alert, eventId);
} catch (notificationError) {
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
}
}
}
return {
success: true,
alertsCreated: alerts.length,
criticalAlertsCount: criticalAlerts.length,
alertIds: alerts.map((a) => a.id),
};
} catch (error) {
logger.error('[processEquipmentValidation] Erreur:', error);
throw error;
}
});
/**
* Crée une alerte dans Firestore
*/
async function createAlertInFirestore(alertData) {
const db = admin.firestore();
const alertRef = db.collection('alerts').doc();
const fullAlertData = {
id: alertRef.id,
...alertData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
status: 'ACTIVE',
emailSent: false,
assignedTo: [],
};
await alertRef.set(fullAlertData);
return {...fullAlertData, id: alertRef.id};
}
/**
* Détermine les utilisateurs à notifier et envoie les notifications
*/
async function sendAlertNotifications(alert, eventId) {
const db = admin.firestore();
const targetUserIds = new Set();
const usersWithPermission = new Set();
try {
// 1. Récupérer TOUS les utilisateurs et leurs permissions
const allUsersSnapshot = await db.collection('users').get();
// Créer un map pour stocker les références de rôles à récupérer
const roleRefs = new Map();
for (const doc of allUsersSnapshot.docs) {
const user = doc.data();
if (!user.role) {
continue;
}
// Extraire le chemin du rôle
let rolePath = '';
let roleId = '';
if (typeof user.role === 'string') {
rolePath = user.role;
roleId = user.role.split('/').pop();
} else if (user.role.path) {
rolePath = user.role.path;
roleId = user.role.path.split('/').pop();
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
roleId = user.role._path.segments[user.role._path.segments.length - 1];
}
if (roleId && !roleRefs.has(roleId)) {
roleRefs.set(roleId, {users: [], rolePath});
}
if (roleId) {
roleRefs.get(roleId).users.push(doc.id);
}
}
// 2. Récupérer les permissions de chaque rôle unique
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
try {
const roleDoc = await db.collection('roles').doc(roleId).get();
if (roleDoc.exists) {
const roleData = roleDoc.data();
const permissions = roleData.permissions || [];
// Vérifier si le rôle a la permission view_all_events
if (permissions.includes('view_all_events')) {
users.forEach((userId) => {
usersWithPermission.add(userId);
targetUserIds.add(userId);
});
}
}
} catch (error) {
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
}
}
// 3. Ajouter la workforce de l'événement
if (eventId) {
const eventDoc = await db.collection('events').doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
const workforce = event.workforce || [];
workforce.forEach((member) => {
// Extraire l'userId selon différentes structures possibles
let userId = null;
if (typeof member === 'string') {
userId = member;
} else if (member.userId) {
userId = member.userId;
} else if (member.id) {
userId = member.id;
} else if (member.user) {
if (typeof member.user === 'string') {
userId = member.user;
} else if (member.user.id) {
userId = member.user.id;
}
}
if (userId) {
targetUserIds.add(userId);
}
});
}
}
const userIds = Array.from(targetUserIds);
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
await db.collection('alerts').doc(alert.id).update({
assignedTo: userIds,
});
// 5. Envoyer les emails si alerte critique
if (alert.severity === 'CRITICAL') {
await sendAlertEmails(alert, userIds);
}
return userIds;
} catch (error) {
logger.error('[sendAlertNotifications] Erreur:', error);
throw error;
}
}
/**
* Envoie les emails d'alerte
*/
async function sendAlertEmails(alert, userIds) {
try {
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
const db = admin.firestore();
// Vérifier que EMAIL_CONFIG est disponible
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
return 0;
}
const transporter = nodemailer.createTransport(getSmtpConfig());
let successCount = 0;
// Envoyer les emails par lots de 5
const batches = [];
for (let i = 0; i < userIds.length; i += 5) {
batches.push(userIds.slice(i, i + 5));
}
for (const batch of batches) {
const promises = batch.map(async (userId) => {
try {
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
if (!userDoc.exists) {
return false;
}
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
if (!user.email) {
return false;
}
// Préparer et envoyer l'email
let html;
try {
const templateData = await prepareTemplateData(alert, user);
html = await renderTemplate('alert-individual', templateData);
} catch (templateError) {
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
html = `
<html>
<body>
<h2>${alert.title || 'Nouvelle alerte'}</h2>
<p>${alert.message}</p>
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
</body>
</html>
`;
}
await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alert),
html: html,
text: alert.message,
});
return true;
} catch (error) {
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
return false;
}
});
const results = await Promise.all(promises);
successCount += results.filter((r) => r).length;
}
// Mettre à jour l'alerte
await db.collection('alerts').doc(alert.id).update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailsSentCount: successCount,
});
return successCount;
} catch (error) {
logger.error('[sendAlertEmails] Erreur globale:', error);
return 0;
}
}
/**
* Formate la date d'un événement
*/
function formatEventDate(event) {
if (event.startDate) {
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
}
return 'Date inconnue';
}

View File

@@ -0,0 +1,265 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
/**
* Envoie un email d'alerte à un utilisateur
* Appelé par le client Dart via callable function
*/
exports.sendAlertEmail = onCall({
region: 'europe-west9',
cors: true
}, async (request) => {
// Vérifier l'authentification
if (!request.auth) {
throw new Error('L\'utilisateur doit être authentifié');
}
const {alertId, userId, templateType} = request.data;
if (!alertId || !userId) {
throw new Error('alertId et userId sont requis');
}
try {
// Récupérer l'alerte depuis Firestore
const alertDoc = await admin.firestore()
.collection('alerts')
.doc(alertId)
.get();
if (!alertDoc.exists) {
throw new Error('Alerte introuvable');
}
const alert = alertDoc.data();
// Récupérer l'utilisateur
const userDoc = await admin.firestore()
.collection('users')
.doc(userId)
.get();
if (!userDoc.exists) {
throw new Error('Utilisateur introuvable');
}
const user = userDoc.data();
// Vérifier les préférences email de l'utilisateur
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
console.log(`Email désactivé pour l'utilisateur ${userId}`);
return {success: true, skipped: true, reason: 'email_disabled'};
}
// Vérifier la préférence pour ce type d'alerte
const alertType = alert.type;
const shouldSend = checkAlertPreference(alertType, prefs);
if (!shouldSend) {
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
return {success: true, skipped: true, reason: 'alert_type_disabled'};
}
// Préparer les données pour le template
const templateData = await prepareTemplateData(alert, user);
// Rendre le template HTML
const html = await renderTemplate(
templateType || 'alert-individual',
templateData,
);
// Configurer le transporteur SMTP
const transporter = nodemailer.createTransporter(getSmtpConfig());
// Envoyer l'email
const info = await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alert),
html: html,
// Fallback texte brut
text: alert.message,
});
console.log('Email envoyé:', info.messageId);
// Marquer l'email comme envoyé dans l'alerte
await alertDoc.ref.update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
});
return {
success: true,
messageId: info.messageId,
skipped: false,
};
} catch (error) {
console.error('Erreur envoi email:', error);
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
}
});
/**
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
};
const prefKey = typeMapping[alertType];
return prefKey ? (preferences[prefKey] !== false) : true;
}
/**
* Prépare les données pour le template
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
subject: getEmailSubject(alert),
};
// Ajouter des détails selon le type d'alerte
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
data.eventName = event.Name;
if (event.StartDateTime) {
const date = event.StartDateTime.toDate();
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
}
} catch (error) {
console.error('Erreur récupération événement:', error);
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.doc(alert.equipmentId)
.get();
if (eqDoc.exists) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
console.error('Erreur récupération équipement:', error);
}
}
return data;
}
/**
* Génère le titre de l'email selon le type d'alerte
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
}
/**
* Génère le titre pour le corps de l'email
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
};
return titles[type] || 'Nouvelle alerte';
}
/**
* Rend un template HTML avec Handlebars
*/
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'templates',
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
const compileBase = handlebars.compile(baseTemplate);
// Rendre le contenu
const renderedContent = compileContent(data);
// Rendre le template de base avec le contenu
return compileBase({
...data,
content: renderedContent,
});
} catch (error) {
console.error('Erreur rendu template:', error);
// Fallback vers un template simple
return `
<html>
<body>
<h2>${data.alertTitle}</h2>
<p>${data.alertMessage}</p>
<a href="${data.actionUrl}">Voir l'alerte</a>
</body>
</html>
`;
}
}

View File

@@ -0,0 +1,267 @@
/**
* Fonction schedulée : Envoie quotidienne d'un résumé des alertes non lues
* S'exécute tous les jours à 8h00 (Europe/Paris)
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const { getSmtpConfig } = require('./utils/emailConfig');
/**
* Fonction principale : envoie le digest quotidien
*/
async function sendDailyDigest() {
const db = admin.firestore();
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
try {
// 1. Récupérer tous les utilisateurs avec email activé
const usersSnapshot = await db.collection('users').get();
const eligibleUsers = [];
usersSnapshot.forEach((doc) => {
const user = doc.data();
const prefs = user.notificationPreferences || {};
// Vérifier si l'utilisateur a activé les emails
if (prefs.emailEnabled !== false && user.email) {
eligibleUsers.push({
uid: doc.id,
email: user.email,
firstName: user.firstName || 'Utilisateur',
lastName: user.lastName || '',
});
}
});
logger.info(`[sendDailyDigest] ${eligibleUsers.length} utilisateurs éligibles`);
// 2. Pour chaque utilisateur, récupérer ses alertes non lues des dernières 24h
const now = admin.firestore.Timestamp.now();
const yesterday = admin.firestore.Timestamp.fromMillis(now.toMillis() - 24 * 60 * 60 * 1000);
const transporter = nodemailer.createTransport(getSmtpConfig());
let emailsSent = 0;
for (const user of eligibleUsers) {
try {
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
const alertsSnapshot = await db.collection('alerts')
.where('assignedTo', 'array-contains', user.uid)
.where('isRead', '==', false)
.where('createdAt', '>=', yesterday)
.orderBy('createdAt', 'desc')
.get();
if (alertsSnapshot.empty) {
continue; // Pas d'alertes non lues pour cet utilisateur
}
const alerts = [];
alertsSnapshot.forEach((doc) => {
alerts.push({ id: doc.id, ...doc.data() });
});
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
// 3. Envoyer l'email de digest
const sent = await sendDigestEmail(transporter, user, alerts);
if (sent) {
emailsSent++;
}
} catch (error) {
logger.error(`[sendDailyDigest] Erreur pour ${user.email}:`, error);
}
}
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
return { success: true, emailsSent };
} catch (error) {
logger.error('[sendDailyDigest] Erreur globale:', error);
throw error;
}
}
/**
* Envoie l'email de digest à un utilisateur
*/
async function sendDigestEmail(transporter, user, alerts) {
try {
// Grouper les alertes par sévérité
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
// Construire le HTML
const html = buildDigestHtml(user, {
critical: criticalAlerts,
warning: warningAlerts,
info: infoAlerts,
});
// Envoyer l'email
await transporter.sendMail({
from: `"EM2RP Notifications" <${process.env.SMTP_USER}>`,
to: user.email,
subject: `📬 ${alerts.length} nouvelle(s) alerte(s) EM2RP`,
html,
});
logger.info(`[sendDigestEmail] ✓ Email envoyé à ${user.email}`);
return true;
} catch (error) {
logger.error(`[sendDigestEmail] Erreur pour ${user.email}:`, error);
return false;
}
}
/**
* Construit le HTML du digest
*/
function buildDigestHtml(user, alertsByType) {
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
let alertsHtml = '';
// Alertes critiques
if (alertsByType.critical.length > 0) {
alertsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
🔴 Alertes critiques (${alertsByType.critical.length})
</h3>
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
</div>
`;
}
// Alertes warning
if (alertsByType.warning.length > 0) {
alertsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
⚠️ Avertissements (${alertsByType.warning.length})
</h3>
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
</div>
`;
}
// Alertes info
if (alertsByType.info.length > 0) {
alertsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
Informations (${alertsByType.info.length})
</h3>
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
</div>
`;
}
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
<!-- En-tête -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 28px;">📬 Résumé quotidien</h1>
<p style="color: rgba(255,255,255,0.9); margin: 8px 0 0 0; font-size: 16px;">
Bonjour ${user.firstName},
</p>
</div>
<!-- Contenu -->
<div style="background-color: white; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px 0;">
Vous avez <strong>${totalAlerts} nouvelle(s) alerte(s)</strong> dans les dernières 24 heures.
</p>
${alertsHtml}
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; text-align: center;">
<a href="https://app.em2event.fr/#/alerts"
style="display: inline-block; background-color: #667eea; color: white; padding: 12px 32px; text-decoration: none; border-radius: 8px; font-weight: 600;">
Voir toutes les alertes
</a>
</div>
</div>
<!-- Pied de page -->
<div style="text-align: center; padding: 24px; color: #6b7280; font-size: 14px;">
<p style="margin: 0 0 8px 0;">EM2RP - Gestion d'événements</p>
<p style="margin: 0;">
<a href="https://app.em2event.fr/#/settings" style="color: #667eea; text-decoration: none;">
Gérer mes préférences de notification
</a>
</p>
</div>
</div>
</body>
</html>
`;
}
/**
* Formate un item d'alerte pour l'email
*/
function formatAlertItem(alert) {
const date = alert.createdAt?.toDate ?
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) :
'Date inconnue';
// Type d'alerte en français
const typeLabels = {
'EQUIPMENT_MISSING': 'Équipement manquant',
'LOST': 'Équipement perdu',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Écart de quantité',
'EVENT_CREATED': 'Événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'WORKFORCE_ADDED': 'Ajout à la workforce',
};
const typeLabel = typeLabels[alert.type] || alert.type;
return `
<div style="background-color: #f9fafb; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid ${getSeverityColor(alert.severity)};">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<strong style="color: #111827; font-size: 15px;">${typeLabel}</strong>
<span style="color: #6b7280; font-size: 13px;">${date}</span>
</div>
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
${alert.message || 'Aucun message'}
</p>
</div>
`;
}
/**
* Retourne la couleur selon la sévérité
*/
function getSeverityColor(severity) {
switch (severity) {
case 'CRITICAL': return '#dc2626';
case 'WARNING': return '#f59e0b';
case 'INFO': return '#3b82f6';
default: return '#6b7280';
}
}
module.exports = { sendDailyDigest };

View File

@@ -0,0 +1,107 @@
<div style="margin-bottom: 30px;">
<!-- En-tête du digest -->
<div style="margin-bottom: 25px;">
<h2 style="color: #111827; margin: 0 0 10px 0; font-size: 24px; font-weight: 600;">
📬 Votre résumé quotidien
</h2>
<p style="color: #6b7280; margin: 0; font-size: 14px;">
{{digestDate}} • {{alertCount}} nouvelle(s) alerte(s)
</p>
</div>
<!-- Message d'introduction -->
<p style="color: #374151; margin: 0 0 30px 0; font-size: 16px; line-height: 1.6;">
Bonjour <strong>{{userName}}</strong>,<br>
Voici le récapitulatif de vos alertes des dernières 24 heures.
</p>
<!-- Liste des alertes -->
{{#each alerts}}
<div style="background-color: #f9fafb; border-left: 4px solid {{#if this.isCritical}}#DC2626{{else}}#3B82F6{{/if}}; padding: 20px; margin-bottom: 15px; border-radius: 4px;">
<!-- Badge type -->
<div style="display: inline-block; padding: 4px 12px; border-radius: 12px; margin-bottom: 10px; background-color: {{#if this.isCritical}}#FEE2E2{{else}}#DBEAFE{{/if}}; color: {{#if this.isCritical}}#991B1B{{else}}#1E40AF{{/if}}; font-size: 11px; font-weight: 600; text-transform: uppercase;">
{{this.typeLabel}}
</div>
<!-- Titre de l'alerte -->
<h3 style="color: #111827; margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">
{{this.title}}
</h3>
<!-- Message -->
<p style="color: #4b5563; margin: 0 0 12px 0; font-size: 14px; line-height: 1.5;">
{{this.message}}
</p>
<!-- Contexte -->
{{#if this.context}}
<p style="color: #6b7280; margin: 0; font-size: 13px;">
<strong>Contexte :</strong> {{this.context}}
</p>
{{/if}}
<!-- Timestamp -->
<p style="color: #9ca3af; margin: 8px 0 0 0; font-size: 12px;">
🕐 {{this.timestamp}}
</p>
</div>
{{/each}}
<!-- Aucune alerte -->
{{#unless alerts}}
<div style="background-color: #f0fdf4; border: 1px solid #86efac; padding: 20px; margin-bottom: 20px; border-radius: 8px; text-align: center;">
<p style="color: #166534; margin: 0; font-size: 16px;">
<strong>Aucune alerte aujourd'hui</strong><br>
<span style="font-size: 14px; color: #15803d;">Tout est en ordre !</span>
</p>
</div>
{{/unless}}
<!-- Bouton d'action principal -->
<div style="text-align: center; margin-top: 30px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
<tr>
<td style="border-radius: 6px; background: #3B82F6;">
<a href="{{appUrl}}/alerts" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
Voir toutes mes alertes
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Statistiques -->
{{#if stats}}
<div style="margin-top: 30px; padding: 20px; background-color: #fef3c7; border-radius: 8px;">
<h3 style="color: #92400e; margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">
📊 Vos statistiques
</h3>
<table style="width: 100%;">
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
<strong>Alertes non lues :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
{{stats.unreadCount}}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
<strong>Événements en cours :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
{{stats.activeEvents}}
</td>
</tr>
</table>
</div>
{{/if}}
<!-- Note de bas de page -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
</p>
</div>

View File

@@ -0,0 +1,81 @@
<div style="margin-bottom: 30px;">
<!-- Badge de sévérité -->
<div style="display: inline-block; padding: 8px 16px; border-radius: 20px; margin-bottom: 20px; {{#if isCritical}}background-color: #FEE2E2; color: #991B1B;{{else}}background-color: #FEF3C7; color: #92400E;{{/if}}">
<strong style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
{{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}}
</strong>
</div>
<!-- Titre -->
<h2 style="color: #111827; margin: 0 0 20px 0; font-size: 24px; font-weight: 600;">
{{alertTitle}}
</h2>
<!-- Message -->
<p style="color: #374151; margin: 0 0 25px 0; font-size: 16px; line-height: 1.6;">
{{alertMessage}}
</p>
<!-- Détails de l'alerte -->
{{#if alertDetails}}
<div style="background-color: #f9fafb; border-left: 4px solid #3B82F6; padding: 16px; margin-bottom: 25px; border-radius: 4px;">
<p style="margin: 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Détails :</strong><br>
{{alertDetails}}
</p>
</div>
{{/if}}
<!-- Informations contextuelles -->
{{#if eventName}}
<table style="width: 100%; margin-bottom: 25px; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Événement :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
{{eventName}}
</td>
</tr>
{{#if eventDate}}
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Date :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
{{eventDate}}
</td>
</tr>
{{/if}}
{{#if equipmentName}}
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Équipement :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
{{equipmentName}}
</td>
</tr>
{{/if}}
</table>
{{/if}}
<!-- Bouton d'action -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius: 6px; {{#if isCritical}}background: #DC2626;{{else}}background: #3B82F6;{{/if}}">
<a href="{{actionUrl}}" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
{{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}}
</a>
</td>
</tr>
</table>
</div>
<!-- Note de bas de page -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
💡 <strong>Astuce :</strong> Vous pouvez gérer vos préférences de notifications dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
</p>
</div>

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{subject}}</title>
<style>
/* Reset styles */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
/* Responsive */
@media only screen and (max-width: 600px) {
.container { width: 100% !important; }
.content { padding: 20px !important; }
.button { width: 100% !important; display: block !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f3f4f6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f3f4f6;">
<tr>
<td align="center" style="padding: 40px 0;">
<!-- Container -->
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td align="center" style="background: linear-gradient(135deg, #1E3A8A 0%, #3B82F6 100%); padding: 30px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
EM2 Events
</h1>
<p style="color: #E0E7FF; margin: 8px 0 0 0; font-size: 14px;">
Gestion d'événements professionnelle
</p>
</td>
</tr>
<!-- Content -->
<tr>
<td class="content" style="padding: 40px 30px;">
{{{content}}}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 15px 0; font-size: 13px; color: #6b7280; text-align: center;">
Cet email a été envoyé automatiquement par EM2 Events
</p>
<p style="margin: 15px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
© {{year}} EM2 Events. Tous droits réservés.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,165 @@
/**
* Utilitaires d'authentification et d'autorisation
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
/**
* Vérifie le token Firebase et retourne l'utilisateur
*/
async function authenticateUser(req) {
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
throw new Error('Unauthorized: No token provided');
}
const idToken = req.headers.authorization.split('Bearer ')[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
return decodedToken;
} catch (e) {
logger.error("Error verifying Firebase ID token:", e);
throw new Error('Unauthorized: Invalid token');
}
}
/**
* Récupère les données utilisateur depuis Firestore
*/
async function getUserData(uid) {
const userDoc = await admin.firestore().collection('users').doc(uid).get();
if (!userDoc.exists) {
return null;
}
return { uid, ...userDoc.data() };
}
/**
* Récupère les permissions d'un rôle
*/
async function getRolePermissions(roleRef) {
if (!roleRef) return [];
let roleId;
if (typeof roleRef === 'string') {
roleId = roleRef;
} else if (roleRef.id) {
roleId = roleRef.id;
} else {
return [];
}
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
if (!roleDoc.exists) return [];
return roleDoc.data().permissions || [];
}
/**
* Vérifie si l'utilisateur a une permission spécifique
*/
async function hasPermission(uid, requiredPermission) {
const userData = await getUserData(uid);
if (!userData) return false;
const permissions = await getRolePermissions(userData.role);
return permissions.includes(requiredPermission);
}
/**
* Vérifie si l'utilisateur est admin
*/
async function isAdmin(uid) {
const userData = await getUserData(uid);
if (!userData) return false;
let roleId;
const roleField = userData.role;
if (typeof roleField === 'string') {
roleId = roleField;
} else if (roleField && roleField.id) {
roleId = roleField.id;
} else {
return false;
}
return roleId === 'ADMIN';
}
/**
* Vérifie si l'utilisateur est assigné à un événement
*/
async function isAssignedToEvent(uid, eventId) {
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
if (!eventDoc.exists) return false;
const eventData = eventDoc.data();
const workforce = eventData.workforce || [];
// workforce contient des références DocumentReference
return workforce.some(ref => {
if (typeof ref === 'string') return ref === uid;
if (ref && ref.id) return ref.id === uid;
return false;
});
}
/**
* Middleware d'authentification pour les Cloud Functions HTTP
*/
async function authMiddleware(req, res, next) {
try {
const decodedToken = await authenticateUser(req);
req.user = decodedToken;
req.uid = decodedToken.uid;
next();
} catch (error) {
res.status(401).json({ error: error.message });
}
}
/**
* Middleware de vérification de permission
*/
function requirePermission(permission) {
return async (req, res, next) => {
try {
const hasAccess = await hasPermission(req.uid, permission);
if (!hasAccess) {
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
}
};
}
/**
* Middleware admin uniquement
*/
async function requireAdmin(req, res, next) {
try {
const adminAccess = await isAdmin(req.uid);
if (!adminAccess) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
}
}
module.exports = {
authenticateUser,
getUserData,
getRolePermissions,
hasPermission,
isAdmin,
isAssignedToEvent,
authMiddleware,
requirePermission,
requireAdmin,
};

View File

@@ -0,0 +1,39 @@
/**
* Configuration SMTP pour l'envoi d'emails
* Les credentials sont stockés dans les variables d'environnement
*/
// Configuration SMTP depuis les variables d'environnement
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
const getSmtpConfig = () => {
return {
host: process.env.SMTP_HOST || 'mail.em2events.fr',
port: parseInt(process.env.SMTP_PORT || '465'),
secure: true, // true pour port 465, false pour autres ports
auth: {
user: process.env.SMTP_USER || 'notify@em2events.fr',
pass: process.env.SMTP_PASS || '',
},
tls: {
// Ne pas échouer sur certificats invalides
rejectUnauthorized: false,
},
};
};
// Configuration email par défaut
const EMAIL_CONFIG = {
from: {
name: 'EM2 Events',
address: 'notify@em2events.fr',
},
replyTo: 'contact@em2events.fr',
// URL de l'application pour les liens
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
};
module.exports = {
getSmtpConfig,
EMAIL_CONFIG,
};

View File

@@ -0,0 +1,177 @@
const admin = require('firebase-admin');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {EMAIL_CONFIG} = require('./emailConfig');
/**
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'DAMAGED': 'equipmentNotifications',
'QUANTITY_MISMATCH': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
};
const prefKey = typeMapping[alertType];
return prefKey ? (preferences[prefKey] !== false) : true;
}
/**
* Prépare les données pour le template
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
subject: getEmailSubject(alert),
};
// Ajouter des détails selon le type d'alerte
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
data.eventName = event.Name || event.name || 'Événement';
if (event.StartDateTime || event.startDate) {
const dateField = event.StartDateTime || event.startDate;
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
}
} catch (error) {
// Ignorer silencieusement
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.doc(alert.equipmentId)
.get();
if (eqDoc.exists) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
// Ignorer silencieusement
}
}
return data;
}
/**
* Génère le titre de l'email selon le type d'alerte
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'DAMAGED': '⚠️ Équipement endommagé',
'QUANTITY_MISMATCH': ' Quantité incorrecte',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
}
/**
* Génère le titre pour le corps de l'email
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Quantité incorrecte',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
};
return titles[type] || 'Nouvelle alerte';
}
/**
* Rend un template HTML avec Handlebars
*/
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'..',
'templates',
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
const compileBase = handlebars.compile(baseTemplate);
// Rendre le contenu
const renderedContent = compileContent(data);
// Rendre le template de base avec le contenu
return compileBase({
...data,
content: renderedContent,
});
} catch (error) {
// Fallback vers un template simple
return `
<html>
<body>
<h2>${data.alertTitle}</h2>
<p>${data.alertMessage}</p>
<a href="${data.actionUrl}">Voir l'alerte</a>
</body>
</html>
`;
}
}
module.exports = {
checkAlertPreference,
prepareTemplateData,
getEmailSubject,
getAlertTitle,
renderTemplate,
};

View File

@@ -0,0 +1,191 @@
/**
* Helpers pour la manipulation de données Firestore
*/
const admin = require('firebase-admin');
/**
* Convertit les Timestamps Firestore en ISO strings pour JSON
*/
function serializeTimestamps(data) {
if (!data) return data;
// Éviter la récursion sur les types Firestore spéciaux
if (data._firestore || data._path || data._converter) {
// C'est un objet Firestore interne, ne pas le traiter
if (data.id && data.path) {
// C'est une DocumentReference
return data.path;
}
return null;
}
const result = { ...data };
for (const key in result) {
const value = result[key];
if (!value) {
continue;
}
// Gérer les Timestamps Firestore
if (value.toDate && typeof value.toDate === 'function') {
result[key] = value.toDate().toISOString();
}
// Gérer les DocumentReference
else if (value.path && value.id && typeof value.path === 'string') {
result[key] = value.path;
}
// Gérer les GeoPoint
else if (value.latitude !== undefined && value.longitude !== undefined) {
result[key] = {
latitude: value.latitude,
longitude: value.longitude
};
}
// Gérer les tableaux
else if (Array.isArray(value)) {
result[key] = value.map(item => {
if (!item || typeof item !== 'object') return item;
// DocumentReference dans un tableau
if (item.path && item.id) {
return item.path;
}
// Timestamp dans un tableau
if (item.toDate && typeof item.toDate === 'function') {
return item.toDate().toISOString();
}
// Objet normal
return serializeTimestamps(item);
});
}
// Gérer les objets imbriqués (mais pas les objets Firestore)
else if (typeof value === 'object' && !value._firestore && !value._path) {
result[key] = serializeTimestamps(value);
}
}
return result;
}
/**
* Convertit les ISO strings en Timestamps Firestore
*/
function deserializeTimestamps(data, timestampFields = []) {
if (!data) return data;
const result = { ...data };
for (const field of timestampFields) {
if (result[field] && typeof result[field] === 'string') {
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
}
}
return result;
}
/**
* Convertit les références DocumentReference en IDs
*/
function serializeReferences(data) {
if (!data) return data;
const result = { ...data };
for (const key in result) {
if (result[key] && result[key].path && typeof result[key].path === 'string') {
// C'est une DocumentReference
result[key] = result[key].id;
} else if (Array.isArray(result[key])) {
result[key] = result[key].map(item => {
if (item && item.path && typeof item.path === 'string') {
return item.id;
}
return item;
});
}
}
return result;
}
/**
* Masque les champs sensibles selon les permissions
*/
function maskSensitiveFields(data, canViewSensitive) {
if (canViewSensitive) return data;
const masked = { ...data };
// Masquer les prix si pas de permission manage_equipment
delete masked.purchasePrice;
delete masked.rentalPrice;
return masked;
}
/**
* Pagination helper
*/
function paginate(query, limit = 50, startAfter = null) {
let paginatedQuery = query.limit(limit);
if (startAfter) {
paginatedQuery = paginatedQuery.startAfter(startAfter);
}
return paginatedQuery;
}
/**
* Filtre les événements annulés
*/
function filterCancelledEvents(events) {
return events.filter(event => event.status !== 'CANCELLED');
}
/**
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
* @param {Object} data - Données de l'événement
* @returns {Object} - Données avec DocumentReference
*/
function convertIdsToReferences(data) {
if (!data) return data;
const result = { ...data };
// Convertir EventType (ID → DocumentReference)
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
}
// Convertir customer (ID → DocumentReference)
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
result.customer = admin.firestore().collection('customers').doc(result.customer);
}
// Convertir workforce (IDs → DocumentReference)
if (Array.isArray(result.workforce)) {
result.workforce = result.workforce.map(item => {
if (typeof item === 'string' && !item.includes('/')) {
return admin.firestore().collection('users').doc(item);
}
return item;
});
}
return result;
}
module.exports = {
serializeTimestamps,
deserializeTimestamps,
serializeReferences,
maskSensitiveFields,
paginate,
filterCancelledEvents,
convertIdsToReferences,
};

View File

@@ -0,0 +1,14 @@
@echo off
REM Script Windows pour incrémenter uniquement la version
echo Incrémentation de la version...
node scripts\increment_version.js
if %ERRORLEVEL% EQU 0 (
echo Version incrémentée avec succès!
) else (
echo Erreur lors de l'incrémentation
)
pause

View File

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View File

@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,19 @@
/// Configuration de l'API backend
class ApiConfig {
// Mode développement : utilise les émulateurs locaux
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
// URL de base pour les Cloud Functions
static const String productionUrl = 'https://europe-west9-em2rp-951dc.cloudfunctions.net';
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/europe-west9';
/// Retourne l'URL de base selon l'environnement
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
/// Configuration du timeout
static const Duration requestTimeout = Duration(seconds: 30);
/// Nombre de tentatives en cas d'échec
static const int maxRetries = 3;
}

View File

@@ -0,0 +1,12 @@
/// Configuration de la version de l'application
class AppVersion {
static const String version = '1.1.14';
/// Retourne la version complète de l'application
static String get fullVersion => 'v$version';
/// Retourne la version avec un préfixe personnalisé
static String getVersionWithPrefix(String prefix) => '$prefix $version';
}

View File

@@ -3,8 +3,7 @@ class Env {
// Configuration de l'auto-login en développement
static const String devAdminEmail = 'paul.fournel@em2events.fr';
static const String devAdminPassword =
"Azerty\$1!"; // À remplacer par le vrai mot de passe
static const String devAdminPassword = 'Pastis51!';
// URLs et endpoints
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
@@ -15,3 +14,4 @@ class Env {
// Autres configurations
static const int apiTimeout = 30000; // 30 secondes
}

View File

@@ -0,0 +1,17 @@
class Env {
static const bool isDevelopment = true;
// Configuration de l'auto-login en développement
static const String devAdminEmail = 'paul.fournel@em2events.fr';
static const String devAdminPassword = 'Pastis51!';
// URLs et endpoints
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
// Configuration Firebase
static const String firebaseProjectId = 'em2rp-951dc';
// Autres configurations
static const int apiTimeout = 30000; // 30 secondes
}

View File

@@ -0,0 +1,483 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:file_picker/file_picker.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/services/event_form_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart';
class EventFormController extends ChangeNotifier {
// Controllers
final TextEditingController nameController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final TextEditingController basePriceController = TextEditingController();
final TextEditingController installationController = TextEditingController();
final TextEditingController disassemblyController = TextEditingController();
final TextEditingController addressController = TextEditingController();
final TextEditingController jaugeController = TextEditingController();
final TextEditingController contactEmailController = TextEditingController();
final TextEditingController contactPhoneController = TextEditingController();
// State variables
DateTime? _startDateTime;
DateTime? _endDateTime;
bool _isLoading = false;
String? _error;
String? _success;
String? _selectedEventTypeId;
List<EventTypeModel> _eventTypes = [];
bool _isLoadingEventTypes = true;
List<String> _selectedUserIds = [];
List<UserModel> _allUsers = [];
bool _isLoadingUsers = true;
List<Map<String, String>> _uploadedFiles = [];
List<Map<String, dynamic>> _selectedOptions = [];
bool _formChanged = false;
EventStatus _selectedStatus = EventStatus.waitingForApproval;
List<EventEquipment> _assignedEquipment = [];
List<String> _assignedContainers = [];
// Getters
DateTime? get startDateTime => _startDateTime;
DateTime? get endDateTime => _endDateTime;
bool get isLoading => _isLoading;
String? get error => _error;
String? get success => _success;
String? get selectedEventTypeId => _selectedEventTypeId;
List<EventTypeModel> get eventTypes => _eventTypes;
bool get isLoadingEventTypes => _isLoadingEventTypes;
List<String> get selectedUserIds => _selectedUserIds;
List<UserModel> get allUsers => _allUsers;
bool get isLoadingUsers => _isLoadingUsers;
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
List<EventEquipment> get assignedEquipment => _assignedEquipment;
List<String> get assignedContainers => _assignedContainers;
bool get formChanged => _formChanged;
EventStatus get selectedStatus => _selectedStatus;
EventFormController() {
_setupListeners();
}
void _setupListeners() {
nameController.addListener(_onAnyFieldChanged);
basePriceController.addListener(_onAnyFieldChanged);
installationController.addListener(_onAnyFieldChanged);
disassemblyController.addListener(_onAnyFieldChanged);
addressController.addListener(_onAnyFieldChanged);
descriptionController.addListener(_onAnyFieldChanged);
jaugeController.addListener(_onAnyFieldChanged);
contactEmailController.addListener(_onAnyFieldChanged);
contactPhoneController.addListener(_onAnyFieldChanged);
}
void _onAnyFieldChanged() {
if (!_formChanged) {
_formChanged = true;
notifyListeners();
}
}
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
await Future.wait([
_fetchUsers(),
_fetchEventTypes(),
]);
if (existingEvent != null) {
// 🔧 FIX: Recharger l'événement avec tous les détails (équipements + containers avec enfants)
try {
final dataService = DataService(FirebaseFunctionsApiService());
final result = await dataService.getEventWithDetails(existingEvent.id);
final eventData = result['event'] as Map<String, dynamic>;
// Reconstruire l'événement avec les données complètes
final completeEvent = EventModel.fromMap(eventData, eventData['id'] as String);
_populateFromEvent(completeEvent);
} catch (e) {
// Si erreur, utiliser l'événement existant (fallback)
print('[EventFormController] Error loading event with details, using existing: $e');
_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();
}
void _populateFromEvent(EventModel event) {
nameController.text = event.name;
descriptionController.text = event.description;
basePriceController.text = event.basePrice.toStringAsFixed(2);
installationController.text = event.installationTime.toString();
disassemblyController.text = event.disassemblyTime.toString();
addressController.text = event.address;
jaugeController.text = event.jauge?.toString() ?? '';
contactEmailController.text = event.contactEmail ?? '';
contactPhoneController.text = event.contactPhone ?? '';
_startDateTime = event.startDateTime;
_endDateTime = event.endDateTime;
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
_assignedContainers = List<String>.from(event.assignedContainers);
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
// Gérer workforce qui peut contenir String ou DocumentReference
_selectedUserIds = event.workforce.map((ref) {
if (ref is String) return ref;
if (ref is DocumentReference) return ref.id;
return '';
}).where((id) => id.isNotEmpty).toList();
_uploadedFiles = List<Map<String, String>>.from(event.documents);
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
_selectedStatus = event.status;
}
Future<void> _fetchUsers() async {
try {
_allUsers = await EventFormService.fetchUsers();
_isLoadingUsers = false;
} catch (e) {
_error = e.toString();
_isLoadingUsers = false;
}
notifyListeners();
}
Future<void> _fetchEventTypes() async {
try {
_eventTypes = await EventFormService.fetchEventTypes();
_isLoadingEventTypes = false;
} catch (e) {
_error = e.toString();
_isLoadingEventTypes = false;
}
notifyListeners();
}
void setStartDateTime(DateTime? dateTime) {
_startDateTime = dateTime;
if (_endDateTime != null &&
dateTime != null &&
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
_endDateTime = null;
}
_onAnyFieldChanged();
notifyListeners();
}
void setEndDateTime(DateTime? dateTime) {
_endDateTime = dateTime;
_onAnyFieldChanged();
notifyListeners();
}
void onEventTypeChanged(String? newTypeId, BuildContext context) {
if (newTypeId == _selectedEventTypeId) return;
final oldEventTypeIndex = _selectedEventTypeId != null
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
: -1;
final EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
_selectedEventTypeId = newTypeId;
if (newTypeId != null) {
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
final defaultPriceTTC = selectedType.defaultPrice;
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
final oldDefaultPrice = oldEventType?.defaultPrice;
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
if (basePriceController.text.isEmpty ||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
}
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
final before = _selectedOptions.length;
_selectedOptions.removeWhere((opt) {
// Vérifier si cette option est compatible avec le type d'événement sélectionné
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
return !optionEventTypes.contains(selectedType.id);
});
if (_selectedOptions.length < before) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
);
}
} else {
_selectedOptions.clear();
}
_onAnyFieldChanged();
notifyListeners();
}
void setSelectedUserIds(List<String> userIds) {
_selectedUserIds = userIds;
_onAnyFieldChanged();
notifyListeners();
}
void setUploadedFiles(List<Map<String, String>> files) {
_uploadedFiles = files;
_onAnyFieldChanged();
notifyListeners();
}
void setSelectedOptions(List<Map<String, dynamic>> options) {
_selectedOptions = options;
_onAnyFieldChanged();
notifyListeners();
}
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
_assignedEquipment = equipment;
_assignedContainers = containers;
_onAnyFieldChanged();
notifyListeners();
}
Future<void> pickAndUploadFiles() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
if (result != null && result.files.isNotEmpty) {
_isLoading = true;
_error = null;
notifyListeners();
try {
final files = await EventFormService.uploadFiles(result.files);
_uploadedFiles.addAll(files);
_onAnyFieldChanged();
} catch (e) {
_error = 'Erreur lors de l\'upload : $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
}
bool validateForm() {
return nameController.text.isNotEmpty &&
_startDateTime != null &&
_endDateTime != null &&
_selectedEventTypeId != null &&
addressController.text.isNotEmpty &&
(_endDateTime!.isAfter(_startDateTime!));
}
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
if (!validateForm()) {
_error = "Veuillez remplir tous les champs obligatoires.";
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
_success = null;
notifyListeners();
try {
final eventTypeRef = _selectedEventTypeId != null
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
: null;
if (existingEvent != null) {
// Mode édition
// Gérer les nouveaux fichiers uploadés s'il y en a
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
final newFiles = _uploadedFiles.where((file) =>
file['url']?.contains('events/temp/') ?? false).toList();
if (newFiles.isNotEmpty) {
// Déplacer les nouveaux fichiers vers le dossier de l'événement
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
// Remplacer les URLs temporaires par les nouvelles URLs
for (int i = 0; i < finalDocuments.length; i++) {
final tempFile = finalDocuments[i];
final movedFile = movedFiles.firstWhere(
(moved) => moved['name'] == tempFile['name'],
orElse: () => tempFile,
);
finalDocuments[i] = movedFile;
}
}
final updatedEvent = EventModel(
id: existingEvent.id,
name: nameController.text.trim(),
description: descriptionController.text.trim(),
startDateTime: _startDateTime!,
endDateTime: _endDateTime!,
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
installationTime: int.tryParse(installationController.text) ?? 0,
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
eventTypeId: _selectedEventTypeId!,
eventTypeRef: eventTypeRef,
customerId: existingEvent.customerId,
address: addressController.text.trim(),
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
workforce: _selectedUserIds,
latitude: existingEvent.latitude,
longitude: existingEvent.longitude,
documents: finalDocuments,
options: _selectedOptions,
status: _selectedStatus,
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
assignedEquipment: _assignedEquipment,
assignedContainers: _assignedContainers,
preparationStatus: existingEvent.preparationStatus,
returnStatus: existingEvent.returnStatus,
);
await EventFormService.updateEvent(updatedEvent);
// Mettre à jour l'événement dans le cache (au lieu de tout recharger)
final eventProvider = Provider.of<EventProvider>(context, listen: false);
await eventProvider.updateEvent(updatedEvent);
_success = "Événement modifié avec succès !";
} else {
// Mode création
final newEvent = EventModel(
id: '',
name: nameController.text.trim(),
description: descriptionController.text.trim(),
startDateTime: _startDateTime!,
endDateTime: _endDateTime!,
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
installationTime: int.tryParse(installationController.text) ?? 0,
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
eventTypeId: _selectedEventTypeId!,
eventTypeRef: eventTypeRef,
customerId: '',
address: addressController.text.trim(),
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
workforce: _selectedUserIds,
latitude: 0.0,
longitude: 0.0,
documents: _uploadedFiles,
options: _selectedOptions,
status: _selectedStatus,
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
assignedContainers: _assignedContainers,
assignedEquipment: _assignedEquipment,
);
final eventId = await EventFormService.createEvent(newEvent);
// Créer l'événement avec l'ID retourné
EventModel createdEvent = newEvent.copyWith(id: eventId);
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
if (_uploadedFiles.isNotEmpty) {
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
if (newFiles.isNotEmpty) {
await EventFormService.updateEventDocuments(eventId, newFiles);
// Mettre à jour l'événement avec les nouvelles URLs
createdEvent = createdEvent.copyWith(documents: newFiles);
}
}
// Ajouter l'événement au cache
final eventProvider = Provider.of<EventProvider>(context, listen: false);
await eventProvider.addEvent(createdEvent);
_success = "Événement créé avec succès !";
}
_formChanged = false;
notifyListeners();
return true;
} catch (e) {
_error = "Erreur lors de la sauvegarde : $e";
notifyListeners();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<bool> deleteEvent(BuildContext context, String eventId) async {
_isLoading = true;
_error = null;
_success = null;
notifyListeners();
try {
// Supprimer l'événement via le provider (qui appelle l'API et met à jour le cache)
final eventProvider = Provider.of<EventProvider>(context, listen: false);
await eventProvider.deleteEvent(eventId);
_success = "Événement supprimé avec succès !";
notifyListeners();
return true;
} catch (e) {
_error = "Erreur lors de la suppression : $e";
notifyListeners();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
void clearError() {
_error = null;
notifyListeners();
}
void clearSuccess() {
_success = null;
notifyListeners();
}
@override
void dispose() {
nameController.dispose();
descriptionController.dispose();
basePriceController.dispose();
installationController.dispose();
disassemblyController.dispose();
addressController.dispose();
jaugeController.dispose();
contactEmailController.dispose();
contactPhoneController.dispose();
super.dispose();
}
}

View File

@@ -1,9 +1,24 @@
import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/maintenance_provider.dart';
import 'package:em2rp/providers/alert_provider.dart';
import 'package:em2rp/utils/auth_guard_widget.dart';
import 'package:em2rp/utils/performance_monitor.dart';
import 'package:em2rp/views/alerts_page.dart';
import 'package:em2rp/views/calendar_page.dart';
import 'package:em2rp/views/login_page.dart';
import 'package:em2rp/views/equipment_management_page.dart';
import 'package:em2rp/views/container_management_page.dart';
import 'package:em2rp/views/maintenance_management_page.dart';
import 'package:em2rp/views/container_form_page.dart';
import 'package:em2rp/views/container_detail_page.dart';
import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
@@ -12,37 +27,69 @@ import 'views/my_account_page.dart';
import 'views/user_management_page.dart';
import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart';
import 'services/user_service.dart';
import 'views/reset_password_page.dart';
import 'config/env.dart';
import 'services/update_service.dart';
import 'views/widgets/common/update_dialog.dart';
import 'config/api_config.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'views/widgets/common/update_dialog.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configuration des émulateurs en mode développement
if (ApiConfig.isDevelopment) {
print('🔧 Mode développement activé - Utilisation des émulateurs');
// Configurer l'émulateur Auth
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
print('✓ Auth émulateur configuré: localhost:9199');
// Configurer l'émulateur Firestore
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
print('✓ Firestore émulateur configuré: localhost:8088');
}
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
runApp(
MultiProvider(
providers: [
// Injection du service UserService
Provider<UserService>(create: (_) => UserService()),
// LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()),
// Injection des Providers en utilisant UserService
// UsersProvider migré vers l'API
ChangeNotifierProvider<UsersProvider>(
create: (context) => UsersProvider(context.read<UserService>()),
create: (context) => UsersProvider(),
),
// EventProvider pour la gestion des événements
// EventProvider migré vers l'API
ChangeNotifierProvider<EventProvider>(
create: (context) => EventProvider(),
),
// EquipmentProvider migré vers l'API
ChangeNotifierProvider<EquipmentProvider>(
create: (context) => EquipmentProvider(),
),
// ContainerProvider migré vers l'API
ChangeNotifierProvider<ContainerProvider>(
create: (context) => ContainerProvider(),
),
// MaintenanceProvider migré vers l'API
ChangeNotifierProvider<MaintenanceProvider>(
create: (context) => MaintenanceProvider(),
),
ChangeNotifierProvider<AlertProvider>(
create: (context) => AlertProvider(),
),
],
child: const MyApp(),
),
@@ -54,26 +101,25 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("test");
return MaterialApp(
title: 'EM2 ERP',
title: 'EM2 Hub',
theme: ThemeData(
primarySwatch: Colors.red,
primaryColor: AppColors.noir,
colorScheme:
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
textTheme: const TextTheme(
bodyMedium: TextStyle(color: AppColors.noir),
primarySwatch: Colors.red,
primaryColor: AppColors.noir,
colorScheme:
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
textTheme: const TextTheme(
bodyMedium: TextStyle(color: AppColors.noir),
),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.noir),
),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.noir),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.gris),
),
labelStyle: TextStyle(color: AppColors.noir),
hintStyle: TextStyle(color: AppColors.gris),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.gris),
),
labelStyle: TextStyle(color: AppColors.noir),
hintStyle: TextStyle(color: AppColors.gris),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
@@ -91,9 +137,11 @@ class MyApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: const AutoLoginWrapper(),
initialRoute: '/',
routes: {
'/': (context) => const AutoLoginWrapper(),
'/login': (context) => const LoginPage(),
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/user_management': (context) => const AuthGuard(
@@ -106,6 +154,40 @@ class MyApp extends StatelessWidget {
actionCode: args['actionCode'] as String,
);
},
'/equipment_management': (context) => const AuthGuard(
requiredPermission: "view_equipment",
child: EquipmentManagementPage()),
'/container_management': (context) => const AuthGuard(
requiredPermission: "view_equipment",
child: ContainerManagementPage()),
'/maintenance_management': (context) => const AuthGuard(
requiredPermission: "manage_maintenances",
child: MaintenanceManagementPage()),
'/container_form': (context) {
final args = ModalRoute.of(context)?.settings.arguments;
return AuthGuard(
requiredPermission: "manage_equipment",
child: ContainerFormPage(
container: args as ContainerModel?,
),
);
},
'/container_detail': (context) {
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
return AuthGuard(
requiredPermission: "view_equipment",
child: ContainerDetailPage(container: container),
);
},
'/event_preparation': (context) {
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final event = args['event'] as EventModel;
return AuthGuard(
child: EventPreparationPage(
initialEvent: event,
),
);
},
},
);
}
@@ -122,31 +204,87 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
void initState() {
super.initState();
_autoLogin();
// Attendre la fin du premier build avant de naviguer
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoLogin();
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
_checkForUpdateDelayed();
});
}
/// Vérifie les mises à jour après un délai
Future<void> _checkForUpdateDelayed() async {
try {
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
final updateInfo = await UpdateService.checkForUpdate();
if (updateInfo != null && mounted) {
// Attendre encore un peu pour être sûr que le bon contexte est disponible
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
showDialog(
context: context,
barrierDismissible: !updateInfo.forceUpdate,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
}
} catch (e) {
print('[AutoLoginWrapper] Error checking for update: $e');
}
}
Future<void> _autoLogin() async {
PerformanceMonitor.start('App.autoLogin');
try {
final localAuthProvider =
Provider.of<LocalUserProvider>(context, listen: false);
// Vérifier si l'utilisateur est déjà connecté
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
PerformanceMonitor.start('App.signIn');
// Connexion automatique en mode développement
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
PerformanceMonitor.end('App.signIn');
}
// Charger les données utilisateur
await localAuthProvider.loadUserData();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/calendar');
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
// En Flutter Web, on peut vérifier window.location.hash
final currentUri = Uri.base;
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
print('[AutoLoginWrapper] Fragment URL: $fragment');
// Navigation immédiate sans attendre le chargement des données
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
print('[AutoLoginWrapper] Redirection vers: $fragment');
Navigator.of(context).pushReplacementNamed(fragment);
} else {
// Route par défaut : calendrier
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
Navigator.of(context).pushReplacementNamed('/calendar');
}
PerformanceMonitor.end('App.autoLogin');
PerformanceMonitor.printSummary();
// Charger les données utilisateur en arrière-plan
localAuthProvider.loadUserData().catchError((e) {
print('Error loading user data: $e');
});
}
} catch (e) {
print('Auto login failed: $e');
PerformanceMonitor.end('App.autoLogin');
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
@@ -155,9 +293,41 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
Widget build(BuildContext context) {
return const Scaffold(
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo de l'application
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 200,
height: 200,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 80,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 40),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 20),
const Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
),
],
),
),
);
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
/// Mixin réutilisable pour gérer le mode sélection multiple
/// Utilisable dans equipment_management_page, container_management_page, etc.
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
// État du mode sélection
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
// Getters
bool get isSelectionMode => _isSelectionMode;
Set<String> get selectedIds => _selectedIds;
int get selectedCount => _selectedIds.length;
bool get hasSelection => _selectedIds.isNotEmpty;
/// Active/désactive le mode sélection
void toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedIds.clear();
}
});
}
/// Active le mode sélection
void enableSelectionMode() {
if (!_isSelectionMode) {
setState(() {
_isSelectionMode = true;
});
}
}
/// Désactive le mode sélection et efface la sélection
void disableSelectionMode() {
if (_isSelectionMode) {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
}
/// Toggle la sélection d'un item
void toggleItemSelection(String id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
/// Sélectionne un item
void selectItem(String id) {
setState(() {
_selectedIds.add(id);
});
}
/// Désélectionne un item
void deselectItem(String id) {
setState(() {
_selectedIds.remove(id);
});
}
/// Vérifie si un item est sélectionné
bool isItemSelected(String id) {
return _selectedIds.contains(id);
}
/// Sélectionne tous les items
void selectAll(List<String> ids) {
setState(() {
_selectedIds.addAll(ids);
});
}
/// Efface la sélection
void clearSelection() {
setState(() {
_selectedIds.clear();
});
}
/// Sélectionne/désélectionne tous les items
void toggleSelectAll(List<String> ids) {
setState(() {
if (_selectedIds.length == ids.length) {
// Tout est sélectionné, on désélectionne tout
_selectedIds.clear();
} else {
// Sélectionner tout
_selectedIds.addAll(ids);
}
});
}
/// Widget pour afficher le nombre d'éléments sélectionnés
Widget buildSelectionCounter({
required Color backgroundColor,
required Color textColor,
String? customText,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
child: Text(
customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
),
);
}
/// AppBar pour le mode sélection
PreferredSizeWidget buildSelectionAppBar({
required String title,
required List<Widget> actions,
Color? backgroundColor,
}) {
return AppBar(
backgroundColor: backgroundColor,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: disableSelectionMode,
),
title: Text(
'$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}',
style: const TextStyle(color: Colors.white),
),
actions: actions,
);
}
}

View File

@@ -0,0 +1,273 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Type d'alerte
enum AlertType {
lowStock, // Stock faible
maintenanceDue, // Maintenance à venir
conflict, // Conflit disponibilité
lost, // Équipement perdu
eventCreated, // Événement créé
eventModified, // Événement modifié
eventCancelled, // Événement annulé
eventAssigned, // Assigné à un événement
maintenanceReminder, // Rappel maintenance périodique
equipmentMissing, // Équipement manquant à une étape
quantityMismatch, // Quantité incorrecte
damaged, // Équipement endommagé
workforceAdded, // Ajouté à la workforce d'un événement
}
/// Gravité de l'alerte
enum AlertSeverity {
info, // Information (bleu)
warning, // Avertissement (orange)
critical, // Critique (rouge)
}
String alertTypeToString(AlertType type) {
switch (type) {
case AlertType.lowStock:
return 'LOW_STOCK';
case AlertType.maintenanceDue:
return 'MAINTENANCE_DUE';
case AlertType.conflict:
return 'CONFLICT';
case AlertType.lost:
return 'LOST';
case AlertType.eventCreated:
return 'EVENT_CREATED';
case AlertType.eventModified:
return 'EVENT_MODIFIED';
case AlertType.eventCancelled:
return 'EVENT_CANCELLED';
case AlertType.eventAssigned:
return 'EVENT_ASSIGNED';
case AlertType.maintenanceReminder:
return 'MAINTENANCE_REMINDER';
case AlertType.equipmentMissing:
return 'EQUIPMENT_MISSING';
case AlertType.quantityMismatch:
return 'QUANTITY_MISMATCH';
case AlertType.damaged:
return 'DAMAGED';
case AlertType.workforceAdded:
return 'WORKFORCE_ADDED';
}
}
AlertType alertTypeFromString(String? type) {
switch (type) {
case 'LOW_STOCK':
return AlertType.lowStock;
case 'MAINTENANCE_DUE':
return AlertType.maintenanceDue;
case 'CONFLICT':
return AlertType.conflict;
case 'LOST':
return AlertType.lost;
case 'EVENT_CREATED':
return AlertType.eventCreated;
case 'EVENT_MODIFIED':
return AlertType.eventModified;
case 'EVENT_CANCELLED':
return AlertType.eventCancelled;
case 'EVENT_ASSIGNED':
return AlertType.eventAssigned;
case 'MAINTENANCE_REMINDER':
return AlertType.maintenanceReminder;
case 'EQUIPMENT_MISSING':
return AlertType.equipmentMissing;
case 'QUANTITY_MISMATCH':
return AlertType.quantityMismatch;
case 'DAMAGED':
return AlertType.damaged;
case 'WORKFORCE_ADDED':
return AlertType.workforceAdded;
default:
return AlertType.conflict;
}
}
String alertSeverityToString(AlertSeverity severity) {
switch (severity) {
case AlertSeverity.info:
return 'INFO';
case AlertSeverity.warning:
return 'WARNING';
case AlertSeverity.critical:
return 'CRITICAL';
}
}
AlertSeverity alertSeverityFromString(String? severity) {
switch (severity) {
case 'INFO':
return AlertSeverity.info;
case 'WARNING':
return AlertSeverity.warning;
case 'CRITICAL':
return AlertSeverity.critical;
default:
return AlertSeverity.info;
}
}
class AlertModel {
final String id; // ID généré automatiquement
final AlertType type; // Type d'alerte
final AlertSeverity severity; // Gravité de l'alerte
final String message; // Message de l'alerte
final List<String> assignedToUserIds; // Utilisateurs concernés
final String? eventId; // ID de l'événement concerné (optionnel)
final String? equipmentId; // ID de l'équipement concerné (optionnel)
final String? createdByUserId; // Qui a déclenché l'alerte
final DateTime createdAt; // Date de création
final DateTime? dueDate; // Date d'échéance (pour maintenance)
final String? actionUrl; // URL de redirection (deep link)
final bool isRead; // Statut lu/non lu
final bool isResolved; // Résolue ou non
final String? resolution; // Message de résolution
final DateTime? resolvedAt; // Date de résolution
final String? resolvedByUserId; // Qui a résolu
AlertModel({
required this.id,
required this.type,
this.severity = AlertSeverity.info,
required this.message,
this.assignedToUserIds = const [],
this.eventId,
this.equipmentId,
this.createdByUserId,
required this.createdAt,
this.dueDate,
this.actionUrl,
this.isRead = false,
this.isResolved = false,
this.resolution,
this.resolvedAt,
this.resolvedByUserId,
});
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value) {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
// Parser les assignedToUserIds (peut être List ou null)
List<String> parseUserIds(dynamic value) {
if (value == null) return [];
if (value is List) return value.map((e) => e.toString()).toList();
return [];
}
return AlertModel(
id: id,
type: alertTypeFromString(map['type']),
severity: alertSeverityFromString(map['severity']),
message: map['message'] ?? '',
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
eventId: map['eventId'],
equipmentId: map['equipmentId'],
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
createdAt: _parseDate(map['createdAt']),
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
actionUrl: map['actionUrl'],
isRead: map['isRead'] ?? false,
isResolved: map['isResolved'] ?? false,
resolution: map['resolution'],
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
resolvedByUserId: map['resolvedByUserId'],
);
}
/// Factory depuis un document Firestore
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>?;
if (data == null) {
throw Exception('Document vide: ${doc.id}');
}
return AlertModel.fromMap(data, doc.id);
}
Map<String, dynamic> toMap() {
return {
'type': alertTypeToString(type),
'severity': alertSeverityToString(severity),
'message': message,
'assignedToUserIds': assignedToUserIds,
if (eventId != null) 'eventId': eventId,
if (equipmentId != null) 'equipmentId': equipmentId,
if (createdByUserId != null) 'createdByUserId': createdByUserId,
'createdAt': Timestamp.fromDate(createdAt),
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
if (actionUrl != null) 'actionUrl': actionUrl,
'isRead': isRead,
'isResolved': isResolved,
if (resolution != null) 'resolution': resolution,
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
};
}
AlertModel copyWith({
String? id,
AlertType? type,
AlertSeverity? severity,
String? message,
List<String>? assignedToUserIds,
String? eventId,
String? equipmentId,
String? createdByUserId,
DateTime? createdAt,
DateTime? dueDate,
String? actionUrl,
bool? isRead,
bool? isResolved,
String? resolution,
DateTime? resolvedAt,
String? resolvedByUserId,
}) {
return AlertModel(
id: id ?? this.id,
type: type ?? this.type,
severity: severity ?? this.severity,
message: message ?? this.message,
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
eventId: eventId ?? this.eventId,
equipmentId: equipmentId ?? this.equipmentId,
createdByUserId: createdByUserId ?? this.createdByUserId,
createdAt: createdAt ?? this.createdAt,
dueDate: dueDate ?? this.dueDate,
actionUrl: actionUrl ?? this.actionUrl,
isRead: isRead ?? this.isRead,
isResolved: isResolved ?? this.isResolved,
resolution: resolution ?? this.resolution,
resolvedAt: resolvedAt ?? this.resolvedAt,
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
);
}
/// Helper : Retourne true si l'alerte est pour un événement
bool get isEventAlert =>
type == AlertType.eventCreated ||
type == AlertType.eventModified ||
type == AlertType.eventCancelled ||
type == AlertType.eventAssigned;
/// Helper : Retourne true si l'alerte est pour la maintenance
bool get isMaintenanceAlert =>
type == AlertType.maintenanceDue ||
type == AlertType.maintenanceReminder;
/// Helper : Retourne true si l'alerte est pour un équipement
bool get isEquipmentAlert =>
type == AlertType.lost ||
type == AlertType.equipmentMissing ||
type == AlertType.lowStock;
}

View File

@@ -0,0 +1,382 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Type de container
enum ContainerType {
flightCase, // Flight case
pelicase, // Pelicase
bag, // Sac
openCrate, // Caisse ouverte
toolbox, // Boîte à outils
}
String containerTypeToString(ContainerType type) {
switch (type) {
case ContainerType.flightCase:
return 'FLIGHT_CASE';
case ContainerType.pelicase:
return 'PELICASE';
case ContainerType.bag:
return 'BAG';
case ContainerType.openCrate:
return 'OPEN_CRATE';
case ContainerType.toolbox:
return 'TOOLBOX';
}
}
ContainerType containerTypeFromString(String? type) {
switch (type) {
case 'FLIGHT_CASE':
return ContainerType.flightCase;
case 'PELICASE':
return ContainerType.pelicase;
case 'BAG':
return ContainerType.bag;
case 'OPEN_CRATE':
return ContainerType.openCrate;
case 'TOOLBOX':
return ContainerType.toolbox;
default:
return ContainerType.flightCase;
}
}
String containerTypeLabel(ContainerType type) {
switch (type) {
case ContainerType.flightCase:
return 'Flight Case';
case ContainerType.pelicase:
return 'Pelicase';
case ContainerType.bag:
return 'Sac';
case ContainerType.openCrate:
return 'Caisse Ouverte';
case ContainerType.toolbox:
return 'Boîte à Outils';
}
}
// Extensions pour centraliser les informations d'affichage
extension ContainerTypeExtension on ContainerType {
/// Retourne le label français du type de container
String get label {
switch (this) {
case ContainerType.flightCase:
return 'Flight Case';
case ContainerType.pelicase:
return 'Pelicase';
case ContainerType.bag:
return 'Sac';
case ContainerType.openCrate:
return 'Caisse Ouverte';
case ContainerType.toolbox:
return 'Boîte à Outils';
}
}
/// Retourne l'icône Material du type de container
IconData get iconData {
switch (this) {
case ContainerType.flightCase:
return Icons.work;
case ContainerType.pelicase:
return Icons.work_outline;
case ContainerType.bag:
return Icons.shopping_bag;
case ContainerType.openCrate:
return Icons.inventory_2;
case ContainerType.toolbox:
return Icons.home_repair_service;
}
}
/// Retourne le chemin de l'icône personnalisée (si disponible)
String? get customIconPath {
switch (this) {
case ContainerType.flightCase:
return 'assets/icons/flight-case.svg';
default:
return null;
}
}
/// Vérifie si une icône personnalisée est disponible
bool get hasCustomIcon => customIconPath != null;
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
Widget getIcon({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
// Détection automatique du format (SVG ou PNG)
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
// SVG : on peut appliquer la couleur sans dégrader la qualité
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
// PNG : on n'applique PAS le color filter pour préserver la qualité
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
/// Version pour CircleAvatar et contextes similaires
Widget getIconForAvatar({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
}
/// Modèle de container/boîte pour le matériel
class ContainerModel {
final String id; // Identifiant unique (généré comme pour équipement)
final String name; // Nom du container
final ContainerType type; // Type de container
final EquipmentStatus status; // Statut actuel (même que équipement)
// Caractéristiques physiques
final double? weight; // Poids à vide (kg)
final double? length; // Longueur (cm)
final double? width; // Largeur (cm)
final double? height; // Hauteur (cm)
// Contenu
final List<String> equipmentIds; // IDs des équipements contenus
// Événement
final String? eventId; // ID de l'événement actuel (si en prestation)
// Métadonnées
final String? notes; // Notes additionnelles
final DateTime createdAt; // Date de création
final DateTime updatedAt; // Date de mise à jour
// Historique simple (optionnel)
final List<ContainerHistoryEntry> history; // Historique des modifications
ContainerModel({
required this.id,
required this.name,
required this.type,
this.status = EquipmentStatus.available,
this.weight,
this.length,
this.width,
this.height,
this.equipmentIds = const [],
this.eventId,
this.notes,
required this.createdAt,
required this.updatedAt,
this.history = const [],
});
/// Vérifier si le container est vide
bool get isEmpty => equipmentIds.isEmpty;
/// Nombre d'équipements dans le container
int get itemCount => equipmentIds.length;
/// Calculer le volume (m³)
double? get volume {
if (length == null || width == null || height == null) return null;
return (length! * width! * height!) / 1000000; // cm³ to m³
}
/// Calculer le poids total (poids vide + équipements)
/// Nécessite la liste des équipements
double calculateTotalWeight(List<EquipmentModel> equipment) {
double total = weight ?? 0.0;
for (final eq in equipment) {
if (equipmentIds.contains(eq.id) && eq.weight != null) {
total += eq.weight!;
}
}
return total;
}
/// Factory depuis Firestore
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
return null;
}
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
final List<dynamic> historyRaw = map['history'] ?? [];
final List<ContainerHistoryEntry> history = historyRaw
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
.toList();
return ContainerModel(
id: id,
name: map['name'] ?? '',
type: containerTypeFromString(map['type']),
status: equipmentStatusFromString(map['status']),
weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(),
width: map['width']?.toDouble(),
height: map['height']?.toDouble(),
equipmentIds: equipmentIds,
eventId: map['eventId'],
notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
history: history,
);
}
/// Convertir en Map pour Firestore
Map<String, dynamic> toMap() {
return {
'name': name,
'type': containerTypeToString(type),
'status': equipmentStatusToString(status),
'weight': weight,
'length': length,
'width': width,
'height': height,
'equipmentIds': equipmentIds,
'eventId': eventId,
'notes': notes,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'history': history.map((e) => e.toMap()).toList(),
};
}
/// Copier avec modifications
ContainerModel copyWith({
String? id,
String? name,
ContainerType? type,
EquipmentStatus? status,
double? weight,
double? length,
double? width,
double? height,
List<String>? equipmentIds,
String? eventId,
String? notes,
DateTime? createdAt,
DateTime? updatedAt,
List<ContainerHistoryEntry>? history,
}) {
return ContainerModel(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
status: status ?? this.status,
weight: weight ?? this.weight,
length: length ?? this.length,
width: width ?? this.width,
height: height ?? this.height,
equipmentIds: equipmentIds ?? this.equipmentIds,
eventId: eventId ?? this.eventId,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
history: history ?? this.history,
);
}
}
/// Entrée d'historique pour un container
class ContainerHistoryEntry {
final DateTime timestamp;
final String action; // 'added', 'removed', 'status_change', etc.
final String? equipmentId; // ID de l'équipement concerné (si applicable)
final String? previousValue; // Valeur précédente
final String? newValue; // Nouvelle valeur
final String? userId; // ID de l'utilisateur ayant fait la modification
ContainerHistoryEntry({
required this.timestamp,
required this.action,
this.equipmentId,
this.previousValue,
this.newValue,
this.userId,
});
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
// Helper pour parser la date
DateTime _parseDate(dynamic value) {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
return ContainerHistoryEntry(
timestamp: _parseDate(map['timestamp']),
action: map['action'] ?? '',
equipmentId: map['equipmentId'],
previousValue: map['previousValue'],
newValue: map['newValue'],
userId: map['userId'],
);
}
Map<String, dynamic> toMap() {
return {
'timestamp': Timestamp.fromDate(timestamp),
'action': action,
'equipmentId': equipmentId,
'previousValue': previousValue,
'newValue': newValue,
'userId': userId,
};
}
}

View File

@@ -0,0 +1,528 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
enum EquipmentStatus {
available, // Disponible
inUse, // En prestation
rented, // Loué
lost, // Perdu
outOfService, // HS
maintenance, // En maintenance
}
String equipmentStatusToString(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return 'AVAILABLE';
case EquipmentStatus.inUse:
return 'IN_USE';
case EquipmentStatus.rented:
return 'RENTED';
case EquipmentStatus.lost:
return 'LOST';
case EquipmentStatus.outOfService:
return 'OUT_OF_SERVICE';
case EquipmentStatus.maintenance:
return 'MAINTENANCE';
}
}
EquipmentStatus equipmentStatusFromString(String? status) {
switch (status) {
case 'AVAILABLE':
return EquipmentStatus.available;
case 'IN_USE':
return EquipmentStatus.inUse;
case 'RENTED':
return EquipmentStatus.rented;
case 'LOST':
return EquipmentStatus.lost;
case 'OUT_OF_SERVICE':
return EquipmentStatus.outOfService;
case 'MAINTENANCE':
return EquipmentStatus.maintenance;
default:
return EquipmentStatus.available;
}
}
enum EquipmentCategory {
lighting, // Lumière
sound, // Son
video, // Vidéo
effect, // Effets spéciaux
structure, // Structure
consumable, // Consommable
cable, // Câble
vehicle, // Véhicule
backline, // Régie / Backline
other // Autre
}
String equipmentCategoryToString(EquipmentCategory category) {
switch (category) {
case EquipmentCategory.lighting:
return 'LIGHTING';
case EquipmentCategory.sound:
return 'SOUND';
case EquipmentCategory.video:
return 'VIDEO';
case EquipmentCategory.structure:
return 'STRUCTURE';
case EquipmentCategory.consumable:
return 'CONSUMABLE';
case EquipmentCategory.cable:
return 'CABLE';
case EquipmentCategory.vehicle:
return 'VEHICLE';
case EquipmentCategory.backline:
return 'BACKLINE';
case EquipmentCategory.other:
return 'OTHER';
case EquipmentCategory.effect:
return 'EFFECT';
}
}
EquipmentCategory equipmentCategoryFromString(String? category) {
switch (category) {
case 'LIGHTING':
return EquipmentCategory.lighting;
case 'SOUND':
return EquipmentCategory.sound;
case 'VIDEO':
return EquipmentCategory.video;
case 'STRUCTURE':
return EquipmentCategory.structure;
case 'CONSUMABLE':
return EquipmentCategory.consumable;
case 'CABLE':
return EquipmentCategory.cable;
case 'VEHICLE':
return EquipmentCategory.vehicle;
case 'BACKLINE':
return EquipmentCategory.backline;
case 'EFFECT':
return EquipmentCategory.effect;
case 'OTHER':
default:
return EquipmentCategory.other;
}
}
// Extensions pour centraliser les informations d'affichage
extension EquipmentCategoryExtension on EquipmentCategory {
/// Retourne le label français de la catégorie
String get label {
switch (this) {
case EquipmentCategory.lighting:
return 'Lumière';
case EquipmentCategory.sound:
return 'Son';
case EquipmentCategory.video:
return 'Vidéo';
case EquipmentCategory.effect:
return 'Effets';
case EquipmentCategory.structure:
return 'Structure';
case EquipmentCategory.consumable:
return 'Consommable';
case EquipmentCategory.cable:
return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other:
return 'Autre';
}
}
/// Retourne l'icône Material de la catégorie
IconData get iconData {
switch (this) {
case EquipmentCategory.lighting:
return Icons.light_mode;
case EquipmentCategory.sound:
return Icons.volume_up;
case EquipmentCategory.video:
return Icons.videocam;
case EquipmentCategory.effect:
return Icons.auto_awesome;
case EquipmentCategory.structure:
return Icons.construction;
case EquipmentCategory.consumable:
return Icons.inventory_2;
case EquipmentCategory.cable:
return Icons.cable;
case EquipmentCategory.vehicle:
return Icons.local_shipping;
case EquipmentCategory.backline:
return Icons.piano;
case EquipmentCategory.other:
return Icons.more_horiz;
}
}
/// Retourne la couleur associée à la catégorie
Color get color {
switch (this) {
case EquipmentCategory.lighting:
return Colors.yellow.shade700;
case EquipmentCategory.sound:
return Colors.purple;
case EquipmentCategory.video:
return Colors.blue;
case EquipmentCategory.effect:
return Colors.pink;
case EquipmentCategory.structure:
return Colors.brown;
case EquipmentCategory.consumable:
return Colors.orange;
case EquipmentCategory.cable:
return Colors.grey;
case EquipmentCategory.vehicle:
return Colors.teal;
case EquipmentCategory.backline:
return Colors.indigo;
case EquipmentCategory.other:
return Colors.blueGrey;
}
}
/// Retourne le chemin de l'icône personnalisée (si disponible)
String? get customIconPath {
switch (this) {
case EquipmentCategory.structure:
return 'assets/icons/truss.svg';
case EquipmentCategory.consumable:
return 'assets/icons/tape.svg';
case EquipmentCategory.lighting:
case EquipmentCategory.sound:
case EquipmentCategory.video:
case EquipmentCategory.effect:
case EquipmentCategory.cable:
case EquipmentCategory.vehicle:
case EquipmentCategory.backline:
case EquipmentCategory.other:
return null;
}
}
/// Vérifie si une icône personnalisée est disponible
bool get hasCustomIcon => customIconPath != null;
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
Widget getIcon({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
// Détection automatique du format (SVG ou PNG)
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
// SVG : on peut appliquer la couleur sans dégrader la qualité
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
// PNG : on n'applique PAS le color filter pour préserver la qualité
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
/// Version pour CircleAvatar et contextes similaires (sans ColorFilter si Material Icon)
Widget getIconForAvatar({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
}
extension EquipmentStatusExtension on EquipmentStatus {
/// Retourne le label français du statut
String get label {
switch (this) {
case EquipmentStatus.available:
return 'Disponible';
case EquipmentStatus.inUse:
return 'En prestation';
case EquipmentStatus.rented:
return 'Loué';
case EquipmentStatus.lost:
return 'Perdu';
case EquipmentStatus.outOfService:
return 'HS';
case EquipmentStatus.maintenance:
return 'Maintenance';
}
}
/// Retourne la couleur associée au statut
Color get color {
switch (this) {
case EquipmentStatus.available:
return Colors.green;
case EquipmentStatus.inUse:
return Colors.blue;
case EquipmentStatus.rented:
return Colors.orange;
case EquipmentStatus.lost:
return Colors.red;
case EquipmentStatus.outOfService:
return Colors.red.shade900;
case EquipmentStatus.maintenance:
return Colors.amber;
}
}
}
class EquipmentModel {
final String id; // Identifiant unique (clé)
final String name; // Nom de l'équipement
final String? brand; // Marque (indexé)
final String? model; // Modèle (indexé)
final EquipmentCategory category; // Catégorie
final String? subCategory; // Sous-catégorie (indexé par catégorie)
final EquipmentStatus status; // Statut actuel
// Prix (visible uniquement avec manage_equipment)
final double? purchasePrice; // Prix d'achat
final double? rentalPrice; // Prix de location
// Quantité (pour consommables/câbles)
final int? totalQuantity; // Quantité totale
final int? availableQuantity; // Quantité disponible
final int? criticalThreshold; // Seuil critique pour alerte
// Caractéristiques physiques
final double? weight; // Poids (kg)
final double? length; // Longueur (cm)
final double? width; // Largeur (cm)
final double? height; // Hauteur (cm)
// Dates & maintenance
final DateTime? purchaseDate; // Date d'achat
final DateTime? lastMaintenanceDate; // Dernière maintenance
final DateTime? nextMaintenanceDate; // Prochaine maintenance prévue
// Maintenances (références)
final List<String> maintenanceIds; // IDs des opérations de maintenance
// Image
final String? imageUrl; // URL de l'image (Storage /materiel)
// Métadonnées
final String? notes; // Notes additionnelles
final DateTime createdAt; // Date de création
final DateTime updatedAt; // Date de mise à jour
EquipmentModel({
required this.id,
required this.name,
this.brand,
this.model,
required this.category,
this.subCategory,
this.status = EquipmentStatus.available,
this.purchasePrice,
this.rentalPrice,
this.totalQuantity,
this.availableQuantity,
this.criticalThreshold,
this.weight,
this.length,
this.width,
this.height,
this.purchaseDate,
this.lastMaintenanceDate,
this.nextMaintenanceDate,
this.maintenanceIds = const [],
this.imageUrl,
this.notes,
required this.createdAt,
required this.updatedAt,
});
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
return null;
}
// Gestion des listes
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
return EquipmentModel(
id: id,
name: map['name'] ?? '',
brand: map['brand'],
model: map['model'],
category: equipmentCategoryFromString(map['category']),
subCategory: map['subCategory'],
status: equipmentStatusFromString(map['status']),
purchasePrice: map['purchasePrice']?.toDouble(),
rentalPrice: map['rentalPrice']?.toDouble(),
totalQuantity: map['totalQuantity']?.toInt(),
availableQuantity: map['availableQuantity']?.toInt(),
criticalThreshold: map['criticalThreshold']?.toInt(),
weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(),
width: map['width']?.toDouble(),
height: map['height']?.toDouble(),
purchaseDate: _parseDate(map['purchaseDate']),
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
maintenanceIds: maintenanceIds,
imageUrl: map['imageUrl'],
notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'brand': brand,
'model': model,
'category': equipmentCategoryToString(category),
'subCategory': subCategory,
'status': equipmentStatusToString(status),
'purchasePrice': purchasePrice,
'rentalPrice': rentalPrice,
'totalQuantity': totalQuantity,
'availableQuantity': availableQuantity,
'criticalThreshold': criticalThreshold,
'weight': weight,
'length': length,
'width': width,
'height': height,
'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null,
'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null,
'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null,
'maintenanceIds': maintenanceIds,
'imageUrl': imageUrl,
'notes': notes,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
};
}
EquipmentModel copyWith({
String? id,
String? brand,
String? name,
String? model,
EquipmentCategory? category,
String? subCategory,
EquipmentStatus? status,
double? purchasePrice,
double? rentalPrice,
int? totalQuantity,
int? availableQuantity,
int? criticalThreshold,
double? weight,
double? length,
double? width,
double? height,
DateTime? purchaseDate,
DateTime? lastMaintenanceDate,
DateTime? nextMaintenanceDate,
List<String>? maintenanceIds,
String? imageUrl,
String? notes,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return EquipmentModel(
id: id ?? this.id,
brand: brand ?? this.brand,
name: name ?? this.name,
model: model ?? this.model,
category: category ?? this.category,
subCategory: subCategory ?? this.subCategory,
status: status ?? this.status,
purchasePrice: purchasePrice ?? this.purchasePrice,
rentalPrice: rentalPrice ?? this.rentalPrice,
totalQuantity: totalQuantity ?? this.totalQuantity,
availableQuantity: availableQuantity ?? this.availableQuantity,
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
weight: weight ?? this.weight,
length: length ?? this.length,
width: width ?? this.width,
height: height ?? this.height,
lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate,
purchaseDate: purchaseDate ?? this.purchaseDate,
nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate,
maintenanceIds: maintenanceIds ?? this.maintenanceIds,
imageUrl: imageUrl ?? this.imageUrl,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Helper pour vérifier si c'est un consommable/câble avec quantité
bool get hasQuantity => category == EquipmentCategory.consumable || category == EquipmentCategory.cable;
// Helper pour vérifier si le stock est critique
bool get isCriticalStock {
if (!hasQuantity || criticalThreshold == null || availableQuantity == null) {
return false;
}
return availableQuantity! <= criticalThreshold!;
}
// Helper pour vérifier si la maintenance est à venir
bool get isMaintenanceDue {
if (nextMaintenanceDate == null) return false;
return nextMaintenanceDate!.isBefore(DateTime.now().add(const Duration(days: 7)));
}
}

View File

@@ -1,5 +1,4 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:latlong2/latlong.dart';
enum EventStatus {
confirmed,
@@ -14,8 +13,7 @@ String eventStatusToString(EventStatus status) {
case EventStatus.canceled:
return 'CANCELED';
case EventStatus.waitingForApproval:
default:
return 'WAITING_FOR_APPROVAL';
return 'WAITING_FOR_APPROVAL';
}
}
@@ -31,6 +29,258 @@ EventStatus eventStatusFromString(String? status) {
}
}
enum PreparationStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String preparationStatusToString(PreparationStatus status) {
switch (status) {
case PreparationStatus.notStarted:
return 'NOT_STARTED';
case PreparationStatus.inProgress:
return 'IN_PROGRESS';
case PreparationStatus.completed:
return 'COMPLETED';
case PreparationStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
PreparationStatus preparationStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return PreparationStatus.notStarted;
case 'IN_PROGRESS':
return PreparationStatus.inProgress;
case 'COMPLETED':
return PreparationStatus.completed;
case 'COMPLETED_WITH_MISSING':
return PreparationStatus.completedWithMissing;
default:
return PreparationStatus.notStarted;
}
}
// Statut de chargement (loading)
enum LoadingStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String loadingStatusToString(LoadingStatus status) {
switch (status) {
case LoadingStatus.notStarted:
return 'NOT_STARTED';
case LoadingStatus.inProgress:
return 'IN_PROGRESS';
case LoadingStatus.completed:
return 'COMPLETED';
case LoadingStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
LoadingStatus loadingStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return LoadingStatus.notStarted;
case 'IN_PROGRESS':
return LoadingStatus.inProgress;
case 'COMPLETED':
return LoadingStatus.completed;
case 'COMPLETED_WITH_MISSING':
return LoadingStatus.completedWithMissing;
default:
return LoadingStatus.notStarted;
}
}
// Statut de déchargement (unloading)
enum UnloadingStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String unloadingStatusToString(UnloadingStatus status) {
switch (status) {
case UnloadingStatus.notStarted:
return 'NOT_STARTED';
case UnloadingStatus.inProgress:
return 'IN_PROGRESS';
case UnloadingStatus.completed:
return 'COMPLETED';
case UnloadingStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
UnloadingStatus unloadingStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return UnloadingStatus.notStarted;
case 'IN_PROGRESS':
return UnloadingStatus.inProgress;
case 'COMPLETED':
return UnloadingStatus.completed;
case 'COMPLETED_WITH_MISSING':
return UnloadingStatus.completedWithMissing;
default:
return UnloadingStatus.notStarted;
}
}
enum ReturnStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String returnStatusToString(ReturnStatus status) {
switch (status) {
case ReturnStatus.notStarted:
return 'NOT_STARTED';
case ReturnStatus.inProgress:
return 'IN_PROGRESS';
case ReturnStatus.completed:
return 'COMPLETED';
case ReturnStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
ReturnStatus returnStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return ReturnStatus.notStarted;
case 'IN_PROGRESS':
return ReturnStatus.inProgress;
case 'COMPLETED':
return ReturnStatus.completed;
case 'COMPLETED_WITH_MISSING':
return ReturnStatus.completedWithMissing;
default:
return ReturnStatus.notStarted;
}
}
class EventEquipment {
final String equipmentId; // ID de l'équipement
final int quantity; // Quantité initiale assignée
final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement
final bool isReturned; // Validé au retour
// Tracking des manquants à chaque étape
final bool isMissingAtPreparation; // Manquant à la préparation
final bool isMissingAtLoading; // Manquant au chargement
final bool isMissingAtUnloading; // Manquant au déchargement
final bool isMissingAtReturn; // Manquant au retour
// Quantités réelles à chaque étape (pour les quantifiables)
final int? quantityAtPreparation; // Quantité comptée en préparation
final int? quantityAtLoading; // Quantité comptée au chargement
final int? quantityAtUnloading; // Quantité comptée au déchargement
final int? quantityAtReturn; // Quantité retournée
EventEquipment({
required this.equipmentId,
this.quantity = 1,
this.isPrepared = false,
this.isLoaded = false,
this.isUnloaded = false,
this.isReturned = false,
this.isMissingAtPreparation = false,
this.isMissingAtLoading = false,
this.isMissingAtUnloading = false,
this.isMissingAtReturn = false,
this.quantityAtPreparation,
this.quantityAtLoading,
this.quantityAtUnloading,
this.quantityAtReturn,
});
factory EventEquipment.fromMap(Map<String, dynamic> map) {
return EventEquipment(
equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1,
isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false,
isReturned: map['isReturned'] ?? false,
isMissingAtPreparation: map['isMissingAtPreparation'] ?? false,
isMissingAtLoading: map['isMissingAtLoading'] ?? false,
isMissingAtUnloading: map['isMissingAtUnloading'] ?? false,
isMissingAtReturn: map['isMissingAtReturn'] ?? false,
quantityAtPreparation: map['quantityAtPreparation'],
quantityAtLoading: map['quantityAtLoading'],
quantityAtUnloading: map['quantityAtUnloading'],
quantityAtReturn: map['quantityAtReturn'],
);
}
Map<String, dynamic> toMap() {
return {
'equipmentId': equipmentId,
'quantity': quantity,
'isPrepared': isPrepared,
'isLoaded': isLoaded,
'isUnloaded': isUnloaded,
'isReturned': isReturned,
'isMissingAtPreparation': isMissingAtPreparation,
'isMissingAtLoading': isMissingAtLoading,
'isMissingAtUnloading': isMissingAtUnloading,
'isMissingAtReturn': isMissingAtReturn,
'quantityAtPreparation': quantityAtPreparation,
'quantityAtLoading': quantityAtLoading,
'quantityAtUnloading': quantityAtUnloading,
'quantityAtReturn': quantityAtReturn,
};
}
EventEquipment copyWith({
String? equipmentId,
int? quantity,
bool? isPrepared,
bool? isLoaded,
bool? isUnloaded,
bool? isReturned,
bool? isMissingAtPreparation,
bool? isMissingAtLoading,
bool? isMissingAtUnloading,
bool? isMissingAtReturn,
int? quantityAtPreparation,
int? quantityAtLoading,
int? quantityAtUnloading,
int? quantityAtReturn,
}) {
return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity,
isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded,
isReturned: isReturned ?? this.isReturned,
isMissingAtPreparation: isMissingAtPreparation ?? this.isMissingAtPreparation,
isMissingAtLoading: isMissingAtLoading ?? this.isMissingAtLoading,
isMissingAtUnloading: isMissingAtUnloading ?? this.isMissingAtUnloading,
isMissingAtReturn: isMissingAtReturn ?? this.isMissingAtReturn,
quantityAtPreparation: quantityAtPreparation ?? this.quantityAtPreparation,
quantityAtLoading: quantityAtLoading ?? this.quantityAtLoading,
quantityAtUnloading: quantityAtUnloading ?? this.quantityAtUnloading,
quantityAtReturn: quantityAtReturn ?? this.quantityAtReturn,
);
}
}
class EventModel {
final String id;
final String name;
@@ -41,15 +291,29 @@ class EventModel {
final int installationTime;
final int disassemblyTime;
final String eventTypeId;
final DocumentReference? eventTypeRef;
final String customerId;
final String address;
final double latitude;
final double longitude;
final List<DocumentReference> workforce;
final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
final List<Map<String, String>> documents;
final List<Map<String, dynamic>> options;
final EventStatus status;
// Champs de contact
final int? jauge;
final String? contactEmail;
final String? contactPhone;
// Nouveaux champs pour la gestion du matériel
final List<EventEquipment> assignedEquipment;
final List<String> assignedContainers; // IDs des conteneurs assignés
final PreparationStatus? preparationStatus;
final LoadingStatus? loadingStatus;
final UnloadingStatus? unloadingStatus;
final ReturnStatus? returnStatus;
EventModel({
required this.id,
required this.name,
@@ -60,6 +324,7 @@ class EventModel {
required this.installationTime,
required this.disassemblyTime,
required this.eventTypeId,
this.eventTypeRef,
required this.customerId,
required this.address,
required this.latitude,
@@ -68,62 +333,197 @@ class EventModel {
required this.documents,
this.options = const [],
this.status = EventStatus.waitingForApproval,
this.jauge,
this.contactEmail,
this.contactPhone,
this.assignedEquipment = const [],
this.assignedContainers = const [],
this.preparationStatus,
this.loadingStatus,
this.unloadingStatus,
this.returnStatus,
});
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
final List<dynamic> workforceRefs = map['workforce'] ?? [];
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?;
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?;
try {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value, DateTime defaultValue) {
if (value == null) return defaultValue;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
return defaultValue;
}
final docsRaw = map['documents'] ?? [];
final docs = docsRaw is List
? docsRaw.map<Map<String, String>>((e) {
// Gestion sécurisée des références workforce
final List<dynamic> workforceRefs = map['workforce'] ?? [];
final List<dynamic> safeWorkforce = [];
for (var ref in workforceRefs) {
if (ref is DocumentReference) {
safeWorkforce.add(ref);
} else if (ref is String) {
// Accepter directement les UIDs (envoyés par le backend)
safeWorkforce.add(ref);
} else {
print('Warning: Invalid workforce reference in event $id: $ref');
}
}
// Gestion sécurisée des timestamps avec support ISO string
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
// Gestion sécurisée des documents
final docsRaw = map['documents'] ?? [];
final List<Map<String, String>> docs = [];
if (docsRaw is List) {
for (var e in docsRaw) {
try {
if (e is Map) {
return Map<String, String>.from(e as Map);
docs.add(Map<String, String>.from(e));
} else if (e is String) {
final fileName = Uri.decodeComponent(
e.split('/').last.split('?').first,
);
return {'name': fileName, 'url': e};
} else {
return {};
docs.add({'name': fileName, 'url': e});
}
}).toList()
: <Map<String, String>>[];
final optionsRaw = map['options'] ?? [];
final options = optionsRaw is List
? optionsRaw.map<Map<String, dynamic>>((e) {
} catch (docError) {
print('Warning: Failed to parse document in event $id: $docError');
}
}
}
// Gestion sécurisée des options
final optionsRaw = map['options'] ?? [];
final List<Map<String, dynamic>> options = [];
if (optionsRaw is List) {
for (var e in optionsRaw) {
try {
if (e is Map) {
return Map<String, dynamic>.from(e as Map);
} else {
return {};
options.add(Map<String, dynamic>.from(e));
}
}).toList()
: <Map<String, dynamic>>[];
return EventModel(
id: id,
name: map['Name'] ?? '',
description: map['Description'] ?? '',
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
endDateTime: endTimestamp?.toDate() ??
DateTime.now().add(const Duration(hours: 1)),
basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(),
installationTime: map['InstallationTime'] ?? 0,
disassemblyTime: map['DisassemblyTime'] ?? 0,
eventTypeId: map['EventType'] is DocumentReference
? (map['EventType'] as DocumentReference).id
: '',
customerId: map['customer'] is DocumentReference
? (map['customer'] as DocumentReference).id
: '',
address: map['Address'] ?? '',
latitude: (map['Latitude'] ?? 0.0).toDouble(),
longitude: (map['Longitude'] ?? 0.0).toDouble(),
workforce: workforceRefs.whereType<DocumentReference>().toList(),
documents: docs,
options: options,
status: eventStatusFromString(map['status'] as String?),
);
} catch (optionError) {
print('Warning: Failed to parse option in event $id: $optionError');
}
}
}
// Gestion sécurisée de l'EventType
String eventTypeId = '';
DocumentReference? eventTypeRef;
if (map['EventType'] is DocumentReference) {
eventTypeRef = map['EventType'] as DocumentReference;
eventTypeId = eventTypeRef.id;
} else if (map['EventType'] is String) {
final eventTypeString = map['EventType'] as String;
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
if (eventTypeString.contains('/')) {
eventTypeId = eventTypeString.split('/').last;
} else {
eventTypeId = eventTypeString;
}
}
// Gestion sécurisée du customer
String customerId = '';
if (map['customer'] is DocumentReference) {
customerId = (map['customer'] as DocumentReference).id;
} else if (map['customer'] is String) {
final customerString = map['customer'] as String;
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
if (customerString.contains('/')) {
customerId = customerString.split('/').last;
} else {
customerId = customerString;
}
}
// Gestion des équipements assignés
final assignedEquipmentRaw = map['assignedEquipment'] ?? [];
final List<EventEquipment> assignedEquipment = [];
if (assignedEquipmentRaw is List) {
for (var e in assignedEquipmentRaw) {
try {
if (e is Map) {
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.from(e)));
}
} catch (equipmentError) {
print('Warning: Failed to parse equipment in event $id: $equipmentError');
}
}
}
// Gestion des conteneurs assignés
final assignedContainersRaw = map['assignedContainers'] ?? [];
final List<String> assignedContainers = [];
if (assignedContainersRaw is List) {
for (var e in assignedContainersRaw) {
if (e is String) {
assignedContainers.add(e);
}
}
}
return EventModel(
id: id,
name: (map['Name'] ?? '').toString().trim(),
description: (map['Description'] ?? '').toString(),
startDateTime: startDate,
endDateTime: endDate,
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
installationTime: _parseInt(map['InstallationTime'] ?? 0),
assignedContainers: assignedContainers,
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
eventTypeId: eventTypeId,
eventTypeRef: eventTypeRef,
customerId: customerId,
address: (map['Address'] ?? '').toString(),
latitude: _parseDouble(map['Latitude'] ?? 0.0),
longitude: _parseDouble(map['Longitude'] ?? 0.0),
workforce: safeWorkforce,
documents: docs,
options: options,
status: eventStatusFromString(map['status'] as String?),
jauge: map['jauge'] != null ? _parseInt(map['jauge']) : null,
contactEmail: map['contactEmail']?.toString(),
contactPhone: map['contactPhone']?.toString(),
assignedEquipment: assignedEquipment,
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
);
} catch (e) {
print('Error parsing event $id: $e');
print('Event data: $map');
rethrow;
}
}
// Méthodes utilitaires pour le parsing sécurisé
static double _parseDouble(dynamic value) {
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
final parsed = double.tryParse(value);
if (parsed != null) return parsed;
}
return 0.0;
}
static int _parseInt(dynamic value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) return parsed;
}
return 0;
}
Map<String, dynamic> toMap() {
@@ -135,8 +535,10 @@ class EventModel {
'BasePrice': basePrice,
'InstallationTime': installationTime,
'DisassemblyTime': disassemblyTime,
'EventType': eventTypeId,
'customer': customerId,
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'customer': customerId.isNotEmpty ? customerId : null,
'Address': address,
'Position': GeoPoint(latitude, longitude),
'Latitude': latitude,
@@ -145,6 +547,75 @@ class EventModel {
'documents': documents,
'options': options,
'status': eventStatusToString(status),
'jauge': jauge,
'contactEmail': contactEmail,
'contactPhone': contactPhone,
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
'assignedContainers': assignedContainers,
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : null,
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
};
}
EventModel copyWith({
String? id,
String? name,
String? description,
DateTime? startDateTime,
DateTime? endDateTime,
double? basePrice,
int? installationTime,
int? disassemblyTime,
String? eventTypeId,
DocumentReference? eventTypeRef,
String? customerId,
String? address,
double? latitude,
double? longitude,
List<dynamic>? workforce,
List<Map<String, String>>? documents,
List<Map<String, dynamic>>? options,
EventStatus? status,
int? jauge,
String? contactEmail,
String? contactPhone,
List<EventEquipment>? assignedEquipment,
List<String>? assignedContainers,
PreparationStatus? preparationStatus,
LoadingStatus? loadingStatus,
UnloadingStatus? unloadingStatus,
ReturnStatus? returnStatus,
}) {
return EventModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
startDateTime: startDateTime ?? this.startDateTime,
endDateTime: endDateTime ?? this.endDateTime,
basePrice: basePrice ?? this.basePrice,
installationTime: installationTime ?? this.installationTime,
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
eventTypeId: eventTypeId ?? this.eventTypeId,
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
customerId: customerId ?? this.customerId,
address: address ?? this.address,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
workforce: workforce ?? this.workforce,
documents: documents ?? this.documents,
options: options ?? this.options,
status: status ?? this.status,
jauge: jauge ?? this.jauge,
contactEmail: contactEmail ?? this.contactEmail,
contactPhone: contactPhone ?? this.contactPhone,
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
assignedContainers: assignedContainers ?? this.assignedContainers,
preparationStatus: preparationStatus ?? this.preparationStatus,
loadingStatus: loadingStatus ?? this.loadingStatus,
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
returnStatus: returnStatus ?? this.returnStatus,
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventTypeModel {
final String id;
final String name;
final double defaultPrice;
final DateTime createdAt;
EventTypeModel({
required this.id,
required this.name,
required this.defaultPrice,
required this.createdAt,
});
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
// Gérer createdAt qui peut être Timestamp (Firestore) ou String ISO (API)
DateTime parseCreatedAt(dynamic value) {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
return EventTypeModel(
id: id,
name: map['name'] ?? '',
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
createdAt: parseCreatedAt(map['createdAt']),
);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'defaultPrice': defaultPrice,
'createdAt': createdAt,
};
}
}

View File

@@ -0,0 +1,146 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum MaintenanceType {
preventive, // Préventive
corrective, // Corrective
inspection // Inspection
}
String maintenanceTypeToString(MaintenanceType type) {
switch (type) {
case MaintenanceType.preventive:
return 'PREVENTIVE';
case MaintenanceType.corrective:
return 'CORRECTIVE';
case MaintenanceType.inspection:
return 'INSPECTION';
}
}
MaintenanceType maintenanceTypeFromString(String? type) {
switch (type) {
case 'PREVENTIVE':
return MaintenanceType.preventive;
case 'CORRECTIVE':
return MaintenanceType.corrective;
case 'INSPECTION':
return MaintenanceType.inspection;
default:
return MaintenanceType.preventive;
}
}
class MaintenanceModel {
final String id; // ID aléatoire
final List<String> equipmentIds; // IDs des équipements concernés (peut être multiple)
final MaintenanceType type; // Type de maintenance
final DateTime scheduledDate; // Date planifiée
final DateTime? completedDate; // Date de réalisation (null si pas encore effectuée)
final String name; // Nom de l'opération
final String description; // Description détaillée
final String? performedBy; // ID de l'utilisateur qui a effectué la maintenance
final double? cost; // Coût de la maintenance
final String? notes; // Notes additionnelles
final DateTime createdAt; // Date de création
final DateTime updatedAt; // Date de mise à jour
MaintenanceModel({
required this.id,
required this.equipmentIds,
required this.type,
required this.scheduledDate,
this.completedDate,
required this.name,
required this.description,
this.performedBy,
this.cost,
this.notes,
required this.createdAt,
required this.updatedAt,
});
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
return null;
}
// Gestion de la liste des équipements
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
return MaintenanceModel(
id: id,
equipmentIds: equipmentIds,
type: maintenanceTypeFromString(map['type']),
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
completedDate: _parseDate(map['completedDate']),
name: map['name'] ?? '',
description: map['description'] ?? '',
performedBy: map['performedBy'],
cost: map['cost']?.toDouble(),
notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
);
}
Map<String, dynamic> toMap() {
return {
'equipmentIds': equipmentIds,
'type': maintenanceTypeToString(type),
'scheduledDate': Timestamp.fromDate(scheduledDate),
'completedDate': completedDate != null ? Timestamp.fromDate(completedDate!) : null,
'name': name,
'description': description,
'performedBy': performedBy,
'cost': cost,
'notes': notes,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
};
}
MaintenanceModel copyWith({
String? id,
List<String>? equipmentIds,
MaintenanceType? type,
DateTime? scheduledDate,
DateTime? completedDate,
String? name,
String? description,
String? performedBy,
double? cost,
String? notes,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return MaintenanceModel(
id: id ?? this.id,
equipmentIds: equipmentIds ?? this.equipmentIds,
type: type ?? this.type,
scheduledDate: scheduledDate ?? this.scheduledDate,
completedDate: completedDate ?? this.completedDate,
name: name ?? this.name,
description: description ?? this.description,
performedBy: performedBy ?? this.performedBy,
cost: cost ?? this.cost,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Helper pour vérifier si la maintenance est complétée
bool get isCompleted => completedDate != null;
// Helper pour vérifier si la maintenance est en retard
bool get isOverdue {
if (isCompleted) return false;
return scheduledDate.isBefore(DateTime.now());
}
}

View File

@@ -0,0 +1,88 @@
/// Préférences de notifications pour un utilisateur
class NotificationPreferences {
final bool emailEnabled; // Recevoir emails
final bool pushEnabled; // Recevoir notifications push
final bool inAppEnabled; // Recevoir alertes in-app
// Préférences par type d'alerte
final bool eventsNotifications; // Alertes événements
final bool maintenanceNotifications; // Alertes maintenance
final bool stockNotifications; // Alertes stock
final bool equipmentNotifications; // Alertes équipement
// Token FCM (pour push)
final String? fcmToken;
const NotificationPreferences({
this.emailEnabled = true, // ✓ Activé par défaut
this.pushEnabled = false,
this.inAppEnabled = true,
this.eventsNotifications = true,
this.maintenanceNotifications = true,
this.stockNotifications = true,
this.equipmentNotifications = true,
this.fcmToken,
});
/// Valeurs par défaut pour un nouvel utilisateur
factory NotificationPreferences.defaults() {
return const NotificationPreferences(
emailEnabled: true, // ✓ Activé par défaut
pushEnabled: false,
inAppEnabled: true,
eventsNotifications: true,
maintenanceNotifications: true,
stockNotifications: true,
equipmentNotifications: true,
);
}
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
return NotificationPreferences(
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
pushEnabled: map['pushEnabled'] ?? false,
inAppEnabled: map['inAppEnabled'] ?? true,
eventsNotifications: map['eventsNotifications'] ?? true,
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
stockNotifications: map['stockNotifications'] ?? true,
equipmentNotifications: map['equipmentNotifications'] ?? true,
fcmToken: map['fcmToken'],
);
}
Map<String, dynamic> toMap() {
return {
'emailEnabled': emailEnabled,
'pushEnabled': pushEnabled,
'inAppEnabled': inAppEnabled,
'eventsNotifications': eventsNotifications,
'maintenanceNotifications': maintenanceNotifications,
'stockNotifications': stockNotifications,
'equipmentNotifications': equipmentNotifications,
if (fcmToken != null) 'fcmToken': fcmToken,
};
}
NotificationPreferences copyWith({
bool? emailEnabled,
bool? pushEnabled,
bool? inAppEnabled,
bool? eventsNotifications,
bool? maintenanceNotifications,
bool? stockNotifications,
bool? equipmentNotifications,
String? fcmToken,
}) {
return NotificationPreferences(
emailEnabled: emailEnabled ?? this.emailEnabled,
pushEnabled: pushEnabled ?? this.pushEnabled,
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
stockNotifications: stockNotifications ?? this.stockNotifications,
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
fcmToken: fcmToken ?? this.fcmToken,
);
}
}

View File

@@ -1,42 +1,48 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventOption {
final String id;
final String code; // Nouveau champ code
final String name;
final String details;
final double valMin;
final double valMax;
final List<DocumentReference> eventTypes;
final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
final bool isQuantitative; // Indique si l'option peut avoir une quantité
EventOption({
required this.id,
required this.code,
required this.name,
required this.details,
required this.valMin,
required this.valMax,
required this.eventTypes,
this.isQuantitative = false,
});
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
return EventOption(
id: id,
code: map['code'] ?? id, // Utilise le code ou l'ID en fallback
name: map['name'] ?? '',
details: map['details'] ?? '',
valMin: (map['valMin'] ?? 0.0).toDouble(),
valMax: (map['valMax'] ?? 0.0).toDouble(),
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
.whereType<DocumentReference>()
.map((e) => e.toString()) // Convertit en String (supporte IDs et références)
.toList(),
isQuantitative: map['isQuantitative'] ?? false,
);
}
Map<String, dynamic> toMap() {
return {
'code': code,
'name': name,
'details': details,
'valMin': valMin,
'valMax': valMax,
'eventTypes': eventTypes,
'isQuantitative': isQuantitative,
};
}
}

View File

@@ -0,0 +1,63 @@
/// Résultat du traitement d'un code QR ou saisi manuellement
class QRCodeProcessResult {
/// Indique si le traitement a réussi
final bool success;
/// Message descriptif du résultat
final String? message;
/// Liste des IDs d'équipements affectés par le traitement
final List<String> affectedEquipmentIds;
/// Mises à jour des états de validation (équipements cochés)
final Map<String, bool>? updatedValidationState;
/// Mises à jour des quantités actuelles
final Map<String, int>? updatedQuantities;
/// Indique si le code n'a pas été trouvé dans l'événement actuel
/// (utilisé pour proposer de l'ajouter depuis la BDD)
final bool codeNotFoundInEvent;
const QRCodeProcessResult({
required this.success,
this.message,
this.affectedEquipmentIds = const [],
this.updatedValidationState,
this.updatedQuantities,
this.codeNotFoundInEvent = false,
});
/// Crée un résultat de succès
factory QRCodeProcessResult.success({
required String message,
required List<String> affectedEquipmentIds,
Map<String, bool>? updatedValidationState,
Map<String, int>? updatedQuantities,
}) {
return QRCodeProcessResult(
success: true,
message: message,
affectedEquipmentIds: affectedEquipmentIds,
updatedValidationState: updatedValidationState,
updatedQuantities: updatedQuantities,
);
}
/// Crée un résultat d'erreur
factory QRCodeProcessResult.error(String message) {
return QRCodeProcessResult(
success: false,
message: message,
);
}
/// Crée un résultat indiquant que le code n'est pas dans l'événement
factory QRCodeProcessResult.notFoundInEvent(String code) {
return QRCodeProcessResult(
success: false,
message: 'Code $code non trouvé dans cet événement',
codeNotFoundInEvent: true,
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class RoleModel {
final String id;

View File

@@ -1,4 +1,5 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/notification_preferences_model.dart';
class UserModel {
final String uid;
@@ -8,6 +9,7 @@ class UserModel {
final String profilePhotoUrl;
final String email;
final String phoneNumber;
final NotificationPreferences? notificationPreferences;
UserModel({
required this.uid,
@@ -17,19 +19,39 @@ class UserModel {
required this.profilePhotoUrl,
required this.email,
required this.phoneNumber,
this.notificationPreferences,
});
// Convertit une Map (Firestore) en UserModel
factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
String roleString;
final roleField = data['role'];
if (roleField is String) {
// Cas 1 : role est déjà un String (ex: "roles/ADMIN")
roleString = roleField;
} else if (roleField is DocumentReference) {
// Cas 2 : role est une DocumentReference
roleString = roleField.id;
} else if (roleField is Map) {
// Cas 3 : role est un Map sérialisé (ex: {"_path": {"segments": ["roles", "ADMIN"]}})
// On extrait le path
final pathData = roleField['_path'];
if (pathData is Map && pathData['segments'] is List) {
final segments = pathData['segments'] as List;
if (segments.length >= 2) {
roleString = segments[1].toString(); // Ex: "ADMIN"
} else {
roleString = 'USER';
}
} else {
roleString = 'USER';
}
} else {
// Cas par défaut
roleString = 'USER';
}
return UserModel(
uid: uid,
firstName: data['firstName'] ?? '',
@@ -38,6 +60,9 @@ class UserModel {
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
email: data['email'] ?? '',
phoneNumber: data['phoneNumber'] ?? '',
notificationPreferences: data['notificationPreferences'] != null
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
: NotificationPreferences.defaults(),
);
}
@@ -46,10 +71,12 @@ class UserModel {
return {
'firstName': firstName,
'lastName': lastName,
'role': FirebaseFirestore.instance.collection('roles').doc(role),
'role': role, // Envoyer directement le string roleId au lieu de créer une DocumentReference
'profilePhotoUrl': profilePhotoUrl,
'email': email,
'phoneNumber': phoneNumber,
if (notificationPreferences != null)
'notificationPreferences': notificationPreferences!.toMap(),
};
}
@@ -60,6 +87,7 @@ class UserModel {
String? profilePhotoUrl,
String? email,
String? phoneNumber,
NotificationPreferences? notificationPreferences,
}) {
return UserModel(
uid: uid, // L'UID ne change pas
@@ -69,6 +97,7 @@ class UserModel {
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
email: email ?? this.email,
phoneNumber: phoneNumber ?? this.phoneNumber,
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/api_service.dart';
class AlertProvider extends ChangeNotifier {
final ApiService _apiService = apiService;
List<AlertModel> _alerts = [];
bool _isLoading = false;
// Getters
List<AlertModel> get alerts => _alerts;
bool get isLoading => _isLoading;
/// Nombre d'alertes non lues
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
/// Alertes non lues uniquement
List<AlertModel> get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList();
/// Alertes de stock critique
List<AlertModel> get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList();
/// Alertes de maintenance
List<AlertModel> get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList();
/// Alertes de conflit
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
/// Charger toutes les alertes via Cloud Function
Future<void> loadAlerts() async {
_isLoading = true;
notifyListeners();
try {
final result = await _apiService.call('getAlerts', {});
final alertsData = result['alerts'] as List<dynamic>;
_alerts = alertsData.map((data) {
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
}).toList();
} catch (e) {
print('Error loading alerts: $e');
_alerts = [];
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Marquer une alerte comme lue via Cloud Function
Future<void> markAsRead(String alertId) async {
try {
await _apiService.call('markAlertAsRead', {'alertId': alertId});
// Mettre à jour localement
final index = _alerts.indexWhere((a) => a.id == alertId);
if (index != -1) {
_alerts[index] = AlertModel(
id: _alerts[index].id,
type: _alerts[index].type,
message: _alerts[index].message,
equipmentId: _alerts[index].equipmentId,
isRead: true,
createdAt: _alerts[index].createdAt,
);
notifyListeners();
}
} catch (e) {
print('Error marking alert as read: $e');
rethrow;
}
}
/// Supprimer une alerte via Cloud Function
Future<void> deleteAlert(String alertId) async {
try {
await _apiService.call('deleteAlert', {'alertId': alertId});
// Supprimer localement
_alerts.removeWhere((a) => a.id == alertId);
notifyListeners();
} catch (e) {
print('Error deleting alert: $e');
rethrow;
}
}
/// Marquer toutes les alertes comme lues
Future<void> markAllAsRead() async {
try {
final unreadAlertIds = _alerts.where((a) => !a.isRead).map((a) => a.id).toList();
for (final alertId in unreadAlertIds) {
await markAsRead(alertId);
}
} catch (e) {
print('Error marking all alerts as read: $e');
rethrow;
}
}
/// Supprimer toutes les alertes lues via Cloud Function
Future<void> deleteReadAlerts() async {
try {
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
for (final alertId in readAlertIds) {
await deleteAlert(alertId);
}
} catch (e) {
print('Error deleting read alerts: $e');
rethrow;
}
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class AlertProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<AlertModel> _alerts = [];
bool _isLoading = false;
List<AlertModel> get alerts => _alerts;
bool get isLoading => _isLoading;
/// Nombre d'alertes non lues
int get unreadCount => _alerts.where((a) => !a.isRead).length;
/// Charger toutes les alertes via l'API
Future<void> loadAlerts() async {
_isLoading = true;
notifyListeners();
try {
final alertsData = await _dataService.getAlerts();
_alerts = alertsData.map((data) {
return AlertModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading alerts: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharger les alertes
Future<void> refresh() async {
await loadAlerts();
}
/// Obtenir les alertes non lues
List<AlertModel> get unreadAlerts {
return _alerts.where((a) => !a.isRead).toList();
}
/// Obtenir les alertes par type
List<AlertModel> getByType(AlertType type) {
return _alerts.where((a) => a.type == type).toList();
}
/// Obtenir les alertes critiques (stock bas, équipement perdu)
List<AlertModel> get criticalAlerts {
return _alerts.where((a) =>
a.type == AlertType.lowStock || a.type == AlertType.lost
).toList();
}
}

View File

@@ -0,0 +1,452 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/container_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class ContainerProvider with ChangeNotifier {
final ContainerService _containerService = ContainerService();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
// Timer pour le debouncing de la recherche
Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion
List<ContainerModel> _paginatedContainers = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour compatibilité
List<ContainerModel> _containers = [];
// Filtres et recherche
ContainerType? _selectedType;
EquipmentStatus? _selectedStatus;
String _searchQuery = '';
bool _isLoading = false;
bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
// Getters
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
ContainerType? get selectedType => _selectedType;
EquipmentStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
Future<void> ensureLoaded() async {
if (_isInitialized || _isLoading) {
return;
}
await loadContainers();
}
/// Charger tous les containers via l'API (avec pagination automatique)
Future<void> loadContainers() async {
_isLoading = true;
notifyListeners();
try {
_containers.clear();
String? lastVisible;
bool hasMore = true;
int pageCount = 0;
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[ContainerProvider] Loading page $pageCount...');
final result = await _dataService.getContainersPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
type: _selectedType?.name,
status: _selectedStatus?.name,
searchQuery: _searchQuery,
);
final containers = (result['containers'] as List<dynamic>)
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
.toList();
_containers.addAll(containers);
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
}
_isLoading = false;
_isInitialized = true;
notifyListeners();
} catch (e) {
print('Error loading containers: $e');
_isLoading = false;
notifyListeners();
}
}
/// Récupérer les containers avec filtres appliqués
Future<List<ContainerModel>> getContainers() async {
return await _containerService.getContainers(
type: _selectedType,
status: _selectedStatus,
searchQuery: _searchQuery,
);
}
/// Stream des containers - retourne un stream depuis les données en cache
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
Stream<List<ContainerModel>> get containersStream async* {
// Si les données ne sont pas chargées, charger d'abord
if (!_isInitialized) {
await loadContainers();
}
// Émettre les données actuelles
yield _containers;
// Continuer à émettre les mises à jour du cache
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
// et notifier quand les données changent
}
/// Définir le type sélectionné
void setSelectedType(ContainerType? type) async {
if (_selectedType == type) return;
_selectedType = type;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le statut sélectionné
void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query;
// Annuler le timer précédent
_searchDebounceTimer?.cancel();
if (_usePagination) {
// Attendre 500ms avant de recharger (debouncing)
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
reload();
});
} else {
notifyListeners();
}
}
@override
void dispose() {
_searchDebounceTimer?.cancel();
super.dispose();
}
// ============================================================================
// PAGINATION - Nouvelles méthodes
// ============================================================================
/// Active le mode pagination (pour la page de gestion)
void enablePagination() {
if (!_usePagination) {
_usePagination = true;
DebugLog.info('[ContainerProvider] Pagination mode enabled');
}
}
/// Désactive le mode pagination (pour les autres pages)
void disablePagination() {
if (_usePagination) {
_usePagination = false;
DebugLog.info('[ContainerProvider] Pagination mode disabled');
}
}
/// Charge la première page (réinitialise tout)
Future<void> loadFirstPage() async {
DebugLog.info('[ContainerProvider] Loading first page...');
_paginatedContainers.clear();
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners();
try {
await loadNextPage();
_isInitialized = true;
} catch (e) {
DebugLog.error('[ContainerProvider] Error loading first page', e);
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge la page suivante (scroll infini)
Future<void> loadNextPage() async {
if (_isLoadingMore || !_hasMore) {
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
return;
}
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
try {
final result = await _dataService.getContainersPaginated(
limit: 20,
startAfter: _lastVisible,
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newContainers = (result['containers'] as List<dynamic>)
.map((data) {
final map = data as Map<String, dynamic>;
return ContainerModel.fromMap(map, map['id'] as String);
})
.toList();
_paginatedContainers.addAll(newContainers);
_hasMore = result['hasMore'] as bool? ?? false;
_lastVisible = result['lastVisible'] as String?;
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
} catch (e) {
DebugLog.error('[ContainerProvider] Error loading next page', e);
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharge en changeant de filtre ou recherche
Future<void> reload() async {
DebugLog.info('[ContainerProvider] Reloading with new filters...');
await loadFirstPage();
}
/// Créer un nouveau container
Future<void> createContainer(ContainerModel container) async {
await _containerService.createContainer(container);
notifyListeners();
}
/// Mettre à jour un container
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
await _containerService.updateContainer(id, data);
notifyListeners();
}
/// Supprimer un container
Future<void> deleteContainer(String id) async {
await _containerService.deleteContainer(id);
notifyListeners();
}
/// Récupérer un container par ID
Future<ContainerModel?> getContainerById(String id) async {
return await _containerService.getContainerById(id);
}
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
if (containerIds.isEmpty) return [];
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
try {
// Vérifier d'abord le cache local
final cachedContainers = <ContainerModel>[];
final missingIds = <String>[];
for (final id in containerIds) {
final cached = _containers.firstWhere(
(c) => c.id == id,
orElse: () => ContainerModel(
id: '',
name: '',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (cached.id.isNotEmpty) {
cachedContainers.add(cached);
} else {
missingIds.add(id);
}
}
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
// Si tous sont en cache, retourner directement
if (missingIds.isEmpty) {
return cachedContainers;
}
// Charger les manquants depuis l'API
final containersData = await _dataService.getContainersByIds(missingIds);
final loadedContainers = containersData.map((data) {
return ContainerModel.fromMap(data, data['id'] as String);
}).toList();
// Ajouter au cache
for (final container in loadedContainers) {
if (!_containers.any((c) => c.id == container.id)) {
_containers.add(container);
}
}
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
// Retourner tous les conteneurs (cache + chargés)
return [...cachedContainers, ...loadedContainers];
} catch (e) {
print('[ContainerProvider] Error loading containers by IDs: $e');
rethrow;
}
}
/// Ajouter un équipement à un container
Future<Map<String, dynamic>> addEquipmentToContainer({
required String containerId,
required String equipmentId,
String? userId,
}) async {
final result = await _containerService.addEquipmentToContainer(
containerId: containerId,
equipmentId: equipmentId,
userId: userId,
);
notifyListeners();
return result;
}
/// Retirer un équipement d'un container
Future<void> removeEquipmentFromContainer({
required String containerId,
required String equipmentId,
String? userId,
}) async {
await _containerService.removeEquipmentFromContainer(
containerId: containerId,
equipmentId: equipmentId,
userId: userId,
);
notifyListeners();
}
/// Vérifier la disponibilité d'un container
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
return await _containerService.checkContainerAvailability(
containerId: containerId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
}
/// Récupérer les équipements d'un container
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
return await _containerService.getContainerEquipment(containerId);
}
/// Trouver tous les containers contenant un équipement
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
return await _containerService.findContainersWithEquipment(equipmentId);
}
/// Vérifier si un ID existe
Future<bool> checkContainerIdExists(String id) async {
return await _containerService.checkContainerIdExists(id);
}
/// Générer un ID unique pour un container
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
static String generateContainerId({
required ContainerType type,
required String name,
int? number,
}) {
// Obtenir le type en majuscules
final typeStr = containerTypeToString(type);
// Nettoyer le nom (enlever espaces, caractères spéciaux)
final cleanName = name
.replaceAll(' ', '_')
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
.toUpperCase();
if (number != null) {
return 'BOX_${typeStr}_${cleanName}_#$number';
}
return 'BOX_${typeStr}_$cleanName';
}
/// Assurer l'unicité d'un ID de container
static Future<String> ensureUniqueContainerId(
String baseId,
ContainerService service,
) async {
String uniqueId = baseId;
int counter = 1;
while (await service.checkContainerIdExists(uniqueId)) {
uniqueId = '${baseId}_$counter';
counter++;
}
return uniqueId;
}
}

View File

@@ -0,0 +1,533 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class EquipmentProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
// Timer pour le debouncing de la recherche
Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion
List<EquipmentModel> _paginatedEquipment = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour getEquipmentsByIds et compatibilité
List<EquipmentModel> _equipment = [];
List<String> _models = [];
List<String> _brands = [];
// Filtres et recherche
EquipmentCategory? _selectedCategory;
EquipmentStatus? _selectedStatus;
String? _selectedModel;
String _searchQuery = '';
bool _isLoading = false;
bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
EquipmentProvider();
// Getters
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
List<EquipmentModel> get allEquipment => _equipment;
List<String> get models => _models;
List<String> get brands => _brands;
EquipmentCategory? get selectedCategory => _selectedCategory;
EquipmentStatus? get selectedStatus => _selectedStatus;
String? get selectedModel => _selectedModel;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// S'assure que les équipements sont chargés (charge si nécessaire)
Future<void> ensureLoaded() async {
// Si déjà en train de charger, attendre
if (_isLoading) {
print('[EquipmentProvider] Equipment loading in progress, waiting...');
return;
}
// Si initialisé MAIS _equipment est vide, forcer le rechargement
if (_isInitialized && _equipment.isEmpty) {
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
_isInitialized = false; // Réinitialiser le flag
await loadEquipments();
return;
}
// Si déjà initialisé avec des données, ne rien faire
if (_isInitialized) {
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
return;
}
print('[EquipmentProvider] Equipment not loaded, loading now...');
await loadEquipments();
}
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
Future<void> loadEquipments() async {
print('[EquipmentProvider] Starting to load ALL equipments...');
_isLoading = true;
notifyListeners();
try {
_equipment.clear();
String? lastVisible;
bool hasMore = true;
int pageCount = 0;
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[EquipmentProvider] Loading page $pageCount...');
final result = await _dataService.getEquipmentsPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
);
final equipmentsData = result['equipments'] as List<dynamic>;
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
final pageEquipments = equipmentsData.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
}).toList();
_equipment.addAll(pageEquipments);
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
if (!hasMore) {
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
}
}
// Extraire les modèles et marques uniques
_extractUniqueValues();
_isInitialized = true;
_isLoading = false;
notifyListeners();
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
} catch (e) {
print('[EquipmentProvider] Error loading equipments: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge plusieurs équipements par leurs IDs (optimisé pour les détails d'événement)
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> equipmentIds) async {
if (equipmentIds.isEmpty) return [];
print('[EquipmentProvider] Loading ${equipmentIds.length} equipments by IDs...');
try {
// Vérifier d'abord le cache local
final cachedEquipments = <EquipmentModel>[];
final missingIds = <String>[];
for (final id in equipmentIds) {
final cached = _equipment.firstWhere(
(eq) => eq.id == id,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (cached.id.isNotEmpty) {
cachedEquipments.add(cached);
} else {
missingIds.add(id);
}
}
print('[EquipmentProvider] Found ${cachedEquipments.length} in cache, ${missingIds.length} missing');
// Si tous sont en cache, retourner directement
if (missingIds.isEmpty) {
return cachedEquipments;
}
// Charger les manquants depuis l'API
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
final loadedEquipments = equipmentsData.map((data) {
final id = data['id'] as String; // L'ID vient du backend
return EquipmentModel.fromMap(data, id);
}).toList();
// Ajouter au cache
for (final eq in loadedEquipments) {
if (!_equipment.any((e) => e.id == eq.id)) {
_equipment.add(eq);
}
}
print('[EquipmentProvider] Loaded ${loadedEquipments.length} equipments from API');
// Retourner tous les équipements (cache + chargés)
return [...cachedEquipments, ...loadedEquipments];
} catch (e) {
print('[EquipmentProvider] Error loading equipments by IDs: $e');
rethrow;
}
}
/// Extraire modèles et marques uniques
void _extractUniqueValues() {
final modelSet = <String>{};
final brandSet = <String>{};
for (final eq in _equipment) {
if (eq.model != null && eq.model!.isNotEmpty) {
modelSet.add(eq.model!);
}
if (eq.brand != null && eq.brand!.isNotEmpty) {
brandSet.add(eq.brand!);
}
}
_models = modelSet.toList()..sort();
_brands = brandSet.toList()..sort();
}
/// Obtenir les équipements filtrés
List<EquipmentModel> get _filteredEquipment {
var filtered = _equipment;
if (_selectedCategory != null) {
filtered = filtered.where((eq) => eq.category == _selectedCategory).toList();
}
if (_selectedStatus != null) {
filtered = filtered.where((eq) => eq.status == _selectedStatus).toList();
}
if (_selectedModel != null && _selectedModel!.isNotEmpty) {
filtered = filtered.where((eq) => eq.model == _selectedModel).toList();
}
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filtered = filtered.where((eq) {
return eq.name.toLowerCase().contains(query) ||
eq.id.toLowerCase().contains(query) ||
(eq.model?.toLowerCase().contains(query) ?? false) ||
(eq.brand?.toLowerCase().contains(query) ?? false);
}).toList();
}
return filtered;
}
// ============================================================================
// PAGINATION - Nouvelles méthodes
// ============================================================================
/// Active le mode pagination (pour la page de gestion)
void enablePagination() {
if (!_usePagination) {
_usePagination = true;
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
}
}
/// Désactive le mode pagination (pour les autres pages)
void disablePagination() {
if (_usePagination) {
_usePagination = false;
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
}
}
/// Charge la première page (réinitialise tout)
Future<void> loadFirstPage() async {
DebugLog.info('[EquipmentProvider] Loading first page...');
_paginatedEquipment.clear();
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners();
try {
await loadNextPage();
_isInitialized = true;
} catch (e) {
DebugLog.error('[EquipmentProvider] Error loading first page', e);
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge la page suivante (scroll infini)
Future<void> loadNextPage() async {
if (_isLoadingMore || !_hasMore) {
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
return;
}
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 20,
startAfter: _lastVisible,
category: _selectedCategory?.name,
status: _selectedStatus?.name,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newEquipments = (result['equipments'] as List<dynamic>)
.map((data) {
final map = data as Map<String, dynamic>;
final id = map['id'] as String; // L'ID vient du backend dans le JSON
return EquipmentModel.fromMap(map, id);
})
.toList();
_paginatedEquipment.addAll(newEquipments);
_hasMore = result['hasMore'] as bool? ?? false;
_lastVisible = result['lastVisible'] as String?;
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
} catch (e) {
DebugLog.error('[EquipmentProvider] Error loading next page', e);
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharge en changeant de filtre ou recherche
Future<void> reload() async {
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
await loadFirstPage();
}
/// Définir le filtre de catégorie
void setSelectedCategory(EquipmentCategory? category) async {
if (_selectedCategory == category) return;
_selectedCategory = category;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le filtre de statut
void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le filtre de modèle
void setSelectedModel(String? model) async {
if (_selectedModel == model) return;
_selectedModel = model;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query;
// Annuler le timer précédent
_searchDebounceTimer?.cancel();
if (_usePagination) {
// Attendre 500ms avant de recharger (debouncing)
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
reload();
});
} else {
notifyListeners();
}
}
@override
void dispose() {
_searchDebounceTimer?.cancel();
super.dispose();
}
/// Réinitialiser tous les filtres
void clearFilters() async {
_selectedCategory = null;
_selectedStatus = null;
_selectedModel = null;
_searchQuery = '';
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
// ============================================================================
// MÉTHODES COMPATIBILITÉ (pour ancien code)
// ============================================================================
/// Recharger les équipements (ancien système)
Future<void> refresh() async {
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
}
/// Stream des équipements (pour compatibilité avec ancien code)
Stream<List<EquipmentModel>> get equipmentStream async* {
if (!_isInitialized && !_usePagination) {
await loadEquipments();
}
yield equipment;
}
/// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async {
try {
await _dataService.deleteEquipment(equipmentId);
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
rethrow;
}
}
/// Ajouter un équipement
Future<void> addEquipment(EquipmentModel equipment) async {
try {
await _dataService.createEquipment(equipment.id, equipment.toMap());
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
rethrow;
}
}
/// Mettre à jour un équipement
Future<void> updateEquipment(EquipmentModel equipment) async {
try {
await _dataService.updateEquipment(equipment.id, equipment.toMap());
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
rethrow;
}
}
/// Charger les marques
Future<void> loadBrands() async {
await ensureLoaded();
_extractUniqueValues();
}
/// Charger les modèles
Future<void> loadModels() async {
await ensureLoaded();
_extractUniqueValues();
}
/// Charger les modèles d'une marque spécifique
Future<List<String>> loadModelsByBrand(String brand) async {
await ensureLoaded();
return _equipment
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
.map((eq) => eq.model ?? '')
.where((model) => model.isNotEmpty)
.toSet()
.toList()
..sort();
}
/// Charger les sous-catégories d'une catégorie spécifique
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
await ensureLoaded();
return _equipment
.where((eq) => eq.category == category)
.map((eq) => eq.subCategory ?? '')
.where((sub) => sub.isNotEmpty)
.toSet()
.toList()
..sort();
}
/// Calculer le statut réel d'un équipement (pour badge)
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
// Pour les consommables/câbles, vérifier le seuil critique
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) {
final availableQty = equipment.availableQuantity ?? 0;
final criticalThreshold = equipment.criticalThreshold ?? 0;
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
}
}
// Sinon retourner le statut de base
return equipment.status;
}
}

View File

@@ -1,76 +1,251 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import '../models/event_model.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/performance_monitor.dart';
class EventProvider with ChangeNotifier {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<EventModel> _events = [];
bool _isLoading = false;
// Cache des utilisateurs chargés depuis getEvents
Map<String, Map<String, dynamic>> _usersCache = {};
// Cache pour éviter les rechargements inutiles (ancien système)
DateTime? _lastLoadTime;
String? _lastUserId;
bool _lastCanViewAll = false;
// Nouveau: Cache par mois pour le lazy loading
Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
String? _currentMonth; // Mois actuellement affiché
List<EventModel> get events => _events;
bool get isLoading => _isLoading;
// Récupérer les événements pour un utilisateur spécifique
Future<void> loadUserEvents(String userId,
{bool canViewAllEvents = false}) async {
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
final now = DateTime.now();
final difference = now.difference(_lastLoadTime!);
return difference.inSeconds > 30;
}
/// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
PerformanceMonitor.end('EventProvider.loadUserEvents');
return;
}
_isLoading = true;
notifyListeners();
try {
print(
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
QuerySnapshot eventsSnapshot;
// On charge tous les events pour les users non-admins aussi
eventsSnapshot = await _firestore.collection('events').get();
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
print('Found ${eventsSnapshot.docs.length} events for user');
PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur
final result = await _dataService.getEvents(userId: userId);
PerformanceMonitor.end('EventProvider.getEvents_API');
// On filtre côté client si l'utilisateur n'est pas admin
final allEvents = eventsSnapshot.docs.map((doc) {
print('Event data: ${doc.data()}');
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
if (canViewAllEvents) {
_events = allEvents;
} else {
final userRef = _firestore.collection('users').doc(userId);
_events = allEvents
.where((e) => e.workforce.any((ref) => ref.id == userRef.id))
.toList();
final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>;
// Stocker les utilisateurs dans le cache
_usersCache = usersData.map((key, value) =>
MapEntry(key, value as Map<String, dynamic>)
);
print('Found ${eventsData.length} events from API');
PerformanceMonitor.start('EventProvider.parseEvents');
List<EventModel> allEvents = [];
int failedCount = 0;
// Parser chaque événement
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event);
} catch (e) {
print('Failed to parse event ${eventData['id']}: $e');
failedCount++;
}
}
PerformanceMonitor.end('EventProvider.parseEvents');
print('Parsed ${_events.length} events');
_events = allEvents;
_lastLoadTime = DateTime.now();
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
_isLoading = false;
notifyListeners();
PerformanceMonitor.end('EventProvider.loadUserEvents');
} catch (e) {
print('Error loading events: $e');
_isLoading = false;
notifyListeners();
PerformanceMonitor.end('EventProvider.loadUserEvents');
rethrow;
}
}
// Récupérer un événement spécifique
Future<EventModel?> getEvent(String eventId) async {
try {
final doc = await _firestore.collection('events').doc(eventId).get();
if (doc.exists) {
return EventModel.fromMap(doc.data()!, doc.id);
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
Future<void> loadMonthEvents(String userId, int year, int month,
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
// Vérifier le cache
if (!forceReload && _eventsByMonth.containsKey(monthKey)) {
print('[EventProvider] Using cached events for $monthKey');
if (!silent) {
_currentMonth = monthKey;
_events = _eventsByMonth[monthKey]!;
notifyListeners();
}
return;
}
if (!silent) {
_isLoading = true;
notifyListeners();
}
try {
print('[EventProvider] Loading events for month: $monthKey');
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
final result = await _dataService.getEventsByMonth(
userId: userId,
year: year,
month: month
);
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>;
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
_usersCache.addAll(
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
);
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
PerformanceMonitor.start('EventProvider.parseMonthEvents');
List<EventModel> monthEvents = [];
int failedCount = 0;
// Parser les événements
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
monthEvents.add(event);
} catch (e) {
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
failedCount++;
}
}
PerformanceMonitor.end('EventProvider.parseMonthEvents');
// Stocker dans le cache par mois
_eventsByMonth[monthKey] = monthEvents;
// Mettre à jour _events et _currentMonth seulement si ce n'est pas un préchargement silencieux
if (!silent) {
_currentMonth = monthKey;
_events = monthEvents;
}
// Mettre à jour les infos de cache global
_lastLoadTime = DateTime.now();
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey (${failedCount} failed)');
if (!silent) {
_isLoading = false;
notifyListeners();
}
return null;
} catch (e) {
print('Error getting event: $e');
print('[EventProvider] Error loading month events: $e');
if (!silent) {
_isLoading = false;
notifyListeners();
}
rethrow;
}
}
// Ajouter un nouvel événement
/// Précharger les mois adjacents en arrière-plan
void preloadAdjacentMonths(String userId, int year, int month,
{bool canViewAllEvents = false}) {
// Mois précédent
final prevMonth = month == 1 ? 12 : month - 1;
final prevYear = month == 1 ? year - 1 : year;
// Mois suivant
final nextMonth = month == 12 ? 1 : month + 1;
final nextYear = month == 12 ? year + 1 : year;
print('[EventProvider] Preloading adjacent months...');
// Charger en arrière-plan (sans bloquer l'UI ni notifier)
Future.microtask(() async {
try {
await loadMonthEvents(userId, prevYear, prevMonth,
canViewAllEvents: canViewAllEvents, silent: true);
await loadMonthEvents(userId, nextYear, nextMonth,
canViewAllEvents: canViewAllEvents, silent: true);
print('[EventProvider] Adjacent months preloaded successfully');
} catch (e) {
print('[EventProvider] Error preloading adjacent months: $e');
}
});
}
/// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
}
/// Récupérer un événement spécifique par ID
EventModel? getEventById(String eventId) {
try {
return _events.firstWhere((event) => event.id == eventId);
} catch (e) {
return null;
}
}
/// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async {
try {
final docRef = await _firestore.collection('events').add(event.toMap());
final newEvent = EventModel.fromMap(event.toMap(), docRef.id);
_events.add(newEvent);
// Ajouter l'événement localement dans _events
_events.add(event);
// Ajouter dans le cache par mois
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.add(event);
}
notifyListeners();
} catch (e) {
print('Error adding event: $e');
@@ -78,13 +253,37 @@ class EventProvider with ChangeNotifier {
}
}
// Mettre à jour un événement
/// Mettre à jour un événement
Future<void> updateEvent(EventModel event) async {
try {
await _firestore.collection('events').doc(event.id).update(event.toMap());
// Mise à jour dans _events
final index = _events.indexWhere((e) => e.id == event.id);
if (index != -1) {
final oldEvent = _events[index];
_events[index] = event;
// Mettre à jour dans le cache par mois
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
if (oldMonthKey != newMonthKey) {
if (_eventsByMonth.containsKey(oldMonthKey)) {
_eventsByMonth[oldMonthKey]!.removeWhere((e) => e.id == event.id);
}
if (_eventsByMonth.containsKey(newMonthKey)) {
_eventsByMonth[newMonthKey]!.add(event);
}
} else {
// Même mois, juste mettre à jour
if (_eventsByMonth.containsKey(newMonthKey)) {
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
if (monthIndex != -1) {
_eventsByMonth[newMonthKey]![monthIndex] = event;
}
}
}
notifyListeners();
}
} catch (e) {
@@ -93,11 +292,23 @@ class EventProvider with ChangeNotifier {
}
}
// Supprimer un événement
/// Supprimer un événement
Future<void> deleteEvent(String eventId) async {
try {
await _firestore.collection('events').doc(eventId).delete();
await _dataService.deleteEvent(eventId);
// Trouver l'événement pour obtenir sa date avant de le supprimer
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
// Supprimer de _events
_events.removeWhere((event) => event.id == eventId);
// Supprimer du cache par mois
if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.removeWhere((event) => event.id == eventId);
}
notifyListeners();
} catch (e) {
print('Error deleting event: $e');
@@ -105,9 +316,49 @@ class EventProvider with ChangeNotifier {
}
}
// Vider la liste des événements
/// Récupérer les données d'un utilisateur depuis le cache
Map<String, dynamic>? getUserFromCache(String uid) {
return _usersCache[uid];
}
/// Récupérer les utilisateurs de la workforce d'un événement
List<Map<String, dynamic>> getWorkforceUsers(EventModel event) {
final users = <Map<String, dynamic>>[];
for (final dynamic userRef in event.workforce) {
try {
String? uid;
// Tenter d'extraire l'UID
if (userRef is String) {
uid = userRef;
} else {
// Essayer d'extraire l'ID si c'est une DocumentReference
final ref = userRef as DocumentReference?;
uid = ref?.id;
}
if (uid != null) {
final userData = getUserFromCache(uid);
if (userData != null) {
users.add(userData);
}
}
} catch (e) {
// Ignorer les références invalides
print('Skipping invalid workforce reference: $userRef');
}
}
return users;
}
/// Vider la liste des événements
void clearEvents() {
_events = [];
_lastLoadTime = null;
_lastUserId = null;
_lastCanViewAll = false;
notifyListeners();
}
}

Some files were not shown because too many files have changed in this diff Show More