Compare commits

17 Commits

Author SHA1 Message Date
ElPoyo 0bbc77ffc8 feat: mise à jour de la version de l'application à 1.2.1 et ajout d'un assistant IA pour la gestion des équipements 2026-05-25 23:55:59 +02:00
ElPoyo 19d3dcef69 fix: correction du merge de equipment_selection_dialog.dart (structure invalide) 2026-05-25 23:42:00 +02:00
ElPoyo 32a279e0ae feat: merge branche IA (beta) - Intégration assistant IA logisticien Gemini 2026-05-25 23:35:40 +02:00
ElPoyo 7258509528 feat: amélioration de l'assistant IA logisticien et de la gestion des containers
- **Backend (Cloud Functions)** :
    - Mise à jour de `firebase-functions` vers la version `7.2.5`.
    - Amélioration de la sécurité et de la flexibilité des clés API Gemini (support des variables d'environnement `.env` et `.env.local`).
    - Optimisation de la recherche d'équipements avec une stratégie multi-passes (exacte, par tokens, puis catégorielle/fuzzy).
    - Ajout de nouveaux outils pour l'IA : `check_container_availability` et `check_container_availability_batch` pour vérifier la disponibilité des flight-cases.
    - Implémentation d'un post-traitement automatique suggérant des containers complets si tous leurs équipements internes sont requis par l'événement.
    - Amélioration de la résilience aux erreurs 429/503 de Gemini avec une stratégie d'exponential backoff.

- **Frontend (Flutter)** :
    - Mise à jour du service `AiEquipmentAssistantService` pour gérer les métadonnées détaillées des containers (rationale, items manquants/matchings, disponibilité).
    - Refonte de l'interface `AiEquipmentAssistantDialog` :
        - Affichage enrichi des containers dans le récapitulatif.
        - Ajout de la possibilité de sélectionner/désélectionner manuellement les containers (notamment ceux marqués comme "partiels").
        - Amélioration visuelle (ombres, bordures, icônes de statut de disponibilité).
        - Marquage de l'assistant en mode "BETA".

- **Général** :
    - Mise à jour du `.gitignore` pour inclure `functions/.env.local`.
    - Correction de typos et amélioration du logging de debug dans le backend.
2026-05-25 23:00:43 +02:00
ElPoyo 7fc28f4374 feat: (BETA) Amélioration de l'assistant IA logisticien (Gemini) et support des documents
- **Amélioration de l'IA (Cloud Functions)** :
    - Mise à jour du modèle vers `gemini-3.1-flash-lite` et augmentation de la limite des résultats de recherche à 50.
    - Optimisation de la gestion des outils : augmentation du nombre d'appels simultanés (`MAX_TOOL_CALLS_PER_ITERATION`) à 40.
    - Refonte du système de recherche d'équipements avec une stratégie en deux passes (recherche précise puis catégorielle avec normalisation agressive).
    - Nouvelles consignes strictes pour la gestion des unités uniques (quantité de 1 par ID) et priorité aux flight cases (containers).
    - Ajout d'une gestion de retry avec temporisation pour les erreurs de quota (429) et de surcharge (503).
    - Support de l'analyse de documents joints (devis, listes) envoyés en `inlineData`.

- **Interface de l'Assistant (`AiEquipmentAssistantDialog`)** :
    - Ajout de la possibilité de joindre des documents (PDF, images, texte) via `FilePicker` pour analyse par l'IA.
    - Implémentation d'une vue de logs de debug détaillée pour suivre le raisonnement de l'IA et les appels d'outils.
    - Amélioration visuelle de la discussion : bulles de message stylisées et structuration automatique des réponses (sections "Matériel ajouté" vs "Matériel non trouvé").
    - Nouvelles options de confirmation : "Tout ajouter" ou "Ajouter sans alternatives".

- **Modèles et Services** :
    - Mise à jour de `EventEquipment` pour inclure un champ `rationale` (justification du choix de l'équipement).
    - Correction dans `EventAssignedEquipmentSection` pour ajouter automatiquement les équipements enfants lors de l'ajout d'un container proposé par l'IA.
    - Ajout de la gestion des logs et des documents dans `AiEquipmentAssistantService`.

- **UI Divers** :
    - Mise à jour de `EquipmentFormPage` pour clarifier le comportement de l'identifiant (auto-génération recommandée).
2026-05-25 20:33:59 +02:00
ElPoyo af5ecaeee1 feat: optimisation du démarrage de l'application et de la gestion de l'authentification
- **Refonte du démarrage** : Mise en place d'un `AppInitializer` pour gérer l'initialisation asynchrone de Firebase et du cache en arrière-plan, réduisant le travail synchrone au lancement.
- **Sécurisation de l'authentification** :
    - Création d'un `AppStartGate` pour gérer proprement la restauration de la session Firebase Auth et les erreurs potentielles sur le Web.
    - Amélioration du `LocalUserProvider` avec un "bootstrap léger" permettant de rendre l'UID disponible immédiatement avant le chargement complet du profil.
    - Ajout de protections contre les erreurs d'accès à `FirebaseAuth.instance` (notamment pour les problèmes d'interop JS sur le Web).
- **Optimisation de l'UI** :
    - Remplacement du `AutoLoginWrapper` par une gestion plus robuste de la navigation post-authentification.
    - Amélioration de l'`AuthGuard` pour permettre l'affichage de certains écrans (comme le calendrier) pendant le chargement des données utilisateur (`allowWhileLoading`).
    - Ajout d'un écran de splash screen uniformisé (`StartupSplashScreen`).
- **Services & Cache** :
    - Introduction de `CacheService` utilisant `shared_preferences` pour le stockage local léger.
    - Refactoring des services (`AlertService`, `EmailService`, `FirebaseStorageManager`) pour accéder aux instances Firebase de manière plus flexible via des getters.
    - Mise à jour des dépendances dans `pubspec.yaml` pour inclure `shared_preferences`.
- **Calendrier** : Ajout d'une logique de chargement initial différé des événements (`_scheduleInitialEventsLoad`) pour éviter les appels redondants au démarrage.
- **Maintenance** : Mise à jour de la version de l'application à `1.1.23` et nettoyage des fichiers de cache de déploiement.
2026-05-05 12:25:45 +02:00
ElPoyo eac103491f feat: recherche d'événements et gestion avancée de la suppression d'équipement
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
    - Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
    - Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
    - Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
    - Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
    - Refonte de l'interface mobile pour intégrer la barre de recherche.
    - Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
    - Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
    - Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
    - Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
2026-04-22 12:25:37 +02:00
ElPoyo 0551f0b9c1 feat: Mise à jour à la version 1.1.20 et amélioration de la recherche d'équipements
- Mise à jour de la version de l'application à `1.1.20` dans `app_version.dart`, `version.json` et `CHANGELOG.md`.
- Optimisation de la fonction Cloud `getEquipmentsPaginated` pour supporter la recherche par ID exact (document ID ou ID legacy) et améliorer la recherche textuelle avec filtrage par lots.
- Amélioration de la gestion des alertes dans `processEquipmentValidation.js` :
    - Ajout d'un statut `NOT_TAKEN` pour éviter les fausses alertes d'équipements perdus s'ils n'ont jamais été emportés.
    - Refonte complète du parsing des dates Firestore pour une meilleure robustesse dans les alertes.
    - Correction de la validation des quantités (vérification du type `number`).
- Ajout de méthodes statiques dans `EventPreparationService` (`shouldMarkEquipmentAsLost`, `isEquipmentNotTakenToEvent`) pour centraliser la logique de détermination du statut des équipements au retour.
- Mise à jour de `EventPreparationPage` pour intégrer le nouveau statut `NOT_TAKEN` et utiliser la logique centralisée du service de préparation.
- Mise à jour des fichiers de cache Firebase Hosting.
2026-03-30 17:12:48 +02:00
ElPoyo 89ab3673c4 ### Key Changes:
**AI Equipment Proposal (`functions/aiEquipmentProposal.js`):**
- Updated Gemini model to `gemini-3.1-flash-lite-preview` and updated the API key.
- Increased `MAX_TOOL_ITERATIONS` from 12 to 20.
- Added a new tool `list_equipment_by_category` to allow the AI to browse equipment when specific searches fail.
- Enhanced the system prompt with instructions to handle typos via category exploration and authorized more creative equipment suggestions based on event descriptions.
- Improved the user prompt to include more event context (name, location, notes, and options).
- Set `responseMimeType: 'application/json'` in the generation config for better reliability.
- Improved error logging and user-facing error messages for timeouts.

**UI & Pagination (`lib/views/`):**
- **ContainerFormPage**: Replaced `StreamBuilder` with a paginated list using `DataService` for equipment selection. Added a scroll controller to support infinite scrolling and updated UI colors to use the newer `withValues` API.
- **EquipmentSelectionDialog**:
    - Increased pagination limit from 25 to 50 items.
    - Implemented `_checkIfMoreItemsNeeded` logic to automatically fetch more pages if filters (like hiding conflicting items) leave the view too empty.
    - Added a `NotificationListener` to the `ListView` to trigger pagination on scroll.
    - Fixed minor encoding issues in comments.

---

### Proposed Commit Message:

feat: Mise à jour du modèle Gemini et optimisation de la sélection du matériel avec pagination

- Mise à jour du modèle d'IA vers `gemini-3.1-flash-lite-preview` et augmentation de la limite d'itérations des outils à 20.
- Ajout de l'outil `list_equipment_by_category` pour permettre à l'IA d'explorer les alternatives en cas d'échec de recherche textuelle.
- Enrichissement du prompt système et du contexte envoyé à l'IA (nom, lieu, notes et options de l'événement).
- Implémentation de la pagination dans `ContainerFormPage` pour la sélection d'équipements afin d'améliorer les performances.
- Optimisation de `EquipmentSelectionDialog` avec chargement automatique des pages suivantes si les filtres réduisent trop la liste visible.
- Passage à `withValues` pour la gestion des couleurs et amélioration de la gestion des erreurs et du logging.
2026-03-30 12:32:33 +02:00
ElPoyo cf13b4a986 .env gitignore 2026-03-28 21:38:56 +01:00
ElPoyo 3f80d9318b feat: Mise à jour à la version 1.1.19 et amélioration du cache/pagination pour la sélection d'équipements
- Mise à jour de la version de l'application à `1.1.19` dans `app_version.dart` et `version.json`.
- Correction d'un bug de cache dans `EquipmentSelectionDialog` qui empêchait l'affichage de certains équipements lors de la sélection.
- Introduction d'une fonction utilitaire `shouldAutoLoadNextPage` et de tests unitaires associés pour fiabiliser le chargement automatique des données.
- Ajout d'une gestion de préchargement automatique dans `EquipmentSelectionDialog` lorsque la liste n'est pas assez longue pour activer le défilement (évite les vues tronquées).
- Amélioration de `ContainerFormPage` pour forcer le rechargement complet de la liste des équipements, évitant ainsi les conflits avec les états de pagination d'autres écrans.
- Optimisation du chargement des conflits de disponibilité et des quantités via un chargement par lots (batch).
- Nettoyage du code et amélioration de la lisibilité des fichiers `container_form_page.dart` et `equipment_selection_dialog.dart`.
2026-03-24 12:18:00 +01:00
ElPoyo 84c882ac0b feat: Intégration d'un assistant IA logisticien basé sur Gemini
- Ajout d'une Cloud Function `aiEquipmentProposal` utilisant le modèle Gemini avec function calling pour suggérer du matériel et des containers.
- Implémentation de plusieurs outils (tools) côté serveur pour permettre à l'IA d'interagir avec Firestore : `search_equipment`, `check_availability_batch`, `get_past_events`, `search_event_reference` et `search_containers`.
- Ajout de la dépendance `@google/generative-ai` dans le backend.
- Création d'un service Flutter `AiEquipmentAssistantService` pour communiquer avec la nouvelle Cloud Function.
- Ajout d'une interface de dialogue `AiEquipmentAssistantDialog` permettant aux utilisateurs de discuter avec l'IA pour affiner les propositions de matériel.
- Intégration de l'assistant IA dans la section de gestion du matériel des événements (`EventAssignedEquipmentSection`).
- Mise à jour de `DataService` avec de nouvelles méthodes de recherche et de vérification de disponibilité optimisées pour l'assistant.
- Activation du mode développement et configuration des identifiants de test dans `env.dart`.
- Optimisation des paramètres de la Cloud Function (timeout de 300s et 1GiB de RAM) pour supporter les traitements IA.
2026-03-24 12:00:30 +01:00
ElPoyo ecf4a5cede feat: mise à jour de la version à 1.1.18 et amélioration de la page calendrier avec ajout de la fonctionnalité de rafraîchissement des événements 2026-03-12 21:14:44 +01:00
ElPoyo 6737ad80e4 feat: mise à jour v1.1.17 et ajout du tableau de bord des statistiques d'événements
- Mise à jour de la version de l'application à `1.1.17` dans `app_version.dart` et `version.json`.
- Création d'un module complet de statistiques (`EventStatisticsPage`, `EventStatisticsService`, `EventStatisticsTab`) permettant de filtrer et visualiser les KPI d'événements (montants HT/TTC, panier moyen, répartition par type, top options).
- Ajout d'une entrée "Statistiques événements" dans le menu latéral (`MainDrawer`) protégée par la permission `generate_reports`.
- Migration exclusive vers Google Cloud TTS dans `SmartTextToSpeechService` et suppression de `TextToSpeechService` (Web Speech API native) pour garantir une compatibilité maximale sur tous les navigateurs.
- Mise à jour des dépendances dans `pubspec.yaml` (`google_fonts`, `flutter_secure_storage`, `mobile_scanner`, `flutter_local_notifications`).
- Migration du code d'export ICS vers `package:web` pour remplacer l'utilisation de `dart:html` obsolète.
- Mise à jour du `CHANGELOG.md` documentant les statistiques et l'évolution du service de synthèse vocale.
2026-03-12 15:05:28 +01:00
ElPoyo afa2c35c90 feat: Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS 2026-03-10 15:08:30 +01:00
ElPoyo 36b420639d feat: Ajout d'un support spécifique pour Chromium dans le service TTS 2026-03-10 14:37:49 +01:00
Paul 9bd4b29967 refactor: Rename date parsing helper functions for consistency 2026-03-09 11:17:03 +01:00
74 changed files with 8080 additions and 1618 deletions
+16 -16
View File
@@ -1,3 +1,4 @@
test_audio_tts.js,1772996026925,be4d2d713c256578bc16646116e3e81fc2627a1d89e45b211318b51e3612f259
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2 manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4 flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0 favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
@@ -22,8 +23,8 @@ assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d6
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
assets/assets/sounds/ok.mp3,1771938119844,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3 assets/assets/sounds/ok.mp3,1772996026461,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
assets/assets/sounds/error.mp3,1771938125144,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b assets/assets/sounds/error.mp3,1772996026458,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703 assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1 assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
@@ -33,17 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1772532792027,2b3f91e827bc27a1901342a048b1bd81d0aabc50935717f9851e1a3ad6cb7411 version.json,1779745850580,c83e8cef9f09921b50bea3e26017c353fb516d339f57fbd0a8d3696f1ffc0e42
test_audio_tts.js,1772532705302,d7b70556456d3b5e7832506b2dafe31480d94db8d0027b89c1633cc9b5c5bdae index.html,1779745856220,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
index.html,1772532797157,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 flutter_bootstrap.js,1779745856203,79bfcfd09b63ba083702fd55c660d283686d9571b49febd8dcab49abbdf6f683
flutter_bootstrap.js,1772532797146,ca3df8691f4db5962ed165489bd051dfd31307628ab4f1ee68842dc747d39fd9 flutter_service_worker.js,1779745934512,3d18931ea97b2eeeba61c4fe7c0c8d736cc42ef9b8c2a6e4ec21e83e14e351ae
flutter_service_worker.js,1772532894886,9ce6b8d9f09c957b763a8d3db3baf03c96d4f84e805f6d629294749d9966cfad assets/FontManifest.json,1779745931038,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/FontManifest.json,1772532889954,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 assets/AssetManifest.bin.json,1779745931038,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.json,1772532889954,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 assets/AssetManifest.bin,1779745931038,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/AssetManifest.bin.json,1772532889954,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 assets/AssetManifest.json,1779745931038,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin,1772532889954,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 assets/shaders/ink_sparkle.frag,1779745931235,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/shaders/ink_sparkle.frag,1772532890224,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779745933681,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1772532893514,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb assets/fonts/MaterialIcons-Regular.otf,1779745933686,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
assets/fonts/MaterialIcons-Regular.otf,1772532893530,71c7128cf890cf3e18fffca405a98480f174bb3fa79d20c575b473d36c8c3093 assets/NOTICES,1779745931041,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
assets/NOTICES,1772532889955,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74 main.dart.js,1779745928953,60d92269024a5be234c7da2ebb889584e20c66a262b28f6d531a3f90c83767b3
main.dart.js,1772532888607,df89975075062e0983691b8997b9e4a1ae4b4d5dfe6c06ca5b42ffa5407fdd3f
+3
View File
@@ -45,3 +45,6 @@ app.*.map.json
# Environment configuration with credentials # Environment configuration with credentials
lib/config/env.dev.dart lib/config/env.dev.dart
functions/.env functions/.env
.env
env.dart
functions/.env.local
+26
View File
@@ -2,6 +2,32 @@
Toutes les modifications notables de ce projet seront documentées dans ce fichier. Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 25/05/2026
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
## 04/05/2026
Optimisation du lancement de l'application et amélioration de la gestion du cache.
## 22/04/2026
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
## 30/03/2026
Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.
## 24/03/2026
Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.
## 12/03/2026bis
Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.
## 12/03/2026
Ajout d'une page de statistiques détaillées pour les équipements et les événements.
## 10/03/2026
Migration vers Google Cloud TTS exclusif pour une compatibilité maximale sur tous les navigateurs. Suppression du TTS local (Web Speech API) qui causait des problèmes de compatibilité sur certaines configurations (notamment Chromium/Linux).
Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS. Résolution bug d'affichage des événements pour les membres CREW
## 24/02/2026 ## 24/02/2026
Ajout de la gestion des maintenance et synthèse vocale Ajout de la gestion des maintenance et synthèse vocale
+1
View File
@@ -7,3 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
# URL de l'application # URL de l'application
APP_URL="https://app.em2events.fr" APP_URL="https://app.em2events.fr"
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
/**
* Cloud Function: generateTTS
* Génère de l'audio TTS avec Google Cloud Text-to-Speech
* Avec système de cache dans Firebase Storage
*/
const textToSpeech = require('@google-cloud/text-to-speech');
const crypto = require('crypto');
const logger = require('firebase-functions/logger');
/**
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
* @param {string} text - Texte à hasher
* @return {string} Hash MD5
*/
function generateCacheKey(text, voiceConfig = {}) {
const cacheString = JSON.stringify({
text,
lang: voiceConfig.languageCode || 'fr-FR',
voice: voiceConfig.name || 'fr-FR-Standard-B',
});
return crypto.createHash('md5').update(cacheString).digest('hex');
}
/**
* Génère l'audio TTS et le stocke dans Firebase Storage
* @param {string} text - Texte à synthétiser
* @param {object} storage - Instance Firebase Storage
* @param {object} bucket - Bucket Firebase Storage
* @param {object} voiceConfig - Configuration de la voix (optionnel)
* @return {Promise<{audioUrl: string, cached: boolean}>}
*/
async function generateTTS(text, storage, bucket, voiceConfig = {}) {
try {
// Validation du texte
if (!text || text.trim().length === 0) {
throw new Error('Text cannot be empty');
}
if (text.length > 5000) {
throw new Error('Text too long (max 5000 characters)');
}
// Configuration par défaut de la voix
const defaultVoiceConfig = {
languageCode: 'fr-FR',
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
ssmlGender: 'MALE',
};
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
// Générer la clé de cache
const cacheKey = generateCacheKey(text, finalVoiceConfig);
const fileName = `tts-cache/${cacheKey}.mp3`;
const file = bucket.file(fileName);
// Vérifier si le fichier existe déjà dans le cache
const [exists] = await file.exists();
if (exists) {
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
// Générer une URL signée valide 7 jours
const [url] = await file.getSignedUrl({
action: 'read',
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
return {
audioUrl: url,
cached: true,
cacheKey,
};
}
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
cacheKey,
text: text.substring(0, 50),
voice: finalVoiceConfig.name,
});
// Créer le client Text-to-Speech
const client = new textToSpeech.TextToSpeechClient();
// Configuration de la requête
const request = {
input: { text: text },
voice: finalVoiceConfig,
audioConfig: {
audioEncoding: 'MP3',
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
pitch: -2.0, // Voix un peu plus grave
volumeGainDb: 0.0,
},
};
// Appeler l'API Google Cloud TTS
const [response] = await client.synthesizeSpeech(request);
if (!response.audioContent) {
throw new Error('No audio content returned from TTS API');
}
logger.info('[generateTTS] ✓ Audio generated', {
size: response.audioContent.length,
});
// Sauvegarder dans Firebase Storage
await file.save(response.audioContent, {
metadata: {
contentType: 'audio/mpeg',
metadata: {
text: text.substring(0, 100), // Premier 100 caractères pour debug
voice: finalVoiceConfig.name,
generatedAt: new Date().toISOString(),
},
},
});
logger.info('[generateTTS] ✓ Audio cached', { fileName });
// Générer une URL signée
const [url] = await file.getSignedUrl({
action: 'read',
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
return {
audioUrl: url,
cached: false,
cacheKey,
};
} catch (error) {
logger.error('[generateTTS] ✗ Error', {
error: error.message,
code: error.code,
text: text?.substring(0, 50),
});
throw error;
}
}
module.exports = { generateTTS, generateCacheKey };
+427 -48
View File
@@ -3,8 +3,11 @@
* Architecture backend sécurisée avec authentification et permissions * Architecture backend sécurisée avec authentification et permissions
*/ */
// Charger les variables d'environnement depuis .env // Charger les variables d'environnement depuis .env.local (développement)
require('dotenv').config(); // ou .env (production Firebase)
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
require('dotenv').config({ path: path.join(__dirname, '.env') });
const { onRequest, onCall } = require("firebase-functions/v2/https"); const { onRequest, onCall } = require("firebase-functions/v2/https");
const { onSchedule } = require("firebase-functions/v2/scheduler"); const { onSchedule } = require("firebase-functions/v2/scheduler");
@@ -16,6 +19,8 @@ const { Storage } = require('@google-cloud/storage');
// Utilitaires // Utilitaires
const auth = require('./utils/auth'); const auth = require('./utils/auth');
const helpers = require('./utils/helpers'); const helpers = require('./utils/helpers');
const { generateTTS } = require('./generateTTS');
const { handleAiEquipmentProposal } = require('./aiEquipmentProposal');
// Initialisation sécurisée // Initialisation sécurisée
if (!admin.apps.length) { if (!admin.apps.length) {
@@ -32,6 +37,13 @@ const httpOptions = {
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS // Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
}; };
// Options dédiées pour les traitements IA potentiellement longs.
const aiHttpOptions = {
...httpOptions,
timeoutSeconds: 300,
memory: '1GiB',
};
// ============================================================================ // ============================================================================
// CORS Middleware // CORS Middleware
// ============================================================================ // ============================================================================
@@ -202,29 +214,51 @@ exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => {
return; return;
} }
const { equipmentId } = req.body.data; const { equipmentId, forceDelete = false } = req.body.data;
if (!equipmentId) { if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' }); res.status(400).json({ error: 'Equipment ID is required' });
return; return;
} }
// Vérifier si l'équipement est utilisé dans des événements actifs // Vérifier si l'équipement est utilisé dans des événements à venir
const eventsSnapshot = await db.collection('events') const eventsSnapshot = await db.collection('events')
.where('status', '!=', 'CANCELLED') .where('status', '!=', 'CANCELLED')
.get(); .get();
const now = new Date();
const upcomingEvents = [];
for (const eventDoc of eventsSnapshot.docs) { for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data(); const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || []; const assignedEquipment = eventData.assignedEquipment || [];
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) { if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
res.status(409).json({ continue;
error: 'Cannot delete equipment: it is assigned to active events',
eventId: eventDoc.id
});
return;
} }
let eventStart = null;
if (eventData.StartDateTime) {
eventStart = eventData.StartDateTime.toDate
? eventData.StartDateTime.toDate()
: new Date(eventData.StartDateTime);
}
if (eventStart && eventStart > now) {
upcomingEvents.push({
eventId: eventDoc.id,
eventName: eventData.Name || '',
startDate: eventStart.toISOString(),
});
}
}
if (upcomingEvents.length > 0 && !forceDelete) {
res.status(409).json({
error: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events',
upcomingEvents,
});
return;
} }
await db.collection('equipments').doc(equipmentId).delete(); await db.collection('equipments').doc(equipmentId).delete();
@@ -1863,6 +1897,116 @@ exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => {
} }
})); }));
const normalizeSearchText = (value) => {
return (value || '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
};
const getEventStartDate = (eventData) => {
const startValue = eventData.StartDateTime;
if (!startValue) {
return null;
}
if (startValue.toDate) {
return startValue.toDate();
}
const parsedDate = new Date(startValue);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
};
const getEventWorkforceUids = (eventData) => {
if (!eventData.workforce || !Array.isArray(eventData.workforce)) {
return [];
}
return eventData.workforce
.map((userRef) => {
if (userRef && userRef.id) {
return userRef.id;
}
if (typeof userRef === 'string' && userRef.startsWith('users/')) {
return userRef.split('/')[1];
}
return null;
})
.filter((uid) => uid !== null);
};
const serializeEventSearchResult = (doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: getEventWorkforceUids(data),
};
};
// ============================================================================
// EVENTS - Search
// ============================================================================
exports.searchEvents = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { userId, query, limit = 20 } = req.body.data || {};
const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20;
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
res.status(200).json({ events: [] });
return;
}
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
let eventsSnapshot;
if (canViewAll) {
eventsSnapshot = await db.collection('events').get();
} else {
const userRef = db.collection('users').doc(userId || decodedToken.uid);
eventsSnapshot = await db.collection('events')
.where('workforce', 'array-contains', userRef)
.get();
}
const matchingEvents = eventsSnapshot.docs
.filter((doc) => {
const eventData = doc.data();
const startDate = getEventStartDate(eventData);
const searchableText = normalizeSearchText([
eventData.Name,
eventData.Description,
eventData.Address,
startDate ? startDate.toLocaleString('fr-FR') : '',
startDate ? startDate.toISOString() : '',
].join(' '));
return searchableText.includes(normalizedQuery);
})
.sort((a, b) => {
const startA = getEventStartDate(a.data()) || new Date(0);
const startB = getEventStartDate(b.data()) || new Date(0);
return startA.getTime() - startB.getTime();
})
.slice(0, maxResults)
.map((doc) => serializeEventSearchResult(doc));
res.status(200).json({ events: matchingEvents });
} catch (error) {
logger.error('Error searching events:', error);
res.status(500).json({ error: error.message });
}
}));
/** /**
* Récupère un événement avec tous les détails (équipements complets + containers avec enfants) * Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
* Optimisé pour la page de préparation et l'affichage détaillé * Optimisé pour la page de préparation et l'affichage détaillé
@@ -3824,18 +3968,97 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
// Convertir en majuscules pour correspondre au format Firestore // Convertir en majuscules pour correspondre au format Firestore
const category = params.category ? params.category.toUpperCase() : null; const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null; const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null; const rawSearchQuery = typeof params.searchQuery === 'string' ? params.searchQuery.trim() : '';
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, '') : null;
const sortBy = params.sortBy || 'id'; const sortBy = params.sortBy || 'id';
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc'; const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`); logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Fast-path pour une recherche d'ID exact: évite le cap queryLimit lors d'une recherche précise.
if (searchQuery && !startAfterId) {
const exactIdCandidates = Array.from(new Set([
rawSearchQuery,
rawSearchQuery.toUpperCase(),
rawSearchQuery.toLowerCase()
].filter(Boolean)));
for (const candidateId of exactIdCandidates) {
const exactDoc = await db.collection('equipments').doc(candidateId).get();
if (!exactDoc.exists) {
continue;
}
const exactData = exactDoc.data() || {};
const matchesCategory = !category || exactData.category === category;
const matchesStatus = !status || exactData.status === status;
if (!matchesCategory || !matchesStatus) {
continue;
}
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
id: exactDoc.id
};
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1
});
return;
}
// Compatibilité legacy: certains documents peuvent stocker un ancien champ `id` différent du document ID.
for (const legacyId of exactIdCandidates) {
let legacyIdQuery = db.collection('equipments').where('id', '==', legacyId);
if (category) {
legacyIdQuery = legacyIdQuery.where('category', '==', category);
}
if (status) {
legacyIdQuery = legacyIdQuery.where('status', '==', status);
}
const legacySnapshot = await legacyIdQuery.limit(1).get();
if (legacySnapshot.empty) {
continue;
}
const exactDoc = legacySnapshot.docs[0];
const exactData = exactDoc.data() || {};
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
id: exactDoc.id
};
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1
});
return;
}
}
// Construire la requête Firestore // Construire la requête Firestore
let query = db.collection('equipments'); let query = db.collection('equipments');
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres // Appliquer les filtres
if (category) { if (category) {
query = query.where('category', '==', category); query = query.where('category', '==', category);
@@ -3860,20 +4083,10 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
} }
} }
// Limiter les résultats const timestampFields = ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'];
query = query.limit(queryLimit + 1);
const snapshot = await query.get(); const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
// Déterminer hasMore basé sur le nombre de documents Firestore
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
let equipments = docsToProcess.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment // Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) { if (!canManage) {
@@ -3881,36 +4094,116 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
delete data.rentalPrice; delete data.rentalPrice;
} }
return { const legacyId = typeof data.id === 'string' ? data.id : '';
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
// Filtrage textuel côté serveur return {
if (searchQuery) { ...helpers.serializeTimestamps(data, timestampFields),
equipments = equipments.filter(eq => { id: doc.id,
const searchableText = [ _legacyId: legacyId
eq.name || '', };
eq.id || '', };
eq.model || '',
eq.brand || '', const matchesSearchQuery = (equipment) => {
eq.subCategory || '' const searchableText = [
].join(' ').toLowerCase(); equipment.name || '',
return searchableText.includes(searchQuery); equipment.id || '',
equipment._legacyId || '',
equipment.model || '',
equipment.brand || '',
equipment.subCategory || ''
].join(' ').toLowerCase();
if (searchableText.includes(searchQuery)) {
return true;
}
if (!compactSearchQuery) {
return false;
}
const compactSearchableText = searchableText.replace(/[\s_-]+/g, '');
return compactSearchableText.includes(compactSearchQuery);
};
if (!searchQuery) {
const snapshot = await query.limit(limit + 1).get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > limit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
const limitedEquipments = docsToProcess
.map(mapEquipmentDoc)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length
}); });
return;
} }
// Pour la limite finale après filtrage textuel // En mode recherche, scanner la collection par lots jusqu'à obtenir `limit + 1` matchs
const limitedEquipments = equipments.slice(0, limit); // afin de garantir des résultats même si les documents pertinents sont loin dans l'ordre de tri.
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
const matchedEquipments = [];
let scannedDocuments = 0;
let searchQueryRef = query;
let hasMoreMatches = false;
let hasMoreDocsToScan = true;
while (hasMoreDocsToScan && !hasMoreMatches) {
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
if (snapshot.empty) {
hasMoreDocsToScan = false;
break;
}
scannedDocuments += snapshot.docs.length;
for (const doc of snapshot.docs) {
const equipment = mapEquipmentDoc(doc);
if (!matchesSearchQuery(equipment)) {
continue;
}
matchedEquipments.push(equipment);
if (matchedEquipments.length > limit) {
hasMoreMatches = true;
break;
}
}
if (hasMoreMatches) {
break;
}
if (snapshot.docs.length < searchBatchSize) {
hasMoreDocsToScan = false;
break;
}
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
searchQueryRef = query.startAfter(lastDocInBatch);
}
const limitedEquipments = matchedEquipments
.slice(0, limit)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null; const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`); logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({ res.status(200).json({
equipments: limitedEquipments, equipments: limitedEquipments,
hasMore: hasMoreDocs, hasMore: hasMoreMatches,
lastVisible, lastVisible,
total: limitedEquipments.length total: limitedEquipments.length
}); });
@@ -4193,3 +4486,89 @@ exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
})); }));
// ============================================================================
// TEXT-TO-SPEECH - Generate TTS Audio
// ============================================================================
// Options HTTP spécifiques pour TTS avec CORS activé
const ttsHttpOptions = {
cors: true, // Activer CORS automatique
invoker: 'public',
region: 'europe-west9',
};
exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => {
try {
// Authentification utilisateur
const decodedToken = await auth.authenticateUser(req);
logger.info('[generateTTSV2] Request from user:', {
uid: decodedToken.uid,
email: decodedToken.email,
});
// Récupération des paramètres
const { text, voiceConfig } = req.body.data || {};
// Validation
if (!text) {
res.status(400).json({ error: 'Text parameter is required' });
return;
}
if (text.length > 5000) {
res.status(400).json({ error: 'Text too long (max 5000 characters)' });
return;
}
// Génération de l'audio avec cache
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
const result = await generateTTS(text, storage, bucket, voiceConfig);
logger.info('[generateTTSV2] ✓ Success', {
cached: result.cached,
cacheKey: result.cacheKey,
});
res.status(200).json({
audioUrl: result.audioUrl,
cached: result.cached,
cacheKey: result.cacheKey,
});
} catch (error) {
logger.error('[generateTTSV2] ✗ Error:', {
error: error.message,
code: error.code,
});
// Gestion des erreurs spécifiques
if (error.code === 'PERMISSION_DENIED') {
res.status(403).json({ error: 'Permission denied. Check Google Cloud TTS API is enabled.' });
} else if (error.code === 'QUOTA_EXCEEDED') {
res.status(429).json({ error: 'TTS quota exceeded. Try again later.' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// ============================================================================
// AI - Assistant Logisticien (Gemini avec function calling côté serveur)
// ============================================================================
exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors(async (req, res) => {
try {
// Authentification Firebase obligatoire (pas de clé API côté client)
await auth.authenticateUser(req);
await handleAiEquipmentProposal(req, res);
} catch (error) {
logger.error('[aiEquipmentProposal] Error:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
}));
+34 -32
View File
@@ -7,11 +7,13 @@
"name": "functions", "name": "functions",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"envdot": "^0.0.3", "envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3", "firebase-functions": "^7.2.5",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
}, },
@@ -772,12 +774,32 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/@google-cloud/text-to-speech": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/@google-cloud/text-to-speech/-/text-to-speech-5.8.1.tgz",
"integrity": "sha512-HXyZBtfQq+ETSLwWV/k3zFRWSzt+KEfiC5/OqXNNUed+lU/LEyN0CsqqEmkFfkL8BPsVIMAK2xiYCaDsKENukg==",
"license": "Apache-2.0",
"dependencies": {
"google-gax": "^4.0.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@google/generative-ai": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@grpc/grpc-js": { "node_modules/@grpc/grpc-js": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@grpc/proto-loader": "^0.7.13", "@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2" "@js-sdsl/ordered-map": "^4.4.2"
@@ -791,7 +813,6 @@
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"lodash.camelcase": "^4.3.0", "lodash.camelcase": "^4.3.0",
"long": "^5.0.0", "long": "^5.0.0",
@@ -1310,7 +1331,6 @@
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"license": "MIT", "license": "MIT",
"optional": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/js-sdsl" "url": "https://opencollective.com/js-sdsl"
@@ -1631,8 +1651,7 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
@@ -1845,7 +1864,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1855,7 +1873,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -2379,7 +2396,6 @@
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^4.2.0", "string-width": "^4.2.0",
@@ -2412,7 +2428,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -2425,7 +2440,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
@@ -2727,7 +2741,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
@@ -2831,7 +2844,6 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -3352,9 +3364,9 @@
} }
}, },
"node_modules/firebase-functions": { "node_modules/firebase-functions": {
"version": "7.0.3", "version": "7.2.5",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==", "integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -3373,7 +3385,8 @@
"peerDependencies": { "peerDependencies": {
"@apollo/server": "^5.2.0", "@apollo/server": "^5.2.0",
"@as-integrations/express4": "^1.1.2", "@as-integrations/express4": "^1.1.2",
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
"graphql": "^16.12.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@apollo/server": { "@apollo/server": {
@@ -3381,6 +3394,9 @@
}, },
"@as-integrations/express4": { "@as-integrations/express4": {
"optional": true "optional": true
},
"graphql": {
"optional": true
} }
} }
}, },
@@ -3576,7 +3592,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -3715,7 +3730,6 @@
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.10.9", "@grpc/grpc-js": "^1.10.9",
"@grpc/proto-loader": "^0.7.13", "@grpc/proto-loader": "^0.7.13",
@@ -3743,7 +3757,6 @@
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
@@ -4093,7 +4106,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5110,8 +5122,7 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/lodash.clonedeep": { "node_modules/lodash.clonedeep": {
"version": "4.5.0", "version": "4.5.0",
@@ -5490,7 +5501,6 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
} }
@@ -5843,7 +5853,6 @@
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"protobufjs": "^7.2.5" "protobufjs": "^7.2.5"
}, },
@@ -6035,7 +6044,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6517,7 +6525,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -6532,7 +6539,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -6997,7 +7003,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -7035,7 +7040,6 @@
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -7052,7 +7056,6 @@
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cliui": "^8.0.1", "cliui": "^8.0.1",
@@ -7071,7 +7074,6 @@
"version": "21.1.1", "version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
+3 -1
View File
@@ -15,11 +15,13 @@
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"envdot": "^0.0.3", "envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3", "firebase-functions": "^7.2.5",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
}, },
+51 -6
View File
@@ -50,6 +50,11 @@ exports.processEquipmentValidation = onCall({
for (const equipment of equipmentList) { for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment; const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
if (status === 'NOT_TAKEN') {
continue;
}
// Cas 1: Équipement PERDU // Cas 1: Équipement PERDU
if (status === 'LOST') { if (status === 'LOST') {
const alertData = await createAlertInFirestore({ const alertData = await createAlertInFirestore({
@@ -91,7 +96,9 @@ exports.processEquipmentValidation = onCall({
} }
// Cas 3: Quantité incorrecte // Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) { const hasExpectedQuantity = typeof expectedQuantity === 'number';
const hasActualQuantity = typeof quantity === 'number';
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({ const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH', type: 'QUANTITY_MISMATCH',
severity: 'INFO', severity: 'INFO',
@@ -409,10 +416,48 @@ async function sendAlertEmails(alert, userIds) {
* Formate la date d'un événement * Formate la date d'un événement
*/ */
function formatEventDate(event) { function formatEventDate(event) {
if (event.startDate) { const rawDate =
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate); event?.StartDateTime ||
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'}); event?.startDateTime ||
} event?.startDate ||
return 'Date inconnue'; event?.eventDate;
const parsedDate = parseFirestoreDate(rawDate);
const safeDate = parsedDate || new Date();
return safeDate.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'numeric',
year: 'numeric',
});
}
function parseFirestoreDate(value) {
if (!value) {
return null;
}
if (typeof value.toDate === 'function') {
return value.toDate();
}
if (value instanceof Date) {
return value;
}
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value === 'object' && typeof value.seconds === 'number') {
return new Date(value.seconds * 1000);
}
if (typeof value === 'object' && typeof value._seconds === 'number') {
return new Date(value._seconds * 1000);
}
return null;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/// Configuration de la version de l'application /// Configuration de la version de l'application
class AppVersion { class AppVersion {
static const String version = '1.1.14'; static const String version = '1.2.1';
/// Retourne la version complète de l'application /// Retourne la version complète de l'application
static String get fullVersion => 'v$version'; static String get fullVersion => 'v$version';
+163 -253
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
@@ -15,13 +17,12 @@ import 'package:em2rp/views/maintenance_management_page.dart';
import 'package:em2rp/views/container_form_page.dart'; import 'package:em2rp/views/container_form_page.dart';
import 'package:em2rp/views/container_detail_page.dart'; import 'package:em2rp/views/container_detail_page.dart';
import 'package:em2rp/views/event_preparation_page.dart'; import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/views/event_statistics_page.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:em2rp/services/app_initializer.dart';
import 'firebase_options.dart';
import 'utils/colors.dart'; import 'utils/colors.dart';
import 'views/my_account_page.dart'; import 'views/my_account_page.dart';
import 'views/user_management_page.dart'; import 'views/user_management_page.dart';
@@ -29,36 +30,21 @@ import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart'; import 'providers/local_user_provider.dart';
import 'views/reset_password_page.dart'; import 'views/reset_password_page.dart';
import 'config/env.dart'; import 'config/env.dart';
import 'services/update_service.dart'; import 'utils/app_start_gate.dart';
import 'views/widgets/common/update_dialog.dart'; import 'views/widgets/common/startup_splash_screen.dart';
import 'config/api_config.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'views/widgets/common/update_dialog.dart';
void main() async { void main() {
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
WidgetsFlutterBinding.ensureInitialized(); 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( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
ChangeNotifierProvider<AppInitializer>(
create: (_) => AppInitializer(),
),
// LocalUserProvider pour la gestion de l'authentification // LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>( ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()), create: (context) => LocalUserProvider()),
@@ -96,239 +82,163 @@ void main() async {
); );
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<void> _startupFuture;
@override
void initState() {
super.initState();
_startupFuture = _bootstrapApp();
}
Future<void> _bootstrapApp() async {
final initializer = context.read<AppInitializer>();
final localAuthProvider = context.read<LocalUserProvider>();
await initializer.initialize();
// Attendre la première valeur d'authentification avant toute décision
// de navigation, afin d'éviter un flash de la page login.
await FirebaseAuth.instance.authStateChanges().first;
if (FirebaseAuth.instance.currentUser != null) {
unawaited(
localAuthProvider.loadUserData().catchError((e) {
print('User data bootstrap failed: $e');
}),
);
return;
}
// En développement, on garde la connexion automatique existante.
if (Env.isDevelopment) {
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
unawaited(
localAuthProvider.loadUserData().catchError((e) {
print('Dev user bootstrap failed: $e');
}),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return FutureBuilder<void>(
title: 'EM2 Hub', future: _startupFuture,
theme: ThemeData( builder: (context, snapshot) {
primarySwatch: Colors.red, if (snapshot.connectionState != ConnectionState.done) {
primaryColor: AppColors.noir, return const MaterialApp(
colorScheme: debugShowCheckedModeBanner: false,
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge), home: StartupSplashScreen(),
textTheme: const TextTheme( );
bodyMedium: TextStyle(color: AppColors.noir), }
),
inputDecorationTheme: const InputDecorationTheme( return MaterialApp(
focusedBorder: OutlineInputBorder( title: 'EM2 Hub',
borderSide: BorderSide(color: AppColors.noir), theme: ThemeData(
), primarySwatch: Colors.red,
enabledBorder: OutlineInputBorder( primaryColor: AppColors.noir,
borderSide: BorderSide(color: AppColors.gris), colorScheme:
), ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
labelStyle: TextStyle(color: AppColors.noir), textTheme: const TextTheme(
hintStyle: TextStyle(color: AppColors.gris), bodyMedium: TextStyle(color: AppColors.noir),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( inputDecorationTheme: const InputDecorationTheme(
style: ElevatedButton.styleFrom( focusedBorder: OutlineInputBorder(
foregroundColor: AppColors.blanc, borderSide: BorderSide(color: AppColors.noir),
backgroundColor: AppColors.noir, ),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.gris),
),
labelStyle: TextStyle(color: AppColors.noir),
hintStyle: TextStyle(color: AppColors.gris),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: AppColors.blanc,
backgroundColor: AppColors.noir,
),
),
), ),
), locale: const Locale('fr', 'FR'),
), supportedLocales: const [
locale: const Locale('fr', 'FR'), Locale('fr', 'FR'),
supportedLocales: const [ ],
Locale('fr', 'FR'), localizationsDelegates: const [
], GlobalMaterialLocalizations.delegate,
localizationsDelegates: const [ GlobalWidgetsLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, ],
GlobalCupertinoLocalizations.delegate, routes: {
], '/login': (context) => const LoginPage(),
initialRoute: '/', '/alerts': (context) => const AuthGuard(child: AlertsPage()),
routes: { '/calendar': (context) => const AuthGuard(
'/': (context) => const AutoLoginWrapper(), allowWhileLoading: true, child: CalendarPage()),
'/login': (context) => const LoginPage(), '/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/alerts': (context) => const AuthGuard(child: AlertsPage()), '/user_management': (context) => const AuthGuard(
'/calendar': (context) => const AuthGuard(child: CalendarPage()), requiredPermission: "view_all_users",
'/my_account': (context) => const AuthGuard(child: MyAccountPage()), child: UserManagementPage()),
'/user_management': (context) => const AuthGuard( '/reset_password': (context) {
requiredPermission: "view_all_users", child: UserManagementPage()), final args = ModalRoute.of(context)!.settings.arguments
'/reset_password': (context) { as Map<String, dynamic>;
final args = ModalRoute.of(context)!.settings.arguments return ResetPasswordPage(
as Map<String, dynamic>; email: args['email'] as String,
return ResetPasswordPage( actionCode: args['actionCode'] as String,
email: args['email'] as String, );
actionCode: args['actionCode'] as String, },
); '/equipment_management': (context) => const AuthGuard(
}, requiredPermission: "view_equipment",
'/equipment_management': (context) => const AuthGuard( child: EquipmentManagementPage()),
requiredPermission: "view_equipment", '/container_management': (context) => const AuthGuard(
child: EquipmentManagementPage()), requiredPermission: "view_equipment",
'/container_management': (context) => const AuthGuard( child: ContainerManagementPage()),
requiredPermission: "view_equipment", '/maintenance_management': (context) => const AuthGuard(
child: ContainerManagementPage()), requiredPermission: "manage_maintenances",
'/maintenance_management': (context) => const AuthGuard( child: MaintenanceManagementPage()),
requiredPermission: "manage_maintenances", '/container_form': (context) {
child: MaintenanceManagementPage()), final args = ModalRoute.of(context)?.settings.arguments;
'/container_form': (context) { return AuthGuard(
final args = ModalRoute.of(context)?.settings.arguments; requiredPermission: "manage_equipment",
return AuthGuard( child: ContainerFormPage(
requiredPermission: "manage_equipment", container: args as ContainerModel?,
child: ContainerFormPage( ),
container: args as ContainerModel?, );
), },
); '/container_detail': (context) {
}, final container = ModalRoute.of(context)!.settings.arguments
'/container_detail': (context) { as ContainerModel;
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel; return AuthGuard(
return AuthGuard( requiredPermission: "view_equipment",
requiredPermission: "view_equipment", child: ContainerDetailPage(container: container),
child: ContainerDetailPage(container: container), );
); },
}, '/event_preparation': (context) {
'/event_preparation': (context) { final args = ModalRoute.of(context)!.settings.arguments
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>; as Map<String, dynamic>;
final event = args['event'] as EventModel; final event = args['event'] as EventModel;
return AuthGuard( return AuthGuard(
child: EventPreparationPage( child: EventPreparationPage(
initialEvent: event, initialEvent: event,
), ),
); );
}, },
'/event_statistics': (context) => const AuthGuard(
requiredPermission: 'generate_reports',
child: EventStatisticsPage()),
},
home: const AppStartGate(),
);
}, },
); );
} }
} }
class AutoLoginWrapper extends StatefulWidget {
const AutoLoginWrapper({super.key});
@override
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
}
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
void initState() {
super.initState();
// 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');
}
if (mounted) {
// 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');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
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,
),
),
],
),
),
);
}
}
+4 -4
View File
@@ -151,7 +151,7 @@ class AlertModel {
factory AlertModel.fromMap(Map<String, dynamic> map, String id) { factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime // Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value) { DateTime parseDate(dynamic value) {
if (value == null) return DateTime.now(); if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate(); if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now(); if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
@@ -174,13 +174,13 @@ class AlertModel {
eventId: map['eventId'], eventId: map['eventId'],
equipmentId: map['equipmentId'], equipmentId: map['equipmentId'],
createdByUserId: map['createdByUserId'] ?? map['createdBy'], createdByUserId: map['createdByUserId'] ?? map['createdBy'],
createdAt: _parseDate(map['createdAt']), createdAt: parseDate(map['createdAt']),
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null, dueDate: map['dueDate'] != null ? parseDate(map['dueDate']) : null,
actionUrl: map['actionUrl'], actionUrl: map['actionUrl'],
isRead: map['isRead'] ?? false, isRead: map['isRead'] ?? false,
isResolved: map['isResolved'] ?? false, isResolved: map['isResolved'] ?? false,
resolution: map['resolution'], resolution: map['resolution'],
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null, resolvedAt: map['resolvedAt'] != null ? parseDate(map['resolvedAt']) : null,
resolvedByUserId: map['resolvedByUserId'], resolvedByUserId: map['resolvedByUserId'],
); );
} }
+5 -5
View File
@@ -243,7 +243,7 @@ class ContainerModel {
/// Factory depuis Firestore /// Factory depuis Firestore
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) { factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime // Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime? _parseDate(dynamic value) { DateTime? parseDate(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is Timestamp) return value.toDate(); if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value); if (value is String) return DateTime.tryParse(value);
@@ -270,8 +270,8 @@ class ContainerModel {
equipmentIds: equipmentIds, equipmentIds: equipmentIds,
eventId: map['eventId'], eventId: map['eventId'],
notes: map['notes'], notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(), createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(), updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
history: history, history: history,
); );
} }
@@ -351,7 +351,7 @@ class ContainerHistoryEntry {
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) { factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
// Helper pour parser la date // Helper pour parser la date
DateTime _parseDate(dynamic value) { DateTime parseDate(dynamic value) {
if (value == null) return DateTime.now(); if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate(); if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now(); if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
@@ -359,7 +359,7 @@ class ContainerHistoryEntry {
} }
return ContainerHistoryEntry( return ContainerHistoryEntry(
timestamp: _parseDate(map['timestamp']), timestamp: parseDate(map['timestamp']),
action: map['action'] ?? '', action: map['action'] ?? '',
equipmentId: map['equipmentId'], equipmentId: map['equipmentId'],
previousValue: map['previousValue'], previousValue: map['previousValue'],
+5 -5
View File
@@ -388,7 +388,7 @@ class EquipmentModel {
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) { factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime // Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime? _parseDate(dynamic value) { DateTime? parseDate(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is Timestamp) return value.toDate(); if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value); if (value is String) return DateTime.tryParse(value);
@@ -416,13 +416,13 @@ class EquipmentModel {
length: map['length']?.toDouble(), length: map['length']?.toDouble(),
width: map['width']?.toDouble(), width: map['width']?.toDouble(),
height: map['height']?.toDouble(), height: map['height']?.toDouble(),
purchaseDate: _parseDate(map['purchaseDate']), purchaseDate: parseDate(map['purchaseDate']),
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']), nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
maintenanceIds: maintenanceIds, maintenanceIds: maintenanceIds,
imageUrl: map['imageUrl'], imageUrl: map['imageUrl'],
notes: map['notes'], notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(), createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(), updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
); );
} }
+9 -3
View File
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
class EventEquipment { class EventEquipment {
final String equipmentId; // ID de l'équipement final String equipmentId; // ID de l'équipement
final int quantity; // Quantité initiale assignée final int quantity; // Quantité initiale assignée
final String? rationale; // Explication/Justification (ex: IA alternative)
final bool isPrepared; // Validé en préparation final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement final bool isUnloaded; // Validé au déchargement
@@ -194,6 +195,7 @@ class EventEquipment {
EventEquipment({ EventEquipment({
required this.equipmentId, required this.equipmentId,
this.quantity = 1, this.quantity = 1,
this.rationale,
this.isPrepared = false, this.isPrepared = false,
this.isLoaded = false, this.isLoaded = false,
this.isUnloaded = false, this.isUnloaded = false,
@@ -212,6 +214,7 @@ class EventEquipment {
return EventEquipment( return EventEquipment(
equipmentId: map['equipmentId'] ?? '', equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1, quantity: map['quantity'] ?? 1,
rationale: map['rationale'],
isPrepared: map['isPrepared'] ?? false, isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false, isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false, isUnloaded: map['isUnloaded'] ?? false,
@@ -231,6 +234,7 @@ class EventEquipment {
return { return {
'equipmentId': equipmentId, 'equipmentId': equipmentId,
'quantity': quantity, 'quantity': quantity,
'rationale': rationale,
'isPrepared': isPrepared, 'isPrepared': isPrepared,
'isLoaded': isLoaded, 'isLoaded': isLoaded,
'isUnloaded': isUnloaded, 'isUnloaded': isUnloaded,
@@ -249,6 +253,7 @@ class EventEquipment {
EventEquipment copyWith({ EventEquipment copyWith({
String? equipmentId, String? equipmentId,
int? quantity, int? quantity,
String? rationale,
bool? isPrepared, bool? isPrepared,
bool? isLoaded, bool? isLoaded,
bool? isUnloaded, bool? isUnloaded,
@@ -265,6 +270,7 @@ class EventEquipment {
return EventEquipment( return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId, equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity, quantity: quantity ?? this.quantity,
rationale: rationale ?? this.rationale,
isPrepared: isPrepared ?? this.isPrepared, isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded, isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded, isUnloaded: isUnloaded ?? this.isUnloaded,
@@ -347,7 +353,7 @@ class EventModel {
factory EventModel.fromMap(Map<String, dynamic> map, String id) { factory EventModel.fromMap(Map<String, dynamic> map, String id) {
try { try {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime // Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value, DateTime defaultValue) { DateTime parseDate(dynamic value, DateTime defaultValue) {
if (value == null) return defaultValue; if (value == null) return defaultValue;
if (value is Timestamp) return value.toDate(); if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? defaultValue; if (value is String) return DateTime.tryParse(value) ?? defaultValue;
@@ -370,8 +376,8 @@ class EventModel {
} }
// Gestion sécurisée des timestamps avec support ISO string // Gestion sécurisée des timestamps avec support ISO string
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now()); final DateTime startDate = parseDate(map['StartDateTime'], DateTime.now());
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1))); final DateTime endDate = parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
// Gestion sécurisée des documents // Gestion sécurisée des documents
final docsRaw = map['documents'] ?? []; final docsRaw = map['documents'] ?? [];
@@ -0,0 +1,132 @@
import 'package:em2rp/models/event_model.dart';
import 'package:flutter/material.dart';
class EventStatisticsFilter {
final DateTimeRange period;
final Set<String> eventTypeIds;
final bool includeCanceled;
final Set<EventStatus> selectedStatuses;
const EventStatisticsFilter({
required this.period,
this.eventTypeIds = const {},
this.includeCanceled = false,
this.selectedStatuses = const {
EventStatus.confirmed,
EventStatus.waitingForApproval,
},
});
EventStatisticsFilter copyWith({
DateTimeRange? period,
Set<String>? eventTypeIds,
bool? includeCanceled,
Set<EventStatus>? selectedStatuses,
}) {
return EventStatisticsFilter(
period: period ?? this.period,
eventTypeIds: eventTypeIds ?? this.eventTypeIds,
includeCanceled: includeCanceled ?? this.includeCanceled,
selectedStatuses: selectedStatuses ?? this.selectedStatuses,
);
}
}
class EventTypeStatistics {
final String eventTypeId;
final String eventTypeName;
final int totalEvents;
final double totalAmount;
final double validatedAmount;
final double pendingAmount;
final double canceledAmount;
const EventTypeStatistics({
required this.eventTypeId,
required this.eventTypeName,
required this.totalEvents,
required this.totalAmount,
required this.validatedAmount,
required this.pendingAmount,
required this.canceledAmount,
});
}
class OptionStatistics {
final String optionKey;
final String optionLabel;
final int usageCount;
final int validatedUsageCount;
final int quantity;
final double totalAmount;
const OptionStatistics({
required this.optionKey,
required this.optionLabel,
required this.usageCount,
required this.validatedUsageCount,
required this.quantity,
required this.totalAmount,
});
}
class EventStatisticsSummary {
final int totalEvents;
final int validatedEvents;
final int pendingEvents;
final int canceledEvents;
final double totalAmount;
final double validatedAmount;
final double pendingAmount;
final double canceledAmount;
final double baseAmount;
final double optionsAmount;
final double medianAmount;
final List<EventTypeStatistics> byEventType;
final List<OptionStatistics> topOptions;
const EventStatisticsSummary({
required this.totalEvents,
required this.validatedEvents,
required this.pendingEvents,
required this.canceledEvents,
required this.totalAmount,
required this.validatedAmount,
required this.pendingAmount,
required this.canceledAmount,
required this.baseAmount,
required this.optionsAmount,
required this.medianAmount,
required this.byEventType,
required this.topOptions,
});
static const empty = EventStatisticsSummary(
totalEvents: 0,
validatedEvents: 0,
pendingEvents: 0,
canceledEvents: 0,
totalAmount: 0,
validatedAmount: 0,
pendingAmount: 0,
canceledAmount: 0,
baseAmount: 0,
optionsAmount: 0,
medianAmount: 0,
byEventType: [],
topOptions: [],
);
double get averageAmount => totalEvents == 0 ? 0 : totalAmount / totalEvents;
double get validationRate =>
totalEvents == 0 ? 0 : validatedEvents / totalEvents;
double get baseContributionRate =>
totalAmount == 0 ? 0 : baseAmount / totalAmount;
double get optionsContributionRate =>
totalAmount == 0 ? 0 : optionsAmount / totalAmount;
}
+5 -5
View File
@@ -61,7 +61,7 @@ class MaintenanceModel {
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) { factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime // Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime? _parseDate(dynamic value) { DateTime? parseDate(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is Timestamp) return value.toDate(); if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value); if (value is String) return DateTime.tryParse(value);
@@ -76,15 +76,15 @@ class MaintenanceModel {
id: id, id: id,
equipmentIds: equipmentIds, equipmentIds: equipmentIds,
type: maintenanceTypeFromString(map['type']), type: maintenanceTypeFromString(map['type']),
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(), scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
completedDate: _parseDate(map['completedDate']), completedDate: parseDate(map['completedDate']),
name: map['name'] ?? '', name: map['name'] ?? '',
description: map['description'] ?? '', description: map['description'] ?? '',
performedBy: map['performedBy'], performedBy: map['performedBy'],
cost: map['cost']?.toDouble(), cost: map['cost']?.toDouble(),
notes: map['notes'], notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(), createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(), updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
); );
} }
+2 -2
View File
@@ -15,13 +15,13 @@ class ContainerProvider with ChangeNotifier {
Timer? _searchDebounceTimer; Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion // Liste paginée pour la page de gestion
List<ContainerModel> _paginatedContainers = []; final List<ContainerModel> _paginatedContainers = [];
bool _hasMore = true; bool _hasMore = true;
bool _isLoadingMore = false; bool _isLoadingMore = false;
String? _lastVisible; String? _lastVisible;
// Cache complet pour compatibilité // Cache complet pour compatibilité
List<ContainerModel> _containers = []; final List<ContainerModel> _containers = [];
// Filtres et recherche // Filtres et recherche
ContainerType? _selectedType; ContainerType? _selectedType;
+4 -4
View File
@@ -12,13 +12,13 @@ class EquipmentProvider extends ChangeNotifier {
Timer? _searchDebounceTimer; Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion // Liste paginée pour la page de gestion
List<EquipmentModel> _paginatedEquipment = []; final List<EquipmentModel> _paginatedEquipment = [];
bool _hasMore = true; bool _hasMore = true;
bool _isLoadingMore = false; bool _isLoadingMore = false;
String? _lastVisible; String? _lastVisible;
// Cache complet pour getEquipmentsByIds et compatibilité // Cache complet pour getEquipmentsByIds et compatibilité
List<EquipmentModel> _equipment = []; final List<EquipmentModel> _equipment = [];
List<String> _models = []; List<String> _models = [];
List<String> _brands = []; List<String> _brands = [];
@@ -433,9 +433,9 @@ class EquipmentProvider extends ChangeNotifier {
} }
/// Supprimer un équipement /// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async { Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
try { try {
await _dataService.deleteEquipment(equipmentId); await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
if (_usePagination) { if (_usePagination) {
await reload(); await reload();
} else { } else {
+84 -29
View File
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
bool _lastCanViewAll = false; bool _lastCanViewAll = false;
// Nouveau: Cache par mois pour le lazy loading // Nouveau: Cache par mois pour le lazy loading
Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events] final Map<String, List<EventModel>> _eventsByMonth =
{}; // "2026-02" => [events]
String? _currentMonth; // Mois actuellement affiché String? _currentMonth; // Mois actuellement affiché
List<EventModel> get events => _events; List<EventModel> get events => _events;
@@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier {
/// Vérifie si les données doivent être rechargées (cache de 30 secondes) /// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) { bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true; if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true; if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
return true;
final now = DateTime.now(); final now = DateTime.now();
final difference = now.difference(_lastLoadTime!); final difference = now.difference(_lastLoadTime!);
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
} }
/// Charger les événements d'un utilisateur via l'API /// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async { Future<void> loadUserEvents(String userId,
{bool canViewAllEvents = false, bool forceReload = false}) async {
PerformanceMonitor.start('EventProvider.loadUserEvents'); PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles // Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) { if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)'); print(
'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
PerformanceMonitor.end('EventProvider.loadUserEvents'); PerformanceMonitor.end('EventProvider.loadUserEvents');
return; return;
} }
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)'); print(
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
PerformanceMonitor.start('EventProvider.getEvents_API'); PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur // Charger via l'API - les permissions sont vérifiées côté serveur
@@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier {
final usersData = result['users'] as Map<String, dynamic>; final usersData = result['users'] as Map<String, dynamic>;
// Stocker les utilisateurs dans le cache // Stocker les utilisateurs dans le cache
_usersCache = usersData.map((key, value) => _usersCache = usersData
MapEntry(key, value as Map<String, dynamic>) .map((key, value) => MapEntry(key, value as Map<String, dynamic>));
);
print('Found ${eventsData.length} events from API'); print('Found ${eventsData.length} events from API');
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
// Parser chaque événement // Parser chaque événement
for (var eventData in eventsData) { for (var eventData in eventsData) {
try { try {
final event = EventModel.fromMap(eventData, eventData['id'] as String); final event =
EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event); allEvents.add(event);
} catch (e) { } catch (e) {
print('Failed to parse event ${eventData['id']}: $e'); print('Failed to parse event ${eventData['id']}: $e');
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId; _lastUserId = userId;
_lastCanViewAll = canViewAllEvents; _lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events (${failedCount} failed)'); print(
'Successfully loaded ${_events.length} events ($failedCount failed)');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
/// Charger les événements d'un mois spécifique (lazy loading optimisé) /// Charger les événements d'un mois spécifique (lazy loading optimisé)
Future<void> loadMonthEvents(String userId, int year, int month, Future<void> loadMonthEvents(String userId, int year, int month,
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async { {bool canViewAllEvents = false,
bool forceReload = false,
bool silent = false}) async {
final monthKey = '$year-${month.toString().padLeft(2, '0')}'; final monthKey = '$year-${month.toString().padLeft(2, '0')}';
// Vérifier le cache // Vérifier le cache
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
PerformanceMonitor.start('EventProvider.loadMonthEvents_API'); PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
final result = await _dataService.getEventsByMonth( final result = await _dataService.getEventsByMonth(
userId: userId, userId: userId, year: year, month: month);
year: year,
month: month
);
PerformanceMonitor.end('EventProvider.loadMonthEvents_API'); PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>; final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>; final usersData = result['users'] as Map<String, dynamic>;
// Mettre à jour le cache utilisateurs (addAll pour cumuler) // Mettre à jour le cache utilisateurs (addAll pour cumuler)
_usersCache.addAll( _usersCache.addAll(usersData
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>)) .map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
);
print('[EventProvider] Found ${eventsData.length} events for $monthKey'); print('[EventProvider] Found ${eventsData.length} events for $monthKey');
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
// Parser les événements // Parser les événements
for (var eventData in eventsData) { for (var eventData in eventsData) {
try { try {
final event = EventModel.fromMap(eventData, eventData['id'] as String); final event =
EventModel.fromMap(eventData, eventData['id'] as String);
monthEvents.add(event); monthEvents.add(event);
} catch (e) { } catch (e) {
print('[EventProvider] Failed to parse event ${eventData['id']}: $e'); print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId; _lastUserId = userId;
_lastCanViewAll = canViewAllEvents; _lastCanViewAll = canViewAllEvents;
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey (${failedCount} failed)'); print(
'[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
if (!silent) { if (!silent) {
_isLoading = false; _isLoading = false;
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
/// Précharger les mois adjacents en arrière-plan /// Précharger les mois adjacents en arrière-plan
void preloadAdjacentMonths(String userId, int year, int month, void preloadAdjacentMonths(String userId, int year, int month,
{bool canViewAllEvents = false}) { {bool canViewAllEvents = false}) {
// Mois précédent // Mois précédent
final prevMonth = month == 1 ? 12 : month - 1; final prevMonth = month == 1 ? 12 : month - 1;
final prevYear = month == 1 ? year - 1 : year; final prevYear = month == 1 ? year - 1 : year;
@@ -220,9 +224,20 @@ class EventProvider with ChangeNotifier {
}); });
} }
/// Vide entièrement le cache (mois + métadonnées) pour forcer un rechargement complet
void clearAllCache() {
_eventsByMonth.clear();
_lastLoadTime = null;
_lastUserId = null;
_currentMonth = null;
print('[EventProvider] Cache entièrement vidé');
}
/// Recharger les événements (utilise le dernier userId) /// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async { Future<void> refreshEvents(String userId,
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true); {bool canViewAllEvents = false}) async {
await loadUserEvents(userId,
canViewAllEvents: canViewAllEvents, forceReload: true);
} }
/// Récupérer un événement spécifique par ID /// Récupérer un événement spécifique par ID
@@ -234,6 +249,41 @@ class EventProvider with ChangeNotifier {
} }
} }
/// Recherche des événements accessibles à l'utilisateur.
Future<List<EventModel>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) {
return [];
}
final result = await _dataService.searchEvents(
userId: userId,
query: trimmedQuery,
limit: limit,
);
final events = <EventModel>[];
for (final eventData in result) {
try {
final eventId = eventData['id'] as String?;
if (eventId == null || eventId.isEmpty) {
continue;
}
events.add(EventModel.fromMap(eventData, eventId));
} catch (e) {
print('Failed to parse searched event ${eventData['id']}: $e');
}
}
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return events;
}
/// Ajouter un nouvel événement /// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async { Future<void> addEvent(EventModel event) async {
try { try {
@@ -241,7 +291,8 @@ class EventProvider with ChangeNotifier {
_events.add(event); _events.add(event);
// Ajouter dans le cache par mois // Ajouter dans le cache par mois
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; final monthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
if (_eventsByMonth.containsKey(monthKey)) { if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.add(event); _eventsByMonth[monthKey]!.add(event);
} }
@@ -263,8 +314,10 @@ class EventProvider with ChangeNotifier {
_events[index] = event; _events[index] = event;
// Mettre à jour dans le cache par mois // Mettre à jour dans le cache par mois
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}'; final oldMonthKey =
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; '${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 // Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
if (oldMonthKey != newMonthKey) { if (oldMonthKey != newMonthKey) {
@@ -277,7 +330,8 @@ class EventProvider with ChangeNotifier {
} else { } else {
// Même mois, juste mettre à jour // Même mois, juste mettre à jour
if (_eventsByMonth.containsKey(newMonthKey)) { if (_eventsByMonth.containsKey(newMonthKey)) {
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id); final monthIndex = _eventsByMonth[newMonthKey]!
.indexWhere((e) => e.id == event.id);
if (monthIndex != -1) { if (monthIndex != -1) {
_eventsByMonth[newMonthKey]![monthIndex] = event; _eventsByMonth[newMonthKey]![monthIndex] = event;
} }
@@ -299,7 +353,8 @@ class EventProvider with ChangeNotifier {
// Trouver l'événement pour obtenir sa date avant de le supprimer // Trouver l'événement pour obtenir sa date avant de le supprimer
final eventToDelete = _events.firstWhere((e) => e.id == eventId); final eventToDelete = _events.firstWhere((e) => e.id == eventId);
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}'; final monthKey =
'${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
// Supprimer de _events // Supprimer de _events
_events.removeWhere((event) => event.id == eventId); _events.removeWhere((event) => event.id == eventId);
+51 -5
View File
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier { class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser; UserModel? _currentUser;
RoleModel? _currentRole; RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance; FirebaseAuth? _auth;
final FirebaseStorageManager _storageManager = FirebaseStorageManager(); final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService); final DataService _dataService = DataService(apiService);
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
/// Charge les données de l'utilisateur actuel via Cloud Function /// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData({bool forceReload = false}) async { Future<void> loadUserData({bool forceReload = false}) async {
if (_auth.currentUser == null) { // Si FirebaseAuth n'est pas encore disponible
final FirebaseAuth auth;
try {
auth = _getAuthInstance();
} catch (e) {
print('Auth instance not ready in loadUserData: $e');
return;
}
if (auth.currentUser == null) {
print('No current user in Auth'); print('No current user in Auth');
return; return;
} }
// Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans
// qui en ont besoin, même si le profil complet n'est pas encore chargé.
if (_currentUser == null) {
final firebaseUser = auth.currentUser!;
_currentUser = UserModel(
uid: firebaseUser.uid,
email: firebaseUser.email ?? '',
firstName: '',
lastName: '',
role: 'USER',
phoneNumber: '',
profilePhotoUrl: firebaseUser.photoURL ?? '',
);
_currentRole = RoleModel(
id: 'USER',
name: '',
permissions: const [],
);
notifyListeners();
}
// Éviter les rechargements inutiles // Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) { if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data'); print('Using cached user data');
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
_isLoadingUserData = true; _isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData'); PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}'); print('Loading user data for: ${_auth!.currentUser!.uid}');
try { try {
// Utiliser la Cloud Function getCurrentUser // Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API'); PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
Future<UserCredential> signInWithEmailAndPassword( Future<UserCredential> signInWithEmailAndPassword(
String email, String password) async { String email, String password) async {
try { try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword( final auth = _getAuthInstance();
UserCredential userCredential = await auth.signInWithEmailAndPassword(
email: email, password: password); email: email, password: password);
// Note: loadUserData() sera appelé en arrière-plan dans main.dart // Note: loadUserData() sera appelé en arrière-plan dans main.dart
// pour ne pas bloquer la navigation // pour ne pas bloquer la navigation
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
/// Déconnexion /// Déconnexion
Future<void> signOut() async { Future<void> signOut() async {
await _auth.signOut(); try {
final auth = _getAuthInstance();
await auth.signOut();
} catch (e) {
debugPrint('Error during signOut: $e');
}
clearUser(); clearUser();
} }
FirebaseAuth _getAuthInstance() {
try {
_auth ??= FirebaseAuth.instance;
return _auth!;
} catch (e, st) {
debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st');
throw Exception('FirebaseAuth not available');
}
}
/// Vérifie si l'utilisateur a une permission spécifique /// Vérifie si l'utilisateur a une permission spécifique
bool hasPermission(String permission) { bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false; return _currentRole?.permissions.contains(permission) ?? false;
@@ -0,0 +1,280 @@
import 'dart:async';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Représente un tour de conversation dans le chat.
class AiAssistantChatTurn {
final bool isUser;
final String text;
const AiAssistantChatTurn({required this.isUser, required this.text});
}
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
class AiEquipmentDocument {
final String base64Data;
final String mimeType;
final String? fileName;
const AiEquipmentDocument({
required this.base64Data,
required this.mimeType,
this.fileName,
});
}
/// Un item proposé par l'IA dans la liste de matériel.
class AiEquipmentProposalItem {
final String equipmentId;
final int quantity;
final String rationale;
const AiEquipmentProposalItem({
required this.equipmentId,
required this.quantity,
required this.rationale,
});
}
/// Métadonnées pour un container proposé par l'IA.
class AiEquipmentProposalContainer {
final String containerId;
final String rationale;
final List<String> equipmentIds;
final List<String> matchingEquipmentIds;
final List<String> missingEquipmentIds;
final bool partial;
final bool? available;
final dynamic availabilityDetail;
const AiEquipmentProposalContainer({
required this.containerId,
required this.rationale,
this.equipmentIds = const [],
this.matchingEquipmentIds = const [],
this.missingEquipmentIds = const [],
this.partial = false,
this.available,
this.availabilityDetail,
});
}
/// Proposition complète retournée par l'IA.
class AiEquipmentProposal {
final String summary;
final List<AiEquipmentProposalItem> items;
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
final List<EventEquipment> asEventEquipment;
/// Containers (métadonnées) proposés par l'IA.
final List<AiEquipmentProposalContainer> containers;
List<String> get containerIds => containers.map((c) => c.containerId).toList();
const AiEquipmentProposal({
required this.summary,
required this.items,
required this.asEventEquipment,
required this.containers,
});
}
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
class AiEquipmentAssistantResponse {
final String assistantMessage;
final AiEquipmentProposal? proposal;
final List<String> debugLogs;
const AiEquipmentAssistantResponse({
required this.assistantMessage,
this.proposal,
this.debugLogs = const [],
});
}
/// Service assistant IA logisticien.
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
class AiEquipmentAssistantService {
final ApiService _apiService;
AiEquipmentAssistantService({ApiService? apiService})
: _apiService = apiService ?? FirebaseFunctionsApiService();
/// Envoie un message et retourne la réponse de l'assistant IA.
Future<AiEquipmentAssistantResponse> generateProposal({
required DateTime startDate,
required DateTime endDate,
required List<AiAssistantChatTurn> history,
required String userMessage,
String? eventTypeId,
String? excludeEventId,
List<EventEquipment> currentAssignedEquipment = const [],
List<EventEquipment> workingProposalEquipment = const [],
AiEquipmentDocument? document,
}) async {
final payload = <String, dynamic>{
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'userMessage': userMessage.trim(),
'history': history
.where((turn) => turn.text.trim().isNotEmpty)
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
.toList(),
'currentEquipment': currentAssignedEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
'workingProposal': workingProposalEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
};
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
if (document != null) {
payload['document'] = {
'mimeType': document.mimeType,
'data': document.base64Data,
if (document.fileName != null) 'fileName': document.fileName,
};
}
try {
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
final result = await _apiService.call('aiEquipmentProposal', payload);
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
final proposal = _parseProposal(result['proposal']);
final rawLogs = result['debugLogs'];
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
DebugLog.info(
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
);
return AiEquipmentAssistantResponse(
assistantMessage: assistantMessage.isNotEmpty
? assistantMessage
: 'Je n\'ai pas pu générer de réponse.',
proposal: proposal,
debugLogs: debugLogs,
);
} on ApiException catch (e) {
DebugLog.error('[AiEquipmentAssistantService] API error', e);
if (e.isUnauthorized) {
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
}
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
} catch (e) {
DebugLog.error('[AiEquipmentAssistantService] Error', e);
rethrow;
}
}
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
final proposalItems = <AiEquipmentProposalItem>[];
final eventEquipmentList = <EventEquipment>[];
// legacy containerIds variable removed (we now use containersMeta)
final rawItems = rawProposal['items'];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.from(rawItem);
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
if (equipmentId.isEmpty || quantity <= 0) continue;
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
proposalItems.add(AiEquipmentProposalItem(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
eventEquipmentList.add(EventEquipment(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
}
}
final containersMeta = <AiEquipmentProposalContainer>[];
final rawContainers = rawProposal['containers'];
if (rawContainers is List) {
for (final rawContainer in rawContainers) {
if (rawContainer is String) {
final cid = rawContainer.toString().trim();
if (cid.isNotEmpty) {
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
}
continue;
}
if (rawContainer is! Map) continue;
final container = Map<String, dynamic>.from(rawContainer);
final containerId = container['containerId']?.toString().trim() ?? '';
if (containerId.isEmpty) continue;
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
final equipmentIds = <String>[];
final matching = <String>[];
final missing = <String>[];
if (container['equipmentIds'] is List) {
for (final v in container['equipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) equipmentIds.add(s);
}
}
if (container['matchingEquipmentIds'] is List) {
for (final v in container['matchingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) matching.add(s);
}
}
if (container['missingEquipmentIds'] is List) {
for (final v in container['missingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) missing.add(s);
}
}
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
containersMeta.add(AiEquipmentProposalContainer(
containerId: containerId,
rationale: rationale,
equipmentIds: equipmentIds,
matchingEquipmentIds: matching,
missingEquipmentIds: missing,
partial: partial,
available: available,
availabilityDetail: availabilityDetail,
));
}
}
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
return AiEquipmentProposal(
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
? rawProposal['summary'].toString().trim()
: 'Proposition matériel générée automatiquement.',
items: proposalItems,
asEventEquipment: eventEquipmentList,
containers: containersMeta,
);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions /// Architecture simplifiée : le client appelle uniquement les Cloud Functions
/// Toute la logique métier est gérée côté backend /// Toute la logique métier est gérée côté backend
class AlertService { class AlertService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; FirebaseFirestore get _firestore => FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance; FirebaseAuth get _auth => FirebaseAuth.instance;
/// Stream des alertes pour l'utilisateur connecté /// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() { Stream<List<AlertModel>> getAlertsStream() {
+2
View File
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
statusCode: response.statusCode, statusCode: response.statusCode,
); );
} }
} on ApiException {
rethrow;
} catch (e) { } catch (e) {
DebugLog.error('[API] Error during request: $functionName', e); DebugLog.error('[API] Error during request: $functionName', e);
throw ApiException( throw ApiException(
+79
View File
@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../firebase_options.dart';
import '../config/api_config.dart';
import 'cache_service.dart';
/// Service responsable des initialisations lourdes en tâche de fond.
///
/// Objectif : réduire au maximum le travail synchrone dans main(),
/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone.
class AppInitializer with ChangeNotifier {
bool _isInitialized = false;
bool _isInitializing = false;
bool get isInitialized => _isInitialized;
bool get isInitializing => _isInitializing;
final CacheService cacheService = CacheService();
/// Démarre l'initialisation asynchrone. Idempotent.
Future<void> initialize() async {
if (_isInitialized || _isInitializing) return;
_isInitializing = true;
notifyListeners();
try {
// Initialiser Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configurer les émulateurs en dev si demandé
if (ApiConfig.isDevelopment) {
try {
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
} catch (e) {
// Ignorer si non supporté
if (kDebugMode) print('Emulator setup failed: $e');
}
}
// Initialiser le cache local sans bloquer l'écran de démarrage.
unawaited(cacheService.init());
// Précharger des assets critiques de façon asynchrone
unawaited(_preloadAssets());
// TODO: lancer ici d'autres initialisations non bloquantes
_isInitialized = true;
_isInitializing = false;
notifyListeners();
} catch (e, st) {
if (kDebugMode) print('AppInitializer failed: $e\n$st');
_isInitializing = false;
// Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs.
notifyListeners();
}
}
Future<void> _preloadAssets() async {
try {
// Charger quelques assets en mémoire pour rendre l'affichage initial fluide
await rootBundle.load('assets/logos/RectangleLogoBlack.png');
await rootBundle.load('assets/logos/SquareLogoWhite.png');
} catch (e) {
if (kDebugMode) print('Preload assets failed: $e');
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service simple de cache local basé sur SharedPreferences.
///
/// Fonctionne sur mobile et sur Flutter Web pour conserver des données
/// locales légères quand cela apporte une vraie valeur.
class CacheService {
SharedPreferences? _prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
bool ready() => _prefs != null;
Future<void> setJson(String key, Map<String, dynamic> value) async {
if (_prefs == null) return;
await _prefs!.setString(key, jsonEncode(value));
}
Map<String, dynamic>? getJson(String key) {
if (_prefs == null) return null;
final s = _prefs!.getString(key);
if (s == null) return null;
try {
return jsonDecode(s) as Map<String, dynamic>;
} catch (e) {
if (kDebugMode) print('CacheService getJson error: $e');
return null;
}
}
Future<void> setString(String key, String value) async {
if (_prefs == null) return;
await _prefs!.setString(key, value);
}
String? getString(String key) => _prefs?.getString(key);
}
@@ -0,0 +1,189 @@
import 'package:web/web.dart' as web;
import 'package:em2rp/config/api_config.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service de Text-to-Speech utilisant Google Cloud TTS via Cloud Function
/// Avec système de cache pour optimiser les performances et réduire les coûts
class CloudTextToSpeechService {
static final Map<String, String> _audioCache = {};
static final Map<String, web.HTMLAudioElement> _audioPlayers = {};
/// Générer l'audio TTS via Cloud Function
/// Retourne l'URL de l'audio (mise en cache automatiquement côté serveur)
static Future<String?> generateAudio(String text) async {
try {
// Vérifier le cache local d'abord
if (_audioCache.containsKey(text)) {
DebugLog.info('[CloudTTS] ✓ Local cache HIT: "${text.substring(0, 30)}..."');
return _audioCache[text];
}
DebugLog.info('[CloudTTS] Generating audio for: "$text"');
// Récupérer le token d'authentification
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
DebugLog.error('[CloudTTS] User not authenticated');
return null;
}
final token = await user.getIdToken();
if (token == null) {
DebugLog.error('[CloudTTS] Failed to get auth token');
return null;
}
// Préparer la requête
final url = '${ApiConfig.baseUrl}/generateTTSV2';
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
final body = json.encode({
'data': {
'text': text,
'voiceConfig': {
'languageCode': 'fr-FR',
'name': 'fr-FR-Standard-B', // Voix masculine gratuite
'ssmlGender': 'MALE',
},
},
});
DebugLog.info('[CloudTTS] Calling Cloud Function...');
final startTime = DateTime.now();
// Appeler la Cloud Function
final response = await http.post(
Uri.parse(url),
headers: headers,
body: body,
);
final duration = DateTime.now().difference(startTime).inMilliseconds;
if (response.statusCode == 200) {
final data = json.decode(response.body);
final audioUrl = data['audioUrl'] as String?;
final cached = data['cached'] as bool? ?? false;
if (audioUrl != null) {
// Mettre en cache localement
_audioCache[text] = audioUrl;
DebugLog.info('[CloudTTS] ✓ Audio generated - cached: $cached, duration: ${duration}ms');
return audioUrl;
}
}
DebugLog.error('[CloudTTS] Failed to generate audio', {
'status': response.statusCode,
'body': response.body,
});
return null;
} catch (e) {
DebugLog.error('[CloudTTS] Exception:', e);
return null;
}
}
/// Lire un audio depuis une URL
static void playAudio(String audioUrl) {
try {
DebugLog.info('[CloudTTS] Playing audio...');
// Créer ou réutiliser un HTMLAudioElement
final player = _audioPlayers[audioUrl] ?? web.HTMLAudioElement();
if (!_audioPlayers.containsKey(audioUrl)) {
player.src = audioUrl;
_audioPlayers[audioUrl] = player;
}
// Configurer le volume
player.volume = 1.0;
// Écouter les événements
player.onEnded.listen((_) {
DebugLog.info('[CloudTTS] ✓ Playback finished');
});
player.onError.listen((event) {
DebugLog.error('[CloudTTS] ✗ Playback error:', event);
});
// Lire l'audio (pas de await avec package:web)
player.play();
DebugLog.info('[CloudTTS] ✓ Playback started');
} catch (e) {
DebugLog.error('[CloudTTS] Error playing audio:', e);
rethrow;
}
}
/// Générer et lire l'audio en une seule opération
static Future<void> speak(String text) async {
try {
final audioUrl = await generateAudio(text);
if (audioUrl != null) {
playAudio(audioUrl);
} else {
DebugLog.error('[CloudTTS] Failed to generate audio for speech');
}
} catch (e) {
DebugLog.error('[CloudTTS] Error in speak:', e);
rethrow;
}
}
/// Arrêter tous les audios en cours
static void stopAll() {
for (final player in _audioPlayers.values) {
try {
player.pause();
player.currentTime = 0;
} catch (e) {
// Ignorer les erreurs de pause
}
}
DebugLog.info('[CloudTTS] All players stopped');
}
/// Nettoyer le cache
static void clearCache() {
_audioCache.clear();
_audioPlayers.clear();
DebugLog.info('[CloudTTS] Cache cleared');
}
/// Pré-charger des audios fréquemment utilisés
static Future<void> preloadCommonPhrases() async {
final phrases = [
'Équipement scanné',
'Flight case',
'Conteneur',
'Validé',
'Erreur',
];
DebugLog.info('[CloudTTS] Preloading ${phrases.length} common phrases...');
for (final phrase in phrases) {
try {
await generateAudio(phrase);
} catch (e) {
DebugLog.warning('[CloudTTS] Failed to preload: $phrase - $e');
}
}
DebugLog.info('[CloudTTS] ✓ Preload complete');
}
}
+243 -34
View File
@@ -27,7 +27,8 @@ class DataService {
if (eventTypes == null) return []; if (eventTypes == null) return [];
return eventTypes.map((e) => e as Map<String, dynamic>).toList(); return eventTypes.map((e) => e as Map<String, dynamic>).toList();
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la récupération des types d\'événements: $e'); throw Exception(
'Erreur lors de la récupération des types d\'événements: $e');
} }
} }
@@ -55,15 +56,18 @@ class DataService {
try { try {
final data = <String, dynamic>{'eventId': eventId}; final data = <String, dynamic>{'eventId': eventId};
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment; if (assignedEquipment != null)
if (preparationStatus != null) data['preparationStatus'] = preparationStatus; data['assignedEquipment'] = assignedEquipment;
if (preparationStatus != null)
data['preparationStatus'] = preparationStatus;
if (loadingStatus != null) data['loadingStatus'] = loadingStatus; if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus; if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
if (returnStatus != null) data['returnStatus'] = returnStatus; if (returnStatus != null) data['returnStatus'] = returnStatus;
await _apiService.call('updateEventEquipment', data); await _apiService.call('updateEventEquipment', data);
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e'); throw Exception(
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
} }
} }
@@ -77,11 +81,13 @@ class DataService {
final data = <String, dynamic>{'equipmentId': equipmentId}; final data = <String, dynamic>{'equipmentId': equipmentId};
if (status != null) data['status'] = status; if (status != null) data['status'] = status;
if (availableQuantity != null) data['availableQuantity'] = availableQuantity; if (availableQuantity != null)
data['availableQuantity'] = availableQuantity;
await _apiService.call('updateEquipmentStatusOnly', data); await _apiService.call('updateEquipmentStatusOnly', data);
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e'); throw Exception(
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
} }
} }
@@ -106,7 +112,8 @@ class DataService {
} }
/// Crée un équipement /// Crée un équipement
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async { Future<void> createEquipment(
String equipmentId, Map<String, dynamic> data) async {
try { try {
// S'assurer que l'ID est dans les données // S'assurer que l'ID est dans les données
final equipmentData = Map<String, dynamic>.from(data); final equipmentData = Map<String, dynamic>.from(data);
@@ -119,7 +126,8 @@ class DataService {
} }
/// Met à jour un équipement /// Met à jour un équipement
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async { Future<void> updateEquipment(
String equipmentId, Map<String, dynamic> data) async {
try { try {
await _apiService.call('updateEquipment', { await _apiService.call('updateEquipment', {
'equipmentId': equipmentId, 'equipmentId': equipmentId,
@@ -131,18 +139,26 @@ class DataService {
} }
/// Supprime un équipement /// Supprime un équipement
Future<void> deleteEquipment(String equipmentId) async { Future<void> deleteEquipment(String equipmentId,
{bool forceDelete = false}) async {
try { try {
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId}); await _apiService.call('deleteEquipment', {
'equipmentId': equipmentId,
'forceDelete': forceDelete,
});
} on ApiException {
rethrow;
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $e'); throw Exception('Erreur lors de la suppression de l\'équipement: $e');
} }
} }
/// Récupère les événements utilisant un type d'événement donné /// Récupère les événements utilisant un type d'événement donné
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async { Future<List<Map<String, dynamic>>> getEventsByEventType(
String eventTypeId) async {
try { try {
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId}); final result = await _apiService
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
final events = result['events'] as List<dynamic>?; final events = result['events'] as List<dynamic>?;
if (events == null) return []; if (events == null) return [];
return events.map((e) => e as Map<String, dynamic>).toList(); return events.map((e) => e as Map<String, dynamic>).toList();
@@ -211,7 +227,7 @@ class DataService {
/// Met à jour une option /// Met à jour une option
Future<void> updateOption(String optionId, Map<String, dynamic> data) async { Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
try { try {
final requestData = {'optionId': optionId, ...data}; final requestData = {'optionId': optionId, 'data': data};
await _apiService.call('updateOption', requestData); await _apiService.call('updateOption', requestData);
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'option: $e'); throw Exception('Erreur lors de la mise à jour de l\'option: $e');
@@ -271,7 +287,8 @@ class DataService {
final events = result['events'] as List<dynamic>? ?? []; final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {}; final users = result['users'] as Map<String, dynamic>? ?? {};
print('[DataService] Events loaded for $year-$month: ${events.length} events'); print(
'[DataService] Events loaded for $year-$month: ${events.length} events');
return { return {
'events': events.map((e) => e as Map<String, dynamic>).toList(), 'events': events.map((e) => e as Map<String, dynamic>).toList(),
@@ -279,7 +296,32 @@ class DataService {
}; };
} catch (e) { } catch (e) {
print('[DataService] Error getting events by month: $e'); print('[DataService] Error getting events by month: $e');
throw Exception('Erreur lors de la récupération des événements du mois: $e'); throw Exception(
'Erreur lors de la récupération des événements du mois: $e');
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<Map<String, dynamic>>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
try {
final result = await _apiService.call('searchEvents', {
'userId': userId,
'query': query,
'limit': limit,
});
final events = result['events'] as List<dynamic>?;
if (events == null) {
return [];
}
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la recherche d\'événements: $e');
} }
} }
@@ -299,7 +341,8 @@ class DataService {
throw Exception('Event not found'); throw Exception('Event not found');
} }
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); print(
'[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
return { return {
'event': event, 'event': event,
@@ -308,7 +351,8 @@ class DataService {
}; };
} catch (e) { } catch (e) {
print('[DataService] Error getting event with details: $e'); print('[DataService] Error getting event with details: $e');
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e'); throw Exception(
'Erreur lors de la récupération de l\'événement avec détails: $e');
} }
} }
@@ -332,11 +376,13 @@ class DataService {
} }
/// Récupère plusieurs équipements par leurs IDs /// Récupère plusieurs équipements par leurs IDs
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async { Future<List<Map<String, dynamic>>> getEquipmentsByIds(
List<String> equipmentIds) async {
try { try {
if (equipmentIds.isEmpty) return []; if (equipmentIds.isEmpty) return [];
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items'); print(
'[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
final result = await _apiService.call('getEquipmentsByIds', { final result = await _apiService.call('getEquipmentsByIds', {
'equipmentIds': equipmentIds, 'equipmentIds': equipmentIds,
}); });
@@ -366,11 +412,13 @@ class DataService {
} }
/// Récupère plusieurs containers par leurs IDs /// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async { Future<List<Map<String, dynamic>>> getContainersByIds(
List<String> containerIds) async {
try { try {
if (containerIds.isEmpty) return []; if (containerIds.isEmpty) return [];
print('[DataService] Getting containers by IDs: ${containerIds.length} items'); print(
'[DataService] Getting containers by IDs: ${containerIds.length} items');
final result = await _apiService.call('getContainersByIds', { final result = await _apiService.call('getContainersByIds', {
'containerIds': containerIds, 'containerIds': containerIds,
}); });
@@ -415,22 +463,25 @@ class DataService {
params['searchQuery'] = searchQuery; params['searchQuery'] = searchQuery;
} }
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated( final result =
await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated', 'getEquipmentsPaginated',
params, params,
); );
return { return {
'equipments': (result['equipments'] as List<dynamic>?) 'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>) ?.map((e) => e as Map<String, dynamic>)
.toList() ?? [], .toList() ??
[],
'hasMore': result['hasMore'] as bool? ?? false, 'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?, 'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0, 'total': result['total'] as int? ?? 0,
}; };
} catch (e) { } catch (e) {
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e); DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
throw Exception('Erreur lors de la récupération paginée des équipements: $e'); throw Exception(
'Erreur lors de la récupération paginée des équipements: $e');
} }
} }
@@ -460,22 +511,25 @@ class DataService {
params['searchQuery'] = searchQuery; params['searchQuery'] = searchQuery;
} }
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated( final result =
await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated', 'getContainersPaginated',
params, params,
); );
return { return {
'containers': (result['containers'] as List<dynamic>?) 'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>) ?.map((e) => e as Map<String, dynamic>)
.toList() ?? [], .toList() ??
[],
'hasMore': result['hasMore'] as bool? ?? false, 'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?, 'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0, 'total': result['total'] as int? ?? 0,
}; };
} catch (e) { } catch (e) {
DebugLog.error('[DataService] Error in getContainersPaginated', e); DebugLog.error('[DataService] Error in getContainersPaginated', e);
throw Exception('Erreur lors de la récupération paginée des containers: $e'); throw Exception(
'Erreur lors de la récupération paginée des containers: $e');
} }
} }
@@ -499,6 +553,156 @@ class DataService {
} }
} }
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
required String query,
int limit = 12,
}) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return [];
}
try {
final quickResults = await quickSearch(
normalizedQuery,
limit: limit,
includeEquipments: true,
includeContainers: false,
);
final equipmentResults = quickResults
.where((item) =>
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
.map(_normalizeAssistantEquipment)
.toList();
if (equipmentResults.isNotEmpty) {
return equipmentResults;
}
final paginated = await getEquipmentsPaginated(
limit: limit,
searchQuery: normalizedQuery,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments =
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
return equipments.map(_normalizeAssistantEquipment).toList();
} catch (e) {
DebugLog.error('[DataService] Error in searchEquipmentsForAssistant', e);
throw Exception('Erreur lors de la recherche de matériel: $e');
}
}
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final available = result['available'] as bool? ?? true;
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map((conflict) {
final eventData =
conflict['eventData'] as Map<String, dynamic>? ?? const {};
final eventName =
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
return {
'eventId': conflict['eventId']?.toString() ?? '',
'eventName': eventName,
'overlapDays': conflict['overlapDays'] as int? ?? 0,
};
}).toList();
return {
'equipmentId': equipmentId,
'available': available,
'conflictCount': conflicts.length,
'conflicts': conflicts,
};
} catch (e) {
DebugLog.error(
'[DataService] Error in checkEquipmentAvailabilityForAssistant', e);
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Retourne des événements passés, idéalement filtrés par type d'événement.
Future<List<Map<String, dynamic>>> getPastEventsForAssistant({
String? eventTypeId,
int limit = 10,
}) async {
try {
final now = DateTime.now();
final events = eventTypeId != null && eventTypeId.isNotEmpty
? await getEventsByEventType(eventTypeId)
: (await getEvents())['events'] as List<Map<String, dynamic>>? ?? [];
final pastEvents = events.where((event) {
final endDate = _parseEventDate(event['EndDateTime']);
return endDate != null && endDate.isBefore(now);
}).toList();
pastEvents.sort((a, b) {
final aDate = _parseEventDate(a['StartDateTime']) ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = _parseEventDate(b['StartDateTime']) ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate);
});
return pastEvents.take(limit).map((event) {
final assignedEquipment =
event['assignedEquipment'] as List<dynamic>? ?? const [];
return {
'id': event['id']?.toString() ?? '',
'name': (event['Name'] ?? '').toString(),
'startDate': event['StartDateTime']?.toString() ?? '',
'endDate': event['EndDateTime']?.toString() ?? '',
'assignedEquipment': assignedEquipment,
'assignedEquipmentCount': assignedEquipment.length,
};
}).toList();
} catch (e) {
DebugLog.error('[DataService] Error in getPastEventsForAssistant', e);
throw Exception(
'Erreur lors de la récupération des événements passés: $e');
}
}
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
return {
'id': (item['id'] ?? '').toString(),
'name': (item['name'] ?? item['id'] ?? '').toString(),
'category': (item['category'] ?? '').toString(),
'status': (item['status'] ?? '').toString(),
'brand': item['brand']?.toString(),
'model': item['model']?.toString(),
'availableQuantity': item['availableQuantity'],
'totalQuantity': item['totalQuantity'],
};
}
DateTime? _parseEventDate(dynamic rawValue) {
if (rawValue is String) {
return DateTime.tryParse(rawValue);
}
return null;
}
// ============================================================================ // ============================================================================
// USER - Current User // USER - Current User
// ============================================================================ // ============================================================================
@@ -512,7 +716,8 @@ class DataService {
return result['user'] as Map<String, dynamic>; return result['user'] as Map<String, dynamic>;
} catch (e) { } catch (e) {
print('[DataService] Error getting current user: $e'); print('[DataService] Error getting current user: $e');
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e'); throw Exception(
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
} }
} }
@@ -593,7 +798,8 @@ class DataService {
}); });
return result; return result;
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la récupération des équipements en conflit: $e'); throw Exception(
'Erreur lors de la récupération des équipements en conflit: $e');
} }
} }
@@ -602,7 +808,8 @@ class DataService {
// ============================================================================ // ============================================================================
/// Récupère toutes les maintenances /// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async { Future<List<Map<String, dynamic>>> getMaintenances(
{String? equipmentId}) async {
try { try {
final data = <String, dynamic>{}; final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId; if (equipmentId != null) data['equipmentId'] = equipmentId;
@@ -619,14 +826,16 @@ class DataService {
/// Supprime une maintenance /// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async { Future<void> deleteMaintenance(String maintenanceId) async {
try { try {
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId}); await _apiService
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la suppression de la maintenance: $e'); throw Exception('Erreur lors de la suppression de la maintenance: $e');
} }
} }
/// Récupère les containers contenant un équipement /// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async { Future<List<Map<String, dynamic>>> getContainersByEquipment(
String equipmentId) async {
try { try {
final result = await _apiService.call('getContainersByEquipment', { final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId, 'equipmentId': equipmentId,
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions /// Service d'envoi d'emails via Cloud Functions
class EmailService { class EmailService {
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9'); FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9');
/// Envoie un email d'alerte à un utilisateur /// Envoie un email d'alerte à un utilisateur
/// ///
@@ -4,6 +4,44 @@ import 'package:em2rp/services/api_service.dart';
class EventPreparationService { class EventPreparationService {
final ApiService _apiService = apiService; final ApiService _apiService = apiService;
/// Retourne true si l'équipement était absent du flux événementiel.
///
/// Cas typique: matériel jamais emporté au départ, donc absent au retour,
/// mais qui ne doit jamais être classé en [LOST].
static bool isEquipmentNotTakenToEvent({
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isMissingAtReturn) {
return false;
}
final loadedQuantity = quantityAtLoading ?? 0;
return !isLoaded || isMissingAtLoading || loadedQuantity <= 0;
}
/// Retourne true uniquement si l'équipement doit être classé perdu.
static bool shouldMarkEquipmentAsLost({
required bool isReturnValidationStep,
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isReturnValidationStep || !isMissingAtReturn) {
return false;
}
return !isEquipmentNotTakenToEvent(
isMissingAtReturn: isMissingAtReturn,
isLoaded: isLoaded,
isMissingAtLoading: isMissingAtLoading,
quantityAtLoading: quantityAtLoading,
);
}
// === PRÉPARATION === // === PRÉPARATION ===
/// Valider un équipement individuel en préparation /// Valider un équipement individuel en préparation
@@ -0,0 +1,280 @@
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_statistics_models.dart';
import 'package:flutter/material.dart';
class EventStatisticsService {
const EventStatisticsService();
static const double _taxRatio = 1.2;
EventStatisticsSummary buildSummary({
required List<EventModel> events,
required EventStatisticsFilter filter,
required Map<String, String> eventTypeNames,
}) {
final filteredEvents =
events.where((event) => _matchesFilter(event, filter)).toList();
if (filteredEvents.isEmpty) {
return EventStatisticsSummary.empty;
}
var validatedEvents = 0;
var pendingEvents = 0;
var canceledEvents = 0;
var validatedAmount = 0.0;
var pendingAmount = 0.0;
var canceledAmount = 0.0;
var baseAmount = 0.0;
var optionsAmount = 0.0;
final eventAmounts = <double>[];
final byType = <String, _EventTypeAccumulator>{};
final optionStats = <String, _OptionAccumulator>{};
for (final event in filteredEvents) {
final base = _toHtAmount(event.basePrice);
final optionTotal = _computeOptionsTotal(event);
final amount = base + optionTotal;
final isValidated = event.status == EventStatus.confirmed;
eventAmounts.add(amount);
baseAmount += base;
optionsAmount += optionTotal;
switch (event.status) {
case EventStatus.confirmed:
validatedEvents += 1;
validatedAmount += amount;
break;
case EventStatus.waitingForApproval:
pendingEvents += 1;
pendingAmount += amount;
break;
case EventStatus.canceled:
canceledEvents += 1;
canceledAmount += amount;
break;
}
final eventTypeId = event.eventTypeId;
final eventTypeName = eventTypeNames[eventTypeId] ?? 'Type inconnu';
final typeAccumulator = byType.putIfAbsent(
eventTypeId,
() => _EventTypeAccumulator(
eventTypeId: eventTypeId, eventTypeName: eventTypeName),
);
typeAccumulator.totalEvents += 1;
typeAccumulator.totalAmount += amount;
switch (event.status) {
case EventStatus.confirmed:
typeAccumulator.validatedAmount += amount;
break;
case EventStatus.waitingForApproval:
typeAccumulator.pendingAmount += amount;
break;
case EventStatus.canceled:
typeAccumulator.canceledAmount += amount;
break;
}
for (final rawOption in event.options) {
final optionPrice = _toHtAmount(_toDouble(rawOption['price']));
final optionQuantity = _toInt(rawOption['quantity'], fallback: 1);
if (optionPrice == 0) {
continue;
}
final optionId = (rawOption['id'] ??
rawOption['code'] ??
rawOption['name'] ??
'option')
.toString();
final optionLabel = _buildOptionLabel(rawOption, optionId);
final optionAmount = optionPrice * optionQuantity;
final optionAccumulator = optionStats.putIfAbsent(
optionId,
() =>
_OptionAccumulator(optionKey: optionId, optionLabel: optionLabel),
);
optionAccumulator.usageCount += 1;
if (isValidated) {
optionAccumulator.validatedUsageCount += 1;
}
optionAccumulator.quantity += optionQuantity;
optionAccumulator.totalAmount += optionAmount;
}
}
final byEventType = byType.values
.map((accumulator) => EventTypeStatistics(
eventTypeId: accumulator.eventTypeId,
eventTypeName: accumulator.eventTypeName,
totalEvents: accumulator.totalEvents,
totalAmount: accumulator.totalAmount,
validatedAmount: accumulator.validatedAmount,
pendingAmount: accumulator.pendingAmount,
canceledAmount: accumulator.canceledAmount,
))
.toList()
..sort((a, b) => b.totalAmount.compareTo(a.totalAmount));
final topOptions = optionStats.values
.map((accumulator) => OptionStatistics(
optionKey: accumulator.optionKey,
optionLabel: accumulator.optionLabel,
usageCount: accumulator.usageCount,
validatedUsageCount: accumulator.validatedUsageCount,
quantity: accumulator.quantity,
totalAmount: accumulator.totalAmount,
))
.toList()
..sort((a, b) {
final validatedComparison =
b.validatedUsageCount.compareTo(a.validatedUsageCount);
if (validatedComparison != 0) {
return validatedComparison;
}
return b.totalAmount.compareTo(a.totalAmount);
});
return EventStatisticsSummary(
totalEvents: filteredEvents.length,
validatedEvents: validatedEvents,
pendingEvents: pendingEvents,
canceledEvents: canceledEvents,
totalAmount: validatedAmount + pendingAmount + canceledAmount,
validatedAmount: validatedAmount,
pendingAmount: pendingAmount,
canceledAmount: canceledAmount,
baseAmount: baseAmount,
optionsAmount: optionsAmount,
medianAmount: _computeMedian(eventAmounts),
byEventType: byEventType,
topOptions: topOptions.take(8).toList(),
);
}
bool _matchesFilter(EventModel event, EventStatisticsFilter filter) {
if (!_overlapsRange(event, filter.period)) {
return false;
}
if (!filter.selectedStatuses.contains(event.status)) {
return false;
}
if (filter.eventTypeIds.isNotEmpty &&
!filter.eventTypeIds.contains(event.eventTypeId)) {
return false;
}
return true;
}
bool _overlapsRange(EventModel event, DateTimeRange range) {
return !event.endDateTime.isBefore(range.start) &&
!event.startDateTime.isAfter(range.end);
}
double _computeOptionsTotal(EventModel event) {
return event.options.fold<double>(0.0, (sum, option) {
final optionPrice = _toHtAmount(_toDouble(option['price']));
final optionQuantity = _toInt(option['quantity'], fallback: 1);
return sum + (optionPrice * optionQuantity);
});
}
double _toHtAmount(double storedAmount) {
return storedAmount / _taxRatio;
}
double _toDouble(dynamic value) {
if (value == null) {
return 0.0;
}
if (value is num) {
return value.toDouble();
}
return double.tryParse(value.toString()) ?? 0.0;
}
int _toInt(dynamic value, {int fallback = 0}) {
if (value == null) {
return fallback;
}
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value.toString()) ?? fallback;
}
String _buildOptionLabel(Map<String, dynamic> option, String fallback) {
final code = (option['code'] ?? '').toString().trim();
final name = (option['name'] ?? '').toString().trim();
if (code.isNotEmpty && name.isNotEmpty) {
return '$code - $name';
}
if (name.isNotEmpty) {
return name;
}
if (code.isNotEmpty) {
return code;
}
return fallback;
}
double _computeMedian(List<double> values) {
if (values.isEmpty) {
return 0.0;
}
final sorted = [...values]..sort();
final middleIndex = sorted.length ~/ 2;
if (sorted.length.isOdd) {
return sorted[middleIndex];
}
return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
}
}
class _EventTypeAccumulator {
final String eventTypeId;
final String eventTypeName;
int totalEvents = 0;
double totalAmount = 0.0;
double validatedAmount = 0.0;
double pendingAmount = 0.0;
double canceledAmount = 0.0;
_EventTypeAccumulator({
required this.eventTypeId,
required this.eventTypeName,
});
}
class _OptionAccumulator {
final String optionKey;
final String optionLabel;
int usageCount = 0;
int validatedUsageCount = 0;
int quantity = 0;
double totalAmount = 0.0;
_OptionAccumulator({
required this.optionKey,
required this.optionLabel,
});
}
+2 -2
View File
@@ -16,7 +16,7 @@ class IcsExportService {
Map<String, String>? optionNames, Map<String, String>? optionNames,
}) async { }) async {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z'; final timestamp = '${DateFormat('yyyyMMddTHHmmss').format(now)}Z';
// Récupérer les informations supplémentaires // Récupérer les informations supplémentaires
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId); final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
@@ -238,7 +238,7 @@ END:VCALENDAR''';
/// Formate une date au format ICS (yyyyMMddTHHmmssZ) /// Formate une date au format ICS (yyyyMMddTHHmmssZ)
static String _formatDateForIcs(DateTime dateTime) { static String _formatDateForIcs(DateTime dateTime) {
final utcDate = dateTime.toUtc(); final utcDate = dateTime.toUtc();
return DateFormat('yyyyMMddTHHmmss').format(utcDate) + 'Z'; return '${DateFormat('yyyyMMddTHHmmss').format(utcDate)}Z';
} }
/// Échappe les caractères spéciaux pour le format ICS /// Échappe les caractères spéciaux pour le format ICS
-1
View File
@@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart'; import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw; import 'package:pdf/widgets.dart' as pw;
-1
View File
@@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
@@ -0,0 +1,78 @@
import 'package:em2rp/services/cloud_text_to_speech_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Service de synthèse vocale utilisant exclusivement Google Cloud TTS
/// Garantit une qualité et une compatibilité maximales sur tous les navigateurs
class SmartTextToSpeechService {
static bool _initialized = false;
/// Initialiser le service
static Future<void> initialize() async {
if (_initialized) return;
try {
DebugLog.info('[SmartTTS] Initializing Cloud TTS only...');
// Pré-charger les phrases courantes pour Cloud TTS
Future.delayed(const Duration(milliseconds: 500), () {
CloudTextToSpeechService.preloadCommonPhrases();
});
_initialized = true;
DebugLog.info('[SmartTTS] ✓ Initialized (Cloud TTS only)');
} catch (e) {
DebugLog.error('[SmartTTS] Initialization error', e);
_initialized = true; // Continuer quand même
}
}
/// Lire un texte à haute voix avec Google Cloud TTS
static Future<void> speak(String text) async {
if (!_initialized) {
await initialize();
}
try {
DebugLog.info('[SmartTTS] → Using Cloud TTS');
await CloudTextToSpeechService.speak(text);
DebugLog.info('[SmartTTS] ✓ Cloud TTS succeeded');
} catch (e) {
DebugLog.error('[SmartTTS] ✗ Cloud TTS failed', e);
rethrow;
}
}
/// Arrêter toute lecture en cours
static Future<void> stop() async {
try {
CloudTextToSpeechService.stopAll();
} catch (e) {
DebugLog.error('[SmartTTS] Error stopping', e);
}
}
/// Vérifier si une lecture est en cours
static Future<bool> isSpeaking() async {
// Cloud TTS n'a pas de méthode native pour vérifier le statut
// Retourner false par défaut (peut être amélioré si nécessaire)
return false;
}
/// Obtenir le statut actuel
static Map<String, dynamic> getStatus() {
return {
'initialized': _initialized,
'currentStrategy': 'Cloud TTS (exclusive)',
};
}
/// Nettoyer les ressources
static Future<void> dispose() async {
try {
CloudTextToSpeechService.clearCache();
} catch (e) {
DebugLog.error('[SmartTTS] Error disposing', e);
}
}
}
@@ -1,244 +0,0 @@
import 'dart:js_interop';
import 'package:web/web.dart' as web;
import 'package:em2rp/utils/debug_log.dart';
/// Service de synthèse vocale pour lire des textes à haute voix (Web)
class TextToSpeechService {
static bool _isInitialized = false;
static bool _voicesLoaded = false;
static List<web.SpeechSynthesisVoice> _cachedVoices = [];
/// Initialiser le service TTS
static Future<void> initialize() async {
if (_isInitialized) return;
try {
_isInitialized = true;
final synthesis = web.window.speechSynthesis;
// Essayer de charger les voix immédiatement
_cachedVoices = synthesis.getVoices().toDart;
if (_cachedVoices.isNotEmpty) {
_voicesLoaded = true;
DebugLog.info('[TextToSpeechService] Service initialized with ${_cachedVoices.length} voices');
return;
}
// Sur certains navigateurs (Firefox notamment), les voix se chargent de manière asynchrone
DebugLog.info('[TextToSpeechService] Waiting for voices to load asynchronously...');
// Attendre l'événement voiceschanged (si supporté)
final voicesLoaded = await _waitForVoices(synthesis);
if (voicesLoaded) {
_cachedVoices = synthesis.getVoices().toDart;
_voicesLoaded = true;
DebugLog.info('[TextToSpeechService] ✓ Voices loaded asynchronously: ${_cachedVoices.length}');
} else {
DebugLog.warning('[TextToSpeechService] ⚠ No voices found after initialization');
}
} catch (e) {
DebugLog.error('[TextToSpeechService] Erreur lors de l\'initialisation', e);
}
}
/// Attendre le chargement des voix (avec timeout)
static Future<bool> _waitForVoices(web.SpeechSynthesis synthesis) async {
// Essayer plusieurs fois avec des délais croissants
for (int attempt = 0; attempt < 5; attempt++) {
await Future.delayed(Duration(milliseconds: 100 * (attempt + 1)));
final voices = synthesis.getVoices().toDart;
if (voices.isNotEmpty) {
return true;
}
DebugLog.info('[TextToSpeechService] Attempt ${attempt + 1}/5: No voices yet');
}
return false;
}
/// Lire un texte à haute voix
static Future<void> speak(String text) async {
if (!_isInitialized) {
await initialize();
}
try {
final synthesis = web.window.speechSynthesis;
DebugLog.info('[TextToSpeechService] Speaking requested: "$text"');
// Arrêter toute lecture en cours
synthesis.cancel();
// Attendre un peu pour que le cancel soit effectif
await Future.delayed(const Duration(milliseconds: 50));
// Créer une nouvelle utterance
final utterance = web.SpeechSynthesisUtterance(text);
utterance.lang = 'fr-FR';
utterance.rate = 0.7;
utterance.pitch = 0.7;
utterance.volume = 1.0;
// Récupérer les voix (depuis le cache ou re-charger)
var voices = _cachedVoices;
// Si le cache est vide, essayer de recharger
if (voices.isEmpty) {
DebugLog.info('[TextToSpeechService] Cache empty, reloading voices...');
voices = synthesis.getVoices().toDart;
// Sur Firefox/Linux, les voix peuvent ne pas être disponibles immédiatement
if (voices.isEmpty && !_voicesLoaded) {
DebugLog.info('[TextToSpeechService] Waiting for voices with multiple attempts...');
// Essayer plusieurs fois avec des délais
for (int i = 0; i < 3; i++) {
await Future.delayed(Duration(milliseconds: 100 * (i + 1)));
voices = synthesis.getVoices().toDart;
if (voices.isNotEmpty) {
DebugLog.info('[TextToSpeechService] ✓ Voices loaded on attempt ${i + 1}');
break;
}
}
}
// Mettre à jour le cache
if (voices.isNotEmpty) {
_cachedVoices = voices;
_voicesLoaded = true;
}
}
DebugLog.info('[TextToSpeechService] Available voices: ${voices.length}');
if (voices.isNotEmpty) {
web.SpeechSynthesisVoice? selectedVoice;
// Lister TOUTES les voix françaises pour debug
final frenchVoices = <web.SpeechSynthesisVoice>[];
for (final voice in voices) {
final lang = voice.lang.toLowerCase();
if (lang.startsWith('fr')) {
frenchVoices.add(voice);
DebugLog.info('[TextToSpeechService] French: ${voice.name} (${voice.lang}) ${voice.localService ? 'LOCAL' : 'REMOTE'}');
}
}
if (frenchVoices.isEmpty) {
DebugLog.warning('[TextToSpeechService] ⚠ NO French voices found!');
DebugLog.info('[TextToSpeechService] Available languages:');
for (final voice in voices.take(5)) {
DebugLog.info('[TextToSpeechService] - ${voice.name} (${voice.lang})');
}
}
// Stratégie de sélection: préférer les voix LOCALES (plus fiables sur Linux)
for (final voice in frenchVoices) {
if (voice.localService) {
selectedVoice = voice;
DebugLog.info('[TextToSpeechService] ✓ Selected LOCAL French voice: ${voice.name}');
break;
}
}
// Si pas de voix locale, chercher une voix masculine
if (selectedVoice == null) {
for (final voice in frenchVoices) {
final name = voice.name.toLowerCase();
if (name.contains('male') ||
name.contains('homme') ||
name.contains('thomas') ||
name.contains('paul') ||
name.contains('bernard')) {
selectedVoice = voice;
DebugLog.info('[TextToSpeechService] Selected male voice: ${voice.name}');
break;
}
}
}
// Fallback: première voix française
selectedVoice ??= frenchVoices.isNotEmpty ? frenchVoices.first : null;
if (selectedVoice != null) {
utterance.voice = selectedVoice;
utterance.lang = selectedVoice.lang; // Utiliser la langue de la voix
DebugLog.info('[TextToSpeechService] Final voice: ${selectedVoice.name} (${selectedVoice.lang})');
} else {
DebugLog.warning('[TextToSpeechService] No French voice, using default with lang=fr-FR');
}
} else {
DebugLog.warning('[TextToSpeechService] ⚠ NO voices available at all!');
DebugLog.warning('[TextToSpeechService] On Linux: install speech-dispatcher and espeak-ng');
}
// Ajouter des événements pour le debug
utterance.onstart = (web.SpeechSynthesisEvent event) {
DebugLog.info('[TextToSpeechService] ✓ Speech started');
}.toJS;
utterance.onend = (web.SpeechSynthesisEvent event) {
DebugLog.info('[TextToSpeechService] ✓ Speech ended');
}.toJS;
utterance.onerror = (web.SpeechSynthesisErrorEvent event) {
DebugLog.error('[TextToSpeechService] ✗ Speech error: ${event.error}');
// Messages spécifiques pour aider au diagnostic
if (event.error == 'synthesis-failed') {
DebugLog.error('[TextToSpeechService] ⚠ SYNTHESIS FAILED - Common on Linux');
DebugLog.error('[TextToSpeechService] Possible causes:');
DebugLog.error('[TextToSpeechService] 1. speech-dispatcher not installed/running');
DebugLog.error('[TextToSpeechService] 2. espeak or espeak-ng not installed');
DebugLog.error('[TextToSpeechService] 3. No TTS engine configured');
DebugLog.error('[TextToSpeechService] Fix: sudo apt-get install speech-dispatcher espeak-ng');
DebugLog.error('[TextToSpeechService] Then restart browser');
} else if (event.error == 'network') {
DebugLog.error('[TextToSpeechService] Network error - online voice unavailable');
} else if (event.error == 'audio-busy') {
DebugLog.error('[TextToSpeechService] Audio system is busy');
}
}.toJS;
// Lire le texte
synthesis.speak(utterance);
DebugLog.info('[TextToSpeechService] Speech command sent');
} catch (e) {
DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e);
}
}
/// Arrêter la lecture en cours
static Future<void> stop() async {
try {
web.window.speechSynthesis.cancel();
} catch (e) {
DebugLog.error('[TextToSpeechService] Erreur lors de l\'arrêt', e);
}
}
/// Vérifier si le service est en train de lire
static Future<bool> isSpeaking() async {
try {
return web.window.speechSynthesis.speaking;
} catch (e) {
return false;
}
}
/// Nettoyer les ressources
static Future<void> dispose() async {
try {
web.window.speechSynthesis.cancel();
} catch (e) {
DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import '../views/login_page.dart';
import '../utils/colors.dart';
/// Gate de démarrage qui attend la restauration Firebase Auth avant
/// d'afficher soit le contenu connecté, soit la page de connexion.
class AppStartGate extends StatelessWidget {
const AppStartGate({super.key});
@override
Widget build(BuildContext context) {
// Sur le web, certaines erreurs natives (ex: cookies tiers bloqués)
// peuvent faire remonter une FirebaseException sur le stream d'auth.
// Pour éviter que StreamBuilder reçoive une erreur qui casse le build
// (TypeError JS interop), on "handleError" et on transforme l'erreur
// en une valeur nulle (pas d'utilisateur) afin de garder l'app stable.
// Accès protégé à `FirebaseAuth.instance` — sur le web certaines erreurs
// d'interop JS peuvent produire des TypeError non compatibles. Nous
// attrapons toute exception lors de l'accès et fournissons un stream
// neutre (pas d'utilisateur) afin de garder l'UI stable.
late final Stream<User?> safeAuthStream;
try {
safeAuthStream = FirebaseAuth.instance
.authStateChanges()
.handleError((error, stack) {
// Log pour debug ; ne rethrow pas
debugPrint('[AppStartGate] authStateChanges error: $error');
});
} catch (e, st) {
// Sur certaines configurations web l'accès à FirebaseAuth.instance
// peut échouer au niveau JS interop. On log puis on fournit un stream
// qui émet une seule valeur nulle pour indiquer "pas d'utilisateur".
debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st');
safeAuthStream = Stream<User?>.value(null);
}
return StreamBuilder<User?>(
stream: safeAuthStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _StartupSplashScreen();
}
if (snapshot.hasError) {
// En théorie handleError évite d'arriver ici, mais on garde
// une protection supplémentaire.
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
return const _StartupSplashScreen(message: 'Erreur de connexion');
}
if (snapshot.data != null) {
return const _AuthenticatedBootstrap();
}
return const LoginPage();
},
);
}
}
class _AuthenticatedBootstrap extends StatefulWidget {
const _AuthenticatedBootstrap();
@override
State<_AuthenticatedBootstrap> createState() =>
_AuthenticatedBootstrapState();
}
class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_redirectAfterAuth();
});
}
Future<void> _redirectAfterAuth() async {
final fragment = Uri.base.fragment;
if (!mounted) return;
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
Navigator.of(context).pushReplacementNamed(fragment);
} else {
Navigator.of(context).pushReplacementNamed('/calendar');
}
}
@override
Widget build(BuildContext context) {
return const _StartupSplashScreen();
}
}
class _StartupSplashScreen extends StatelessWidget {
final String message;
const _StartupSplashScreen({this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message),
],
),
),
);
}
}
+23 -2
View File
@@ -1,27 +1,48 @@
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/login_page.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/views/login_page.dart';
class AuthGuard extends StatelessWidget { class AuthGuard extends StatelessWidget {
final Widget child; final Widget child;
final String? requiredPermission; final String? requiredPermission;
final bool allowWhileLoading;
const AuthGuard({ const AuthGuard({
super.key, super.key,
required this.child, required this.child,
this.requiredPermission, this.requiredPermission,
this.allowWhileLoading = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context); final localAuthProvider = Provider.of<LocalUserProvider>(context);
final firebaseUser = FirebaseAuth.instance.currentUser;
// Log pour débug // Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission'); print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
// Si Firebase n'a pas encore restauré la session ou si le profil charge,
// afficher un écran neutre plutôt que la page de connexion.
if (firebaseUser != null &&
(localAuthProvider.currentUser == null ||
localAuthProvider.isLoadingUserData)) {
if (allowWhileLoading) {
return child;
}
return const Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
),
);
}
// Si l'utilisateur n'est pas connecté // Si l'utilisateur n'est pas connecté
if (localAuthProvider.currentUser == null) { if (firebaseUser == null || localAuthProvider.currentUser == null) {
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage'); print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const LoginPage(); return const LoginPage();
} }
+131
View File
@@ -0,0 +1,131 @@
import 'package:em2rp/services/api_service.dart';
import 'package:flutter/material.dart';
/// Utilitaires partages pour la suppression d'equipement avec forcage.
class EquipmentDeleteUtils {
static const String _legacyConflictToken = 'future_event_assignment';
static const List<String> _conflictMessageTokens = [
'cannot delete equipment because it is assigned to upcoming events',
'cannot delete equipment because it is assigned to future events',
'assigned to upcoming events',
'assigned to future events',
];
static const String deleteDialogTitle = 'Confirmer la suppression';
static const String deleteDialogCancelLabel = 'Annuler';
static const String deleteDialogConfirmLabel = 'Supprimer';
static const String deleteSuccessMessage = 'Équipement supprimé avec succès';
/// Retourne [name] si renseigne, sinon [id].
static String resolveEquipmentLabel({required String id, String? name}) {
final trimmedName = name?.trim();
if (trimmedName == null || trimmedName.isEmpty) {
return id;
}
return trimmedName;
}
/// Construit le message de confirmation de suppression d'un equipement.
static String buildSingleDeleteConfirmationMessage(String equipmentLabel) {
return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de confirmation de suppression multiple.
static String buildBulkDeleteConfirmationMessage(int selectedCount) {
return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de succes de suppression multiple.
static String buildBulkDeleteSuccessMessage(int deletedCount) {
return '$deletedCount équipement(s) supprimé(s) avec succès';
}
/// Construit un message d'erreur de suppression homogene.
static String buildDeleteErrorMessage(Object error) {
return 'Erreur lors de la suppression : $error';
}
/// Indique si l'erreur correspond a un conflit de suppression 409.
static bool isFutureAssignmentDeleteConflict(Object error) {
if (error is ApiException && !error.isConflict) {
return false;
}
final normalizedMessage = _normalizeErrorMessage(error);
if (normalizedMessage.contains(_legacyConflictToken)) {
return true;
}
return _conflictMessageTokens.any(normalizedMessage.contains);
}
/// Affiche la confirmation de suppression forcee.
static Future<bool> showForceDeleteDialog(
BuildContext context, {
required String equipmentLabel,
}) async {
final shouldForceDelete = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Équipement utilisé dans un événement à venir'),
content: Text(
'"$equipmentLabel" est assigné à au moins un événement à venir.\n\n'
'Voulez-vous forcer la suppression ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Forcer la suppression'),
),
],
),
);
return shouldForceDelete == true;
}
/// Execute une suppression, puis propose un forcage en cas de conflit 409.
static Future<bool> deleteWithFutureAssignmentCheck({
required BuildContext context,
required String equipmentLabel,
required Future<void> Function({bool forceDelete}) deleteEquipment,
}) async {
try {
await deleteEquipment(forceDelete: false);
return true;
} catch (error) {
if (!isFutureAssignmentDeleteConflict(error)) {
rethrow;
}
if (!context.mounted) {
return false;
}
final shouldForceDelete = await showForceDeleteDialog(
context,
equipmentLabel: equipmentLabel,
);
if (!shouldForceDelete) {
return false;
}
await deleteEquipment(forceDelete: true);
return true;
}
}
static String _normalizeErrorMessage(Object error) {
if (error is ApiException) {
return error.message.toLowerCase();
}
return error.toString().toLowerCase();
}
}
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager { class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance; FirebaseStorage get _storage => FirebaseStorage.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService()); final DataService _dataService = DataService(apiService);
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage. /// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg. /// Pour le Web, on fixe l'extension .jpg.
+2 -2
View File
@@ -59,7 +59,7 @@ class PerformanceMonitor {
static void printSummary() { static void printSummary() {
if (!_enabled || _results.isEmpty) return; if (!_enabled || _results.isEmpty) return;
print('\n' + '=' * 60); print('\n${'=' * 60}');
print('PERFORMANCE SUMMARY'); print('PERFORMANCE SUMMARY');
print('=' * 60); print('=' * 60);
@@ -77,7 +77,7 @@ class PerformanceMonitor {
Duration.zero, Duration.zero,
(sum, duration) => sum + duration, (sum, duration) => sum + duration,
); );
print('${'=' * 60}'); print('=' * 60);
print('TOTAL: ${total.inMilliseconds}ms'); print('TOTAL: ${total.inMilliseconds}ms');
print('=' * 60 + '\n'); print('=' * 60 + '\n');
} }
+10 -13
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import '../providers/local_user_provider.dart'; import '../providers/local_user_provider.dart';
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
passwordController.text, passwordController.text,
); );
// --- Étape 2: Charger les données utilisateur depuis Firestore --- // --- Étape 2: Charger les données utilisateur en arrière-plan ---
await localAuthProvider.loadUserData(); unawaited(
localAuthProvider.loadUserData().catchError((e) {
debugPrint('Erreur chargement profil après connexion : $e');
}),
);
// Vérifier si le contexte est toujours valide // Vérifier si le contexte est toujours valide
if (context.mounted) { if (context.mounted) {
// Vérifier si l'utilisateur a bien été chargé dans le provider Navigator.of(context, rootNavigator: true)
if (localAuthProvider.currentUser != null) { .pushReplacementNamed('/calendar');
// Utiliser pushReplacementNamed pour une transition propre
Navigator.of(context, rootNavigator: true)
.pushReplacementNamed('/calendar');
} else {
errorMessage =
'Erreur inattendue après connexion: Données utilisateur non chargées.';
isLoading = false;
notifyListeners();
}
} }
} on FirebaseAuthException catch (e) { } on FirebaseAuthException catch (e) {
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.) // Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.)
File diff suppressed because it is too large Load Diff
+158 -95
View File
@@ -7,6 +7,8 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/id_generator.dart'; import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class ContainerFormPage extends StatefulWidget { class ContainerFormPage extends StatefulWidget {
final ContainerModel? container; final ContainerModel? container;
@@ -100,7 +102,6 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
children: [ children: [
// Nom // Nom
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
@@ -169,7 +170,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
// Type // Type
DropdownButtonFormField<ContainerType>( DropdownButtonFormField<ContainerType>(
value: _selectedType, initialValue: _selectedType,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Type de container *', labelText: 'Type de container *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -194,7 +195,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
// Statut // Statut
DropdownButtonFormField<EquipmentStatus>( DropdownButtonFormField<EquipmentStatus>(
value: _selectedStatus, initialValue: _selectedStatus,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Statut *', labelText: 'Statut *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -257,7 +258,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale), prefixIcon: Icon(Icons.scale),
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -279,7 +281,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Longueur (cm)', labelText: 'Longueur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -298,7 +301,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Largeur (cm)', labelText: 'Largeur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -317,7 +321,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Hauteur (cm)', labelText: 'Hauteur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -452,6 +457,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
Future<void> _selectEquipment() async { Future<void> _selectEquipment() async {
final equipmentProvider = context.read<EquipmentProvider>(); final equipmentProvider = context.read<EquipmentProvider>();
// Toujours charger la liste complète pour éviter d'afficher uniquement
// la page paginée active d'un autre écran.
await equipmentProvider.loadEquipments();
if (!mounted) return;
await showDialog( await showDialog(
context: context, context: context,
builder: (context) => _EquipmentSelectorDialog( builder: (context) => _EquipmentSelectorDialog(
@@ -460,6 +470,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
), ),
); );
if (!mounted) return;
setState(() {}); setState(() {});
} }
@@ -535,7 +546,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e); DebugLog.error(
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
} }
} }
@@ -573,7 +585,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}); });
// Gérer les équipements ajoutés // Gérer les équipements ajoutés
final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet()); final addedEquipment =
_selectedEquipmentIds.difference(container.equipmentIds.toSet());
for (final equipmentId in addedEquipment) { for (final equipmentId in addedEquipment) {
try { try {
await provider.addEquipmentToContainer( await provider.addEquipmentToContainer(
@@ -581,12 +594,14 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e); DebugLog.error(
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
} }
} }
// Gérer les équipements retirés // Gérer les équipements retirés
final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds); final removedEquipment =
container.equipmentIds.toSet().difference(_selectedEquipmentIds);
for (final equipmentId in removedEquipment) { for (final equipmentId in removedEquipment) {
try { try {
await provider.removeEquipmentFromContainer( await provider.removeEquipmentFromContainer(
@@ -594,7 +609,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e); DebugLog.error(
'Erreur lors du retrait de l\'équipement $equipmentId', e);
} }
} }
@@ -630,28 +646,92 @@ class _EquipmentSelectorDialog extends StatefulWidget {
}); });
@override @override
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState(); State<_EquipmentSelectorDialog> createState() =>
_EquipmentSelectorDialogState();
} }
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
EquipmentCategory? _filterCategory; EquipmentCategory? _filterCategory;
String _searchQuery = ''; String _searchQuery = '';
late Set<String> _tempSelectedIds; late Set<String> _tempSelectedIds;
final List<EquipmentModel> _paginatedEquipments = [];
bool _isLoadingMore = false;
bool _hasMoreEquipments = true;
String? _lastEquipmentId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Créer une copie temporaire des IDs sélectionnés // Créer une copie temporaire des IDs sélectionnés
_tempSelectedIds = Set<String>.from(widget.selectedIds); _tempSelectedIds = Set<String>.from(widget.selectedIds);
_scrollController.addListener(_onScroll);
_loadNextPage();
} }
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
void _onScroll() {
if (_isLoadingMore) return;
if (_scrollController.hasClients &&
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
if (_hasMoreEquipments) {
_loadNextPage();
}
}
}
Future<void> _loadNextPage() async {
if (_isLoadingMore || !_hasMoreEquipments) return;
setState(() => _isLoadingMore = true);
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 50,
startAfter: _lastEquipmentId,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newEquipments = (result['equipments'] as List<dynamic>)
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
.toList();
if (mounted) {
setState(() {
_paginatedEquipments.addAll(newEquipments);
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
_lastEquipmentId = result['lastVisible'] as String?;
_isLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}
}
Future<void> _reloadData() async {
setState(() {
_paginatedEquipments.clear();
_lastEquipmentId = null;
_hasMoreEquipments = true;
});
await _loadNextPage();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Dialog( return Dialog(
@@ -701,6 +781,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_searchQuery = ''; _searchQuery = '';
}); });
_reloadData();
}, },
) )
: null, : null,
@@ -709,6 +790,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_searchQuery = value; _searchQuery = value;
}); });
_reloadData();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -726,10 +808,12 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_filterCategory = null; _filterCategory = null;
}); });
_reloadData();
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
labelStyle: TextStyle( labelStyle: TextStyle(
color: _filterCategory == null ? Colors.white : Colors.black, color:
_filterCategory == null ? Colors.white : Colors.black,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -743,10 +827,13 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_filterCategory = selected ? category : null; _filterCategory = selected ? category : null;
}); });
_reloadData();
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
labelStyle: TextStyle( labelStyle: TextStyle(
color: _filterCategory == category ? Colors.white : Colors.black, color: _filterCategory == category
? Colors.white
: Colors.black,
), ),
), ),
); );
@@ -760,7 +847,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1), color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -778,86 +865,62 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
// Liste des équipements // Liste des équipements
Expanded( Expanded(
child: StreamBuilder<List<EquipmentModel>>( child: _paginatedEquipments.isEmpty && !_isLoadingMore
stream: widget.equipmentProvider.equipmentStream, ? const Center(child: Text('Aucun équipement trouvé'))
builder: (context, snapshot) { : ListView.builder(
if (snapshot.connectionState == ConnectionState.waiting) { controller: _scrollController,
return const Center(child: CircularProgressIndicator()); itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
} itemBuilder: (context, index) {
if (index == _paginatedEquipments.length) {
if (snapshot.hasError) { return const Center(
return Center(child: Text('Erreur: ${snapshot.error}')); child: Padding(
} padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
var equipment = snapshot.data ?? [];
// Filtrer par catégorie
if (_filterCategory != null) {
equipment = equipment.where((e) => e.category == _filterCategory).toList();
}
// Filtrer par recherche
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
equipment = equipment.where((e) {
return e.id.toLowerCase().contains(query) ||
(e.brand?.toLowerCase().contains(query) ?? false) ||
(e.model?.toLowerCase().contains(query) ?? false);
}).toList();
}
if (equipment.isEmpty) {
return const Center(
child: Text('Aucun équipement trouvé'),
);
}
return ListView.builder(
itemCount: equipment.length,
itemBuilder: (context, index) {
final item = equipment[index];
final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
_tempSelectedIds.add(item.id);
} else {
_tempSelectedIds.remove(item.id);
}
});
},
title: Text(
item.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.brand != null || item.model != null)
Text('${item.brand ?? ''} ${item.model ?? ''}'),
const SizedBox(height: 4),
Text(
_getCategoryLabel(item.category),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
), ),
], );
), }
secondary: Icon(
_getCategoryIcon(item.category), final item = _paginatedEquipments[index];
color: AppColors.rouge, final isSelected = _tempSelectedIds.contains(item.id);
),
activeColor: AppColors.rouge, return CheckboxListTile(
); value: isSelected,
}, onChanged: (selected) {
); setState(() {
}, if (selected == true) {
), _tempSelectedIds.add(item.id);
} else {
_tempSelectedIds.remove(item.id);
}
});
},
title: Text(
item.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.brand != null || item.model != null)
Text('${item.brand ?? ''} ${item.model ?? ''}'),
const SizedBox(height: 4),
Text(
_getCategoryLabel(item.category),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
secondary: Icon(
_getCategoryIcon(item.category),
color: AppColors.rouge,
),
activeColor: AppColors.rouge,
);
},
),
), ),
// Boutons d'action // Boutons d'action
+21 -3
View File
@@ -3,6 +3,7 @@ import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/data_management/event_types_management.dart'; import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
import 'package:em2rp/views/widgets/data_management/options_management.dart'; import 'package:em2rp/views/widgets/data_management/options_management.dart';
import 'package:em2rp/views/widgets/data_management/events_export.dart'; import 'package:em2rp/views/widgets/data_management/events_export.dart';
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/utils/permission_gate.dart';
@@ -32,6 +33,23 @@ class _DataManagementPageState extends State<DataManagementPage> {
icon: Icons.file_download, icon: Icons.file_download,
widget: const EventsExport(), widget: const EventsExport(),
), ),
DataCategory(
title: 'Statistiques evenements',
icon: Icons.bar_chart,
widget: const PermissionGate(
requiredPermissions: ['generate_reports'],
fallback: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Vous n\'avez pas les permissions necessaires pour voir les statistiques.',
textAlign: TextAlign.center,
),
),
),
child: EventStatisticsTab(),
),
),
]; ];
@override @override
@@ -78,7 +96,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
child: Column( child: Column(
children: [ children: [
// Menu horizontal en mobile // Menu horizontal en mobile
Container( SizedBox(
height: 60, height: 60,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -143,7 +161,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1), color: AppColors.rouge.withValues(alpha: 0.1),
), ),
child: Row( child: Row(
children: [ children: [
@@ -177,7 +195,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
), ),
), ),
selected: isSelected, selected: isSelected,
selectedTileColor: AppColors.rouge.withOpacity(0.1), selectedTileColor: AppColors.rouge.withValues(alpha: 0.1),
onTap: () => setState(() => _selectedIndex = index), onTap: () => setState(() => _selectedIndex = index),
); );
}, },
+49 -23
View File
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/qr_code_service.dart'; import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart'; import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart'; import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
Future<void> _loadMaintenances() async { Future<void> _loadMaintenances() async {
try { try {
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id); final maintenances = await _equipmentService
.getMaintenancesForEquipment(widget.equipment.id);
setState(() { setState(() {
_maintenances = maintenances; _maintenances = maintenances;
_isLoadingMaintenances = false; _isLoadingMaintenances = false;
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24), const SizedBox(height: 24),
// 3. Notes // 3. Notes
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[ if (widget.equipment.notes != null &&
widget.equipment.notes!.isNotEmpty) ...[
EquipmentNotesSection(notes: widget.equipment.notes!), EquipmentNotesSection(notes: widget.equipment.notes!),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
); );
} }
void _showQRCode() { void _showQRCode() {
showDialog( showDialog(
context: context, context: context,
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(), '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
.trim(),
style: TextStyle(color: Colors.grey[700]), style: TextStyle(color: Colors.grey[700]),
), ),
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[ if (widget.equipment.subCategory != null &&
widget.equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'📁 ${widget.equipment.subCategory}', '📁 ${widget.equipment.subCategory}',
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
if (!hasPermission) { if (!hasPermission) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Vous n\'avez pas la permission de gérer les maintenances'), content:
Text('Vous n\'avez pas la permission de gérer les maintenances'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
} }
void _deleteEquipment() { void _deleteEquipment() {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: widget.equipment.id,
name: widget.equipment.name,
);
showDialog( showDialog(
context: context, context: pageContext,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Confirmer la suppression'), title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text( content: Text(
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.', EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'), child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
// Fermer le dialog // Fermer le dialog
Navigator.pop(context); Navigator.pop(dialogContext);
// Capturer le ScaffoldMessenger avant la suppression // Capturer le ScaffoldMessenger avant la suppression
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final navigator = Navigator.of(context); final navigator = Navigator.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try { try {
await context final deleted =
.read<EquipmentProvider>() await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
.deleteEquipment(widget.equipment.id); context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
widget.equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
// Revenir à la page précédente // Revenir à la page précédente
navigator.pop(); navigator.pop();
@@ -455,19 +477,23 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
// Afficher le snackbar (même si le widget est démonté) // Afficher le snackbar (même si le widget est démonté)
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
const SnackBar( const SnackBar(
content: Text('Équipement supprimé avec succès'), content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
} catch (e) { } catch (e) {
// Afficher l'erreur // Afficher l'erreur
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur: $e')), SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
); );
} }
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'), child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
), ),
], ],
), ),
+5 -5
View File
@@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
TextFormField( TextFormField(
controller: _identifierController, controller: _identifierController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Identifiant *', labelText: 'Identifiant (Laissez vide pour auto-génération) *',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag), prefixIcon: const Icon(Icons.tag),
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement', hintText: isEditing ? null : 'Auto-attribué par défaut',
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}', helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle',
), ),
enabled: !isEditing, enabled: !isEditing,
validator: (value) { validator: (value) {
@@ -271,7 +271,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
children: [ children: [
Expanded( Expanded(
child: DropdownButtonFormField<EquipmentCategory>( child: DropdownButtonFormField<EquipmentCategory>(
value: _selectedCategory, initialValue: _selectedCategory,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Catégorie *', labelText: 'Catégorie *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -299,7 +299,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: DropdownButtonFormField<EquipmentStatus>( child: DropdownButtonFormField<EquipmentStatus>(
value: _selectedStatus, initialValue: _selectedStatus,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Statut *', labelText: 'Statut *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
+231 -165
View File
@@ -16,6 +16,7 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart'; import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart'; import 'package:em2rp/views/widgets/notification_badge.dart';
@@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget {
_EquipmentManagementPageState(); _EquipmentManagementPageState();
} }
class _EquipmentManagementPageState extends State<EquipmentManagementPage> class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> { with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
@@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (_scrollController.hasClients && if (_scrollController.hasClients &&
_scrollController.position.pixels >= _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) { _scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus // Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) { if (provider.hasMore && !provider.isLoadingMore) {
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll // ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
@@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_isLoadingMore = false; _isLoadingMore = false;
}).catchError((error) { }).catchError((error) {
_isLoadingMore = false; _isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error); DebugLog.error(
'[EquipmentManagementPage] Error loading next page', error);
}); });
} }
} }
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentList() { Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>( return Consumer<EquipmentProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}'); DebugLog.info(
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
// Afficher l'indicateur de chargement initial uniquement // Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) { if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator'); DebugLog.info(
'[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
); );
} }
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items'); DebugLog.info(
'[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement) // Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0); final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
@@ -526,124 +529,127 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
// ✅ RepaintBoundary pour isoler le repaint de chaque carte // ✅ RepaintBoundary pour isoler le repaint de chaque carte
return RepaintBoundary( return RepaintBoundary(
key: ValueKey(equipment.id), key: ValueKey(equipment.id),
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
color: isSelectionMode && isSelected color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.1) ? AppColors.rouge.withValues(alpha: 0.1)
: null, : null,
child: ListTile( child: ListTile(
leading: isSelectionMode leading: isSelectionMode
? Checkbox( ? Checkbox(
value: isSelected, value: isSelected,
onChanged: (value) => toggleItemSelection(equipment.id), onChanged: (value) => toggleItemSelection(equipment.id),
activeColor: AppColors.rouge, activeColor: AppColors.rouge,
) )
: CircleAvatar( : CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2), backgroundColor:
child: equipment.category.getIcon( equipment.category.color.withValues(alpha: 0.2),
size: 20, child: equipment.category.getIcon(
color: equipment.category.color, size: 20,
), color: equipment.category.color,
), ),
title: Row( ),
children: [ title: Row(
Expanded( children: [
child: Text( Expanded(
equipment.id, child: Text(
style: const TextStyle(fontWeight: FontWeight.bold), equipment.id,
), style: const TextStyle(fontWeight: FontWeight.bold),
),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
.isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontStyle: FontStyle.italic,
), ),
), ),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
], ],
// Afficher la quantité disponible pour les consommables/câbles ),
if (equipment.category == EquipmentCategory.consumable || subtitle: Column(
equipment.category == EquipmentCategory.cable) ...[ crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4), const SizedBox(height: 4),
_buildQuantityDisplay(equipment), Text(
], '${equipment.brand ?? ''} ${equipment.model ?? ''}'
], .trim()
), .isNotEmpty
trailing: isSelectionMode ? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
? null .trim()
: Row( : 'Marque/Modèle non défini',
mainAxisSize: MainAxisSize.min, style: TextStyle(color: Colors.grey[600], fontSize: 14),
children: [ ),
// Bouton Restock (uniquement pour consommables/câbles avec permission) // Afficher la sous-catégorie si elle existe
if (equipment.category == EquipmentCategory.consumable || if (equipment.subCategory != null &&
equipment.category == EquipmentCategory.cable) equipment.subCategory!.isNotEmpty) ...[
PermissionGate( const SizedBox(height: 2),
requiredPermissions: const ['manage_equipment'], Text(
child: IconButton( '📁 ${equipment.subCategory}',
icon: const Icon(Icons.add_shopping_cart, style: TextStyle(
color: AppColors.rouge), color: Colors.grey[500],
tooltip: 'Restock', fontSize: 12,
onPressed: () => _showRestockDialog(equipment), fontStyle: FontStyle.italic,
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
), ),
), ),
], ],
), // Afficher la quantité disponible pour les consommables/câbles
onTap: isSelectionMode if (equipment.category == EquipmentCategory.consumable ||
? () => toggleItemSelection(equipment.id) equipment.category == EquipmentCategory.cable) ...[
: () => _viewEquipmentDetails(equipment), const SizedBox(height: 4),
), _buildQuantityDisplay(equipment),
) ],
); ],
),
trailing: isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart,
color: AppColors.rouge),
tooltip: 'Restock',
onPressed: () => _showRestockDialog(equipment),
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) =>
QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
),
),
],
),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
));
} }
Widget _buildQuantityDisplay(EquipmentModel equipment) { Widget _buildQuantityDisplay(EquipmentModel equipment) {
@@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
); );
} }
// Actions // Actions
void _createNewEquipment() { void _createNewEquipment() {
Navigator.push( Navigator.push(
@@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
} }
void _deleteEquipment(EquipmentModel equipment) { void _deleteEquipment(EquipmentModel equipment) {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: equipment.id,
name: equipment.name,
);
showDialog( showDialog(
context: context, context: pageContext,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Confirmer la suppression'), title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'), content: Text(
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'), child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try { try {
await context final deleted =
.read<EquipmentProvider>() await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
.deleteEquipment(equipment.id); context: pageContext,
if (mounted) { equipmentLabel: equipmentLabel,
ScaffoldMessenger.of(context).showSnackBar( deleteEquipment: ({bool forceDelete = false}) {
const SnackBar( return provider.deleteEquipment(
content: Text('Équipement supprimé avec succès')), equipment.id,
); forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
} }
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) { } catch (e) {
if (mounted) { scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar(content: Text('Erreur: $e')), content: Text(
); EquipmentDeleteUtils.buildDeleteErrorMessage(e),
} ),
),
);
} }
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'), child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
), ),
], ],
), ),
@@ -768,46 +798,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
void _deleteSelectedEquipment() async { void _deleteSelectedEquipment() async {
if (!hasSelection) return; if (!hasSelection) return;
final pageContext = context;
showDialog( showDialog(
context: context, context: pageContext,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Confirmer la suppression'), title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text( content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?', EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
selectedCount,
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'), child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try { try {
final provider = context.read<EquipmentProvider>(); final equipmentById = {
for (final equipment
in provider.equipment)
equipment.id: equipment,
};
var deletedCount = 0;
for (final id in selectedIds) { for (final id in selectedIds) {
await provider.deleteEquipment(id); final label = EquipmentDeleteUtils.resolveEquipmentLabel(
id: id,
name: equipmentById[id]?.name,
);
final deleted = await EquipmentDeleteUtils
.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: label,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
id,
forceDelete: forceDelete,
);
},
);
if (deleted) {
deletedCount++;
}
} }
disableSelectionMode(); disableSelectionMode();
if (mounted) { scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar( content: Text(
content: Text( EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
'$selectedCount équipement(s) supprimé(s) avec succès'), deletedCount,
backgroundColor: Colors.green, ),
), ),
); backgroundColor: Colors.green,
} ),
);
} catch (e) { } catch (e) {
if (mounted) { scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar(content: Text('Erreur: $e')), content: Text(
); EquipmentDeleteUtils.buildDeleteErrorMessage(e),
} ),
),
);
} }
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'), child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
), ),
], ],
), ),
@@ -853,7 +915,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) { if (mounted) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first), builder: (context) =>
QRCodeDialog.forEquipment(selectedEquipment.first),
); );
} }
} else { } else {
@@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
); );
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment); await context
.read<EquipmentProvider>()
.updateEquipment(updatedEquipment);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'), content: Text(
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
+25 -22
View File
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
return; return;
} }
final success = await _controller.submitForm(context, existingEvent: widget.event); final success =
await _controller.submitForm(context, existingEvent: widget.event);
if (success && mounted) { if (success && mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
@@ -158,21 +159,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'), title: Text(
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
), ),
body: Center( body: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: (isMobile child: (isMobile
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: _buildFormContent(isMobile), child: _buildFormContent(isMobile),
) )
: Card( : Card(
elevation: 6, elevation: 6,
margin: const EdgeInsets.all(24), margin: const EdgeInsets.all(24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18)),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), padding: const EdgeInsets.symmetric(
horizontal: 32, vertical: 32),
child: _buildFormContent(isMobile), child: _buildFormContent(isMobile),
), ),
)), )),
@@ -186,15 +191,6 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
Widget _buildFormContent(bool isMobile) { Widget _buildFormContent(bool isMobile) {
return Consumer<EventFormController>( return Consumer<EventFormController>(
builder: (context, controller, child) { builder: (context, controller, child) {
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
final selectedEventTypeIndex = controller.selectedEventTypeId != null
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
: -1;
final selectedEventType = selectedEventTypeIndex != -1
? controller.eventTypes[selectedEventTypeIndex]
: null;
final selectedEventTypeName = selectedEventType?.name;
return Form( return Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -209,18 +205,22 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
selectedEventTypeId: controller.selectedEventTypeId, selectedEventTypeId: controller.selectedEventTypeId,
startDateTime: controller.startDateTime, startDateTime: controller.startDateTime,
endDateTime: controller.endDateTime, endDateTime: controller.endDateTime,
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context), onEventTypeChanged: (typeId) =>
controller.onEventTypeChanged(typeId, context),
onStartDateTimeChanged: controller.setStartDateTime, onStartDateTimeChanged: controller.setStartDateTime,
onEndDateTimeChanged: controller.setEndDateTime, onEndDateTimeChanged: controller.setEndDateTime,
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur onAnyFieldChanged:
() {}, // Géré automatiquement par le contrôleur
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
OptionSelectorWidget( OptionSelectorWidget(
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom eventType: controller
.selectedEventTypeId, // Utilise l'ID au lieu du nom
selectedOptions: controller.selectedOptions, selectedOptions: controller.selectedOptions,
onChanged: controller.setSelectedOptions, onChanged: controller.setSelectedOptions,
onRemove: (optionId) { onRemove: (optionId) {
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions); final newOptions = List<Map<String, dynamic>>.from(
controller.selectedOptions);
newOptions.removeWhere((o) => o['id'] == optionId); newOptions.removeWhere((o) => o['id'] == optionId);
controller.setSelectedOptions(newOptions); controller.setSelectedOptions(newOptions);
}, },
@@ -236,6 +236,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
endDate: controller.endDateTime, endDate: controller.endDateTime,
onChanged: controller.setAssignedEquipment, onChanged: controller.setAssignedEquipment,
eventId: widget.event?.id, eventId: widget.event?.id,
eventTypeId: controller.selectedEventTypeId,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
EventDetailsSection( EventDetailsSection(
@@ -247,7 +248,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
contactEmailController: controller.contactEmailController, contactEmailController: controller.contactEmailController,
contactPhoneController: controller.contactPhoneController, contactPhoneController: controller.contactPhoneController,
isMobile: isMobile, isMobile: isMobile,
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur onAnyFieldChanged:
() {}, // Géré automatiquement par le contrôleur
), ),
EventStaffAndDocumentsSection( EventStaffAndDocumentsSection(
allUsers: controller.allUsers, allUsers: controller.allUsers,
@@ -290,9 +292,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
} }
}, },
onSubmit: _submit, onSubmit: _submit,
onSetConfirmed: !isEditMode ? () { onSetConfirmed: !isEditMode ? () {} : null,
} : null, onDelete: isEditMode
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression ? _deleteEvent
: null, // Ajout du callback de suppression
), ),
], ],
), ),
+41 -19
View File
@@ -11,8 +11,9 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/qr_code_processing_service.dart'; import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_service.dart'; import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/text_to_speech_service.dart'; import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/event_preparation_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart'; import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
@@ -49,18 +50,18 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
late final DataService _dataService; late final DataService _dataService;
late final QRCodeProcessingService _qrCodeService; late final QRCodeProcessingService _qrCodeService;
Map<String, EquipmentModel> _equipmentCache = {}; final Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {}; final Map<String, ContainerModel> _containerCache = {};
Map<String, int> _returnedQuantities = {}; final Map<String, int> _returnedQuantities = {};
// État local des validations (non sauvegardé jusqu'à la validation finale) // État local des validations (non sauvegardé jusqu'à la validation finale)
Map<String, bool> _localValidationState = {}; final Map<String, bool> _localValidationState = {};
// Gestion des quantités par étape // Gestion des quantités par étape
Map<String, int> _quantitiesAtPreparation = {}; final Map<String, int> _quantitiesAtPreparation = {};
Map<String, int> _quantitiesAtLoading = {}; final Map<String, int> _quantitiesAtLoading = {};
Map<String, int> _quantitiesAtUnloading = {}; final Map<String, int> _quantitiesAtUnloading = {};
Map<String, int> _quantitiesAtReturn = {}; final Map<String, int> _quantitiesAtReturn = {};
bool _isLoading = true; bool _isLoading = true;
bool _isValidating = false; bool _isValidating = false;
@@ -121,8 +122,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
); );
// Initialiser le service de synthèse vocale // Initialiser le service de synthèse vocale hybride
TextToSpeechService.initialize(); SmartTextToSpeechService.initialize();
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay) // Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
AudioFeedbackService.unlockAudio(); AudioFeedbackService.unlockAudio();
@@ -164,7 +165,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
_animationController.dispose(); _animationController.dispose();
_manualCodeController.dispose(); _manualCodeController.dispose();
_manualCodeFocusNode.dispose(); _manualCodeFocusNode.dispose();
TextToSpeechService.stop(); SmartTextToSpeechService.stop();
super.dispose(); super.dispose();
} }
@@ -1097,6 +1098,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Détermine le statut d'un équipement selon l'étape actuelle /// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) { String _determineEquipmentStatus(EventEquipment eq) {
if (_isNotTakenToEventAtReturn(eq)) {
return 'NOT_TAKEN';
}
// Vérifier d'abord si l'équipement est perdu (LOST) // Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) { if (_shouldMarkAsLost(eq)) {
return 'LOST'; return 'LOST';
@@ -1118,14 +1123,31 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Vérifie si un équipement doit être marqué comme LOST /// Vérifie si un équipement doit être marqué comme LOST
bool _shouldMarkAsLost(EventEquipment eq) { bool _shouldMarkAsLost(EventEquipment eq) {
// Seulement aux étapes de retour return EventPreparationService.shouldMarkEquipmentAsLost(
if (_currentStep != PreparationStep.return_ && isReturnValidationStep: _isReturnValidationStep,
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) { isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool _isNotTakenToEventAtReturn(EventEquipment eq) {
if (!_isReturnValidationStep) {
return false; return false;
} }
// Si manquant maintenant mais PAS manquant à la préparation = LOST return EventPreparationService.isEquipmentNotTakenToEvent(
return eq.isMissingAtReturn && !eq.isMissingAtPreparation; isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool get _isReturnValidationStep {
return _currentStep == PreparationStep.return_ ||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously);
} }
/// Vérifie si un équipement est manquant à l'étape actuelle /// Vérifie si un équipement est manquant à l'étape actuelle
@@ -1212,9 +1234,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
Future<void> _announceNextItem() async { Future<void> _announceNextItem() async {
final nextItem = _findNextItemToScan(); final nextItem = _findNextItemToScan();
if (nextItem != null) { if (nextItem != null) {
await TextToSpeechService.speak('Prochain item: $nextItem'); await SmartTextToSpeechService.speak('Prochain item: $nextItem');
} else { } else {
await TextToSpeechService.speak('Tous les items sont validés'); await SmartTextToSpeechService.speak('Tous les items sont validés');
} }
} }
@@ -0,0 +1,31 @@
import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:flutter/material.dart';
class EventStatisticsPage extends StatelessWidget {
const EventStatisticsPage({super.key});
@override
Widget build(BuildContext context) {
return PermissionGate(
requiredPermissions: const ['generate_reports'],
fallback: const Scaffold(
appBar: CustomAppBar(title: 'Acces refuse'),
body: Center(
child: Text(
'Vous n\'avez pas les permissions necessaires pour acceder aux statistiques.',
textAlign: TextAlign.center,
),
),
),
child: const Scaffold(
appBar: CustomAppBar(title: 'Statistiques evenements'),
drawer: MainDrawer(currentPage: '/event_statistics'),
body: EventStatisticsTab(),
),
);
}
}
@@ -1,4 +1,3 @@
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -133,7 +133,7 @@ class EventDetailsDocuments extends StatelessWidget {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Dialog( return Dialog(
child: Container( child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9, width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.8, height: MediaQuery.of(context).size.height * 0.8,
child: Column( child: Column(
@@ -7,8 +7,9 @@ import 'package:em2rp/views/event_add_page.dart';
import 'package:em2rp/services/ics_export_service.dart'; import 'package:em2rp/services/ics_export_service.dart';
import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'dart:html' as html; import 'package:web/web.dart' as web;
import 'dart:convert'; import 'dart:convert';
import 'dart:js_interop';
class EventDetailsHeader extends StatefulWidget { class EventDetailsHeader extends StatefulWidget {
final EventModel event; final EventModel event;
@@ -180,12 +181,13 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
// Créer un blob et télécharger le fichier // Créer un blob et télécharger le fichier
final bytes = utf8.encode(icsContent); final bytes = utf8.encode(icsContent);
final blob = html.Blob([bytes], 'text/calendar'); final blob = web.Blob([bytes.toJS].toJS, web.BlobPropertyBag(type: 'text/calendar'));
final url = html.Url.createObjectUrlFromBlob(blob); final url = web.URL.createObjectURL(blob);
html.AnchorElement(href: url) final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
..setAttribute('download', fileName) anchor.href = url;
..click(); anchor.download = fileName;
html.Url.revokeObjectUrl(url); anchor.click();
web.URL.revokeObjectURL(url);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -26,6 +26,16 @@ class _EventStatusButtonState extends State<EventStatusButton> {
EventStatus? _optimisticStatus; EventStatus? _optimisticStatus;
final DataService _dataService = DataService(FirebaseFunctionsApiService()); final DataService _dataService = DataService(FirebaseFunctionsApiService());
@override
void didUpdateWidget(EventStatusButton oldWidget) {
super.didUpdateWidget(oldWidget);
// Réinitialiser le statut optimiste si on affiche un nouvel événement
if (oldWidget.event.id != widget.event.id) {
_optimisticStatus = null;
_loading = false;
}
}
Future<void> _changeStatus(EventStatus newStatus) async { Future<void> _changeStatus(EventStatus newStatus) async {
if ((widget.event.status == newStatus) || _loading) return; if ((widget.event.status == newStatus) || _loading) return;
setState(() { setState(() {
@@ -5,6 +5,11 @@ import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/calendar_utils.dart'; import 'package:em2rp/utils/calendar_utils.dart';
class MonthView extends StatelessWidget { class MonthView extends StatelessWidget {
static const double _calendarPadding = 8.0;
static const double _headerHeight = 52.0;
static const double _headerVerticalPadding = 16.0;
static const double _daysOfWeekHeight = 16.0;
final DateTime focusedDay; final DateTime focusedDay;
final DateTime? selectedDay; final DateTime? selectedDay;
final CalendarFormat calendarFormat; final CalendarFormat calendarFormat;
@@ -30,11 +35,17 @@ class MonthView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final rowHeight = (constraints.maxHeight - 100) / 6; final rowCount = _computeRowCount(focusedDay);
final availableHeight = constraints.maxHeight -
(_calendarPadding * 2) -
_headerHeight -
_headerVerticalPadding -
_daysOfWeekHeight;
final rowHeight = availableHeight / rowCount;
return Container( return Container(
height: constraints.maxHeight, height: constraints.maxHeight,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(_calendarPadding),
child: TableCalendar( child: TableCalendar(
firstDay: DateTime.utc(2020, 1, 1), firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.utc(2030, 12, 31), lastDay: DateTime.utc(2030, 12, 31),
@@ -42,6 +53,7 @@ class MonthView extends StatelessWidget {
calendarFormat: calendarFormat, calendarFormat: calendarFormat,
startingDayOfWeek: StartingDayOfWeek.monday, startingDayOfWeek: StartingDayOfWeek.monday,
locale: 'fr_FR', locale: 'fr_FR',
daysOfWeekHeight: _daysOfWeekHeight,
availableCalendarFormats: const { availableCalendarFormats: const {
CalendarFormat.month: 'Mois', CalendarFormat.month: 'Mois',
CalendarFormat.week: 'Semaine', CalendarFormat.week: 'Semaine',
@@ -132,10 +144,9 @@ class MonthView extends StatelessWidget {
Widget _buildDayCell(DateTime day, bool isSelected, {bool isToday = false}) { Widget _buildDayCell(DateTime day, bool isSelected, {bool isToday = false}) {
final dayEvents = CalendarUtils.getEventsForDay(day, events); final dayEvents = CalendarUtils.getEventsForDay(day, events);
final statusCounts = _getStatusCounts(dayEvents);
final textColor = final textColor =
isSelected ? Colors.white : (isToday ? AppColors.rouge : null); isSelected ? Colors.white : (isToday ? AppColors.rouge : null);
final badgeColor = isSelected ? Colors.white : AppColors.rouge;
final badgeTextColor = isSelected ? AppColors.rouge : Colors.white;
BoxDecoration decoration; BoxDecoration decoration;
if (isSelected) { if (isSelected) {
@@ -161,56 +172,125 @@ class MonthView extends StatelessWidget {
return Container( return Container(
margin: const EdgeInsets.all(4), margin: const EdgeInsets.all(4),
decoration: decoration, decoration: decoration,
child: Stack( child: Padding(
children: [ padding: const EdgeInsets.all(4),
Positioned( child: Column(
top: 4, crossAxisAlignment: CrossAxisAlignment.start,
left: 4, children: [
child: Text( Row(
day.day.toString(), crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(color: textColor), children: [
), Text(
), day.day.toString(),
if (dayEvents.isNotEmpty) style: TextStyle(color: textColor),
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(10),
), ),
child: Text( const SizedBox(width: 4),
dayEvents.length.toString(), Expanded(
style: TextStyle( child: Align(
color: badgeTextColor, alignment: Alignment.topRight,
fontSize: 12, child: Wrap(
fontWeight: FontWeight.bold, spacing: 4,
runSpacing: 2,
alignment: WrapAlignment.end,
children: _buildStatusBadges(statusCounts),
),
),
),
],
),
if (dayEvents.isNotEmpty) ...[
const SizedBox(height: 4),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: dayEvents
.map((event) => _buildEventItem(event, isSelected, day))
.toList(),
), ),
), ),
), ),
), ],
if (dayEvents.isNotEmpty) ],
Positioned( ),
bottom: 2,
left: 2,
right: 2,
top: 28,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: dayEvents
.map((event) => _buildEventItem(event, isSelected, day))
.toList(),
),
),
),
],
), ),
); );
} }
Map<EventStatus, int> _getStatusCounts(List<EventModel> dayEvents) {
final counts = <EventStatus, int>{
EventStatus.confirmed: 0,
EventStatus.waitingForApproval: 0,
EventStatus.canceled: 0,
};
for (final event in dayEvents) {
counts[event.status] = (counts[event.status] ?? 0) + 1;
}
return counts;
}
List<Widget> _buildStatusBadges(Map<EventStatus, int> statusCounts) {
final badges = <Widget>[];
void addBadge({
required EventStatus status,
required Color backgroundColor,
required Color textColor,
required String tooltipLabel,
}) {
final count = statusCounts[status] ?? 0;
if (count <= 0) {
return;
}
badges.add(
Tooltip(
message: '$count $tooltipLabel',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
count.toString(),
style: TextStyle(
color: textColor,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
addBadge(
status: EventStatus.confirmed,
backgroundColor: Colors.green,
textColor: Colors.white,
tooltipLabel:
'validé${(statusCounts[EventStatus.confirmed] ?? 0) > 1 ? 's' : ''}',
);
addBadge(
status: EventStatus.waitingForApproval,
backgroundColor: Colors.amber,
textColor: Colors.black,
tooltipLabel: 'en attente',
);
addBadge(
status: EventStatus.canceled,
backgroundColor: Colors.red,
textColor: Colors.white,
tooltipLabel:
'annulé${(statusCounts[EventStatus.canceled] ?? 0) > 1 ? 's' : ''}',
);
return badges;
}
Widget _buildEventItem( Widget _buildEventItem(
EventModel event, bool isSelected, DateTime currentDay) { EventModel event, bool isSelected, DateTime currentDay) {
Color color; Color color;
@@ -228,7 +308,6 @@ class MonthView extends StatelessWidget {
icon = Icons.close; icon = Icons.close;
break; break;
case EventStatus.waitingForApproval: case EventStatus.waitingForApproval:
default:
color = Colors.amber; color = Colors.amber;
textColor = Colors.black; textColor = Colors.black;
icon = Icons.hourglass_empty; icon = Icons.hourglass_empty;
@@ -243,7 +322,8 @@ class MonthView extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 2), margin: const EdgeInsets.only(bottom: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? color.withAlpha(220) : color.withOpacity(0.18), color:
isSelected ? color.withAlpha(220) : color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
@@ -282,4 +362,13 @@ class MonthView extends StatelessWidget {
), ),
); );
} }
/// Calcule le nombre de rangées affichées pour le mois de [focusedDay]
/// (calendrier commençant le lundi : offset = weekday - 1)
int _computeRowCount(DateTime focusedDay) {
final firstOfMonth = DateTime(focusedDay.year, focusedDay.month, 1);
final daysInMonth = DateTime(focusedDay.year, focusedDay.month + 1, 0).day;
final offset = (firstOfMonth.weekday - 1) % 7; // 0 = lundi, 6 = dimanche
return ((daysInMonth + offset) / 7).ceil();
}
} }
@@ -132,7 +132,7 @@ class _UserFilterDropdownState extends State<UserFilterDropdown> {
], ],
), ),
); );
}).toList(), }),
], ],
), ),
), ),
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:web/web.dart' as web; import 'package:web/web.dart' as web;
import 'package:em2rp/services/audio_feedback_service.dart'; import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/text_to_speech_service.dart'; import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
/// Bouton de diagnostic pour tester l'audio et le TTS /// Bouton de diagnostic pour tester l'audio et le TTS
@@ -59,11 +59,11 @@ class AudioDiagnosticButton extends StatelessWidget {
DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}'); DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}');
DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}'); DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}');
await TextToSpeechService.initialize(); await SmartTextToSpeechService.initialize();
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
DebugLog.info('[AudioDiagnostic] Speaking test phrase...'); DebugLog.info('[AudioDiagnostic] Speaking test phrase...');
await TextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.'); await SmartTextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.');
DebugLog.info('[AudioDiagnostic] ========== TTS TEST END =========='); DebugLog.info('[AudioDiagnostic] ========== TTS TEST END ==========');
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
class StartupSplashScreen extends StatelessWidget {
final String message;
const StartupSplashScreen({super.key, this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppColors.noir,
fontSize: 16,
),
),
],
),
),
);
}
}
@@ -0,0 +1,715 @@
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_statistics_models.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/event_statistics_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
enum _AmountDisplayMode { ht, ttc }
enum _DatePreset { currentMonth, previousMonth, currentYear, previousYear }
class EventStatisticsTab extends StatefulWidget {
const EventStatisticsTab({super.key});
@override
State<EventStatisticsTab> createState() => _EventStatisticsTabState();
}
class _EventStatisticsTabState extends State<EventStatisticsTab> {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
final EventStatisticsService _statisticsService =
const EventStatisticsService();
final NumberFormat _currencyFormat =
NumberFormat.currency(locale: 'fr_FR', symbol: 'EUR ');
final NumberFormat _percentFormat = NumberFormat.percentPattern('fr_FR');
DateTimeRange _selectedPeriod = _initialPeriod();
final Set<String> _selectedEventTypeIds = {};
final Set<EventStatus> _selectedStatuses = {
EventStatus.confirmed,
EventStatus.waitingForApproval,
};
_AmountDisplayMode _amountDisplayMode = _AmountDisplayMode.ht;
bool _isLoading = true;
String? _errorMessage;
List<EventModel> _events = [];
Map<String, String> _eventTypeNames = {};
EventStatisticsSummary _summary = EventStatisticsSummary.empty;
@override
void initState() {
super.initState();
_loadStatistics();
}
static DateTimeRange _initialPeriod() {
final now = DateTime.now();
return DateTimeRange(
start: DateTime(now.year, now.month, 1),
end: DateTime(now.year, now.month + 1, 0, 23, 59, 59),
);
}
Future<void> _loadStatistics() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final localUserProvider =
Provider.of<LocalUserProvider>(context, listen: false);
final userId = localUserProvider.uid;
final results = await Future.wait([
_dataService.getEvents(userId: userId),
_dataService.getEventTypes(),
]);
final eventsResult = results[0] as Map<String, dynamic>;
final eventTypesResult = results[1] as List<Map<String, dynamic>>;
final eventsData = eventsResult['events'] as List<Map<String, dynamic>>;
final parsedEvents = <EventModel>[];
for (final eventData in eventsData) {
try {
parsedEvents
.add(EventModel.fromMap(eventData, eventData['id'] as String));
} catch (_) {
// Ignore malformed rows and continue to keep the dashboard available.
}
}
final eventTypeNames = <String, String>{};
for (final eventType in eventTypesResult) {
final id = (eventType['id'] ?? '').toString();
if (id.isEmpty) {
continue;
}
eventTypeNames[id] = (eventType['name'] ?? id).toString();
}
if (!mounted) {
return;
}
setState(() {
_events = parsedEvents;
_eventTypeNames = eventTypeNames;
_isLoading = false;
});
_rebuildSummary();
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des statistiques: $error';
});
}
}
void _rebuildSummary() {
final filter = EventStatisticsFilter(
period: _selectedPeriod,
eventTypeIds: _selectedEventTypeIds,
includeCanceled: _selectedStatuses.contains(EventStatus.canceled),
selectedStatuses: _selectedStatuses,
);
setState(() {
_summary = _statisticsService.buildSummary(
events: _events,
filter: filter,
eventTypeNames: _eventTypeNames,
);
});
}
Future<void> _selectDateRange() async {
final selectedRange = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2035),
initialDateRange: _selectedPeriod,
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.rouge,
onPrimary: Colors.white,
),
),
child: child ?? const SizedBox.shrink(),
);
},
);
if (selectedRange == null) {
return;
}
setState(() {
_selectedPeriod = DateTimeRange(
start: selectedRange.start,
end: DateTime(
selectedRange.end.year,
selectedRange.end.month,
selectedRange.end.day,
23,
59,
59,
),
);
});
_rebuildSummary();
}
void _resetFilters() {
setState(() {
_selectedPeriod = _initialPeriod();
_selectedEventTypeIds.clear();
_selectedStatuses.clear();
_selectedStatuses.addAll({
EventStatus.confirmed,
EventStatus.waitingForApproval,
});
_amountDisplayMode = _AmountDisplayMode.ht;
});
_rebuildSummary();
}
String _formatCurrency(double value) => _currencyFormat.format(value);
String _formatPercent(double value) => _percentFormat.format(value);
String get _amountUnitLabel =>
_amountDisplayMode == _AmountDisplayMode.ht ? 'HT' : 'TTC';
double _toDisplayAmount(double htAmount) {
if (_amountDisplayMode == _AmountDisplayMode.ttc) {
return htAmount * 1.2;
}
return htAmount;
}
String _formatAmount(double htAmount) =>
_formatCurrency(_toDisplayAmount(htAmount));
String _presetLabel(_DatePreset preset) {
switch (preset) {
case _DatePreset.currentMonth:
return 'Ce mois-ci';
case _DatePreset.previousMonth:
return 'Mois dernier';
case _DatePreset.currentYear:
return 'Cette année';
case _DatePreset.previousYear:
return 'Année dernière';
}
}
DateTimeRange _rangeForMonth(int year, int month) {
return DateTimeRange(
start: DateTime(year, month, 1),
end: DateTime(year, month + 1, 0, 23, 59, 59),
);
}
DateTimeRange _rangeForYear(int year) {
return DateTimeRange(
start: DateTime(year, 1, 1),
end: DateTime(year, 12, 31, 23, 59, 59),
);
}
DateTimeRange _rangeForPreset(_DatePreset preset) {
final now = DateTime.now();
switch (preset) {
case _DatePreset.currentMonth:
return _rangeForMonth(now.year, now.month);
case _DatePreset.previousMonth:
return _rangeForMonth(now.year, now.month - 1);
case _DatePreset.currentYear:
return _rangeForYear(now.year);
case _DatePreset.previousYear:
return _rangeForYear(now.year - 1);
}
}
bool _isPresetSelected(_DatePreset preset) {
final presetRange = _rangeForPreset(preset);
return _selectedPeriod.start == presetRange.start &&
_selectedPeriod.end == presetRange.end;
}
void _applyDatePreset(_DatePreset preset) {
setState(() {
_selectedPeriod = _rangeForPreset(preset);
});
_rebuildSummary();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _loadStatistics,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
);
}
return RefreshIndicator(
onRefresh: _loadStatistics,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildFiltersCard(),
const SizedBox(height: 16),
_buildSummaryCards(),
const SizedBox(height: 16),
_buildByTypeSection(),
const SizedBox(height: 16),
_buildTopOptionsSection(),
],
),
);
}
Widget _buildFiltersCard() {
final dateFormat = DateFormat('dd/MM/yyyy');
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.filter_alt, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Filtres',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
ToggleButtons(
isSelected: [
_amountDisplayMode == _AmountDisplayMode.ht,
_amountDisplayMode == _AmountDisplayMode.ttc,
],
onPressed: (index) {
setState(() {
_amountDisplayMode = index == 0
? _AmountDisplayMode.ht
: _AmountDisplayMode.ttc;
});
},
children: const [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('HT'),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('TTC'),
),
],
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: _resetFilters,
icon: const Icon(Icons.restart_alt),
label: const Text('Réinitialiser'),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _selectDateRange,
icon: const Icon(Icons.date_range),
label: Text(
'${dateFormat.format(_selectedPeriod.start)} - ${dateFormat.format(_selectedPeriod.end)}',
),
),
..._DatePreset.values.map(
(preset) => ChoiceChip(
label: Text(_presetLabel(preset)),
selected: _isPresetSelected(preset),
onSelected: (_) => _applyDatePreset(preset),
),
),
],
),
const SizedBox(height: 12),
const Text(
'Statuts d\'événements',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilterChip(
label: const Text('Validés'),
selected: _selectedStatuses.contains(EventStatus.confirmed),
onSelected: (value) {
setState(() {
if (value) {
_selectedStatuses.add(EventStatus.confirmed);
} else {
_selectedStatuses.remove(EventStatus.confirmed);
}
});
_rebuildSummary();
},
),
FilterChip(
label: const Text('En attente'),
selected: _selectedStatuses.contains(EventStatus.waitingForApproval),
onSelected: (value) {
setState(() {
if (value) {
_selectedStatuses.add(EventStatus.waitingForApproval);
} else {
_selectedStatuses.remove(EventStatus.waitingForApproval);
}
});
_rebuildSummary();
},
),
FilterChip(
label: const Text('Annulés'),
selected: _selectedStatuses.contains(EventStatus.canceled),
onSelected: (value) {
setState(() {
if (value) {
_selectedStatuses.add(EventStatus.canceled);
} else {
_selectedStatuses.remove(EventStatus.canceled);
}
});
_rebuildSummary();
},
),
],
),
if (_eventTypeNames.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Types d\'événements',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _eventTypeNames.entries.map((entry) {
final selected = _selectedEventTypeIds.contains(entry.key);
return FilterChip(
label: Text(entry.value),
selected: selected,
onSelected: (value) {
setState(() {
if (value) {
_selectedEventTypeIds.add(entry.key);
} else {
_selectedEventTypeIds.remove(entry.key);
}
});
_rebuildSummary();
},
);
}).toList(),
),
],
],
),
),
);
}
Widget _buildSummaryCards() {
final metrics = <_MetricConfig>[
_MetricConfig(
title: 'Total événements',
value: _summary.totalEvents.toString(),
subtitle: 'Sur la période sélectionnée',
icon: Icons.event,
),
_MetricConfig(
title: 'Montant total',
value: _formatAmount(_summary.totalAmount),
subtitle: 'Base + options ($_amountUnitLabel)',
icon: Icons.account_balance_wallet,
),
_MetricConfig(
title: 'Montant validé',
value: _formatAmount(_summary.validatedAmount),
subtitle: '${_summary.validatedEvents} événement(s) ($_amountUnitLabel)',
icon: Icons.verified,
),
_MetricConfig(
title: 'Montant non validé',
value: _formatAmount(_summary.pendingAmount),
subtitle: '${_summary.pendingEvents} événement(s) ($_amountUnitLabel)',
icon: Icons.hourglass_top,
),
_MetricConfig(
title: 'Montant annulé',
value: _formatAmount(_summary.canceledAmount),
subtitle: '${_summary.canceledEvents} événement(s) ($_amountUnitLabel)',
icon: Icons.cancel,
),
_MetricConfig(
title: 'Panier moyen',
value: _formatAmount(_summary.averageAmount),
subtitle: 'Par événement ($_amountUnitLabel)',
icon: Icons.trending_up,
),
_MetricConfig(
title: 'Panier médian',
value: _formatAmount(_summary.medianAmount),
subtitle: 'Par événement ($_amountUnitLabel)',
icon: Icons.timeline,
),
_MetricConfig(
title: 'Pourcentage de validation',
value: _formatPercent(_summary.validationRate),
subtitle:
'${_summary.validatedEvents} validés sur ${_summary.totalEvents}',
icon: Icons.pie_chart,
),
_MetricConfig(
title: 'Base vs options',
value:
'${_formatPercent(_summary.baseContributionRate)} / ${_formatPercent(_summary.optionsContributionRate)}',
subtitle:
'Base: ${_formatAmount(_summary.baseAmount)} - Options: ${_formatAmount(_summary.optionsAmount)}',
icon: Icons.stacked_bar_chart,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'KPI période',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: metrics
.map(
(metric) => _buildMetricCard(
title: metric.title,
value: metric.value,
subtitle: metric.subtitle,
icon: metric.icon,
),
)
.toList(),
),
],
);
}
Widget _buildMetricCard({
required String title,
required String value,
required String subtitle,
required IconData icon,
}) {
return SizedBox(
width: 280,
child: Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(color: Colors.grey.shade700),
),
],
),
),
),
);
}
Widget _buildByTypeSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par type d\'événement',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (_summary.byEventType.isEmpty)
const Text(
'Aucune donnée pour la période et les filtres sélectionnés.')
else
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
const DataColumn(label: Text('Type')),
const DataColumn(label: Text('Nb')),
const DataColumn(label: Text('Validé')),
const DataColumn(label: Text('Non validé')),
const DataColumn(label: Text('Annulé')),
DataColumn(label: Text('Total $_amountUnitLabel')),
],
rows: _summary.byEventType
.map(
(row) => DataRow(
cells: [
DataCell(Text(row.eventTypeName)),
DataCell(Text(row.totalEvents.toString())),
DataCell(Text(_formatAmount(row.validatedAmount))),
DataCell(Text(_formatAmount(row.pendingAmount))),
DataCell(Text(_formatAmount(row.canceledAmount))),
DataCell(Text(_formatAmount(row.totalAmount))),
],
),
)
.toList(),
),
),
],
),
),
);
}
Widget _buildTopOptionsSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Top options',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (_summary.topOptions.isEmpty)
const Text('Aucune option valorisée sur la période sélectionnée.')
else
..._summary.topOptions.map(
(option) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.add_chart, color: AppColors.rouge),
title: Text(option.optionLabel),
subtitle: Text(
'Validées ${option.validatedUsageCount} fois',
),
trailing: Text(
_formatAmount(option.totalAmount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
}
class _MetricConfig {
final String title;
final String value;
final String subtitle;
final IconData icon;
const _MetricConfig({
required this.title,
required this.value,
required this.subtitle,
required this.icon,
});
}
@@ -241,7 +241,7 @@ class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
], ],
), ),
); );
}).toList(), }),
// Boutons d'action par équipement // Boutons d'action par équipement
if (!isRemoved) if (!isRemoved)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
bool shouldAutoLoadNextPage({
required bool hasMoreData,
required bool isLoadingMore,
required bool hasClients,
required double maxScrollExtent,
}) {
if (!hasMoreData || isLoadingMore) {
return false;
}
// If the list cannot scroll yet, preload the next page to avoid a truncated view.
return !hasClients || maxScrollExtent <= 0;
}
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire /// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
class OptimizedEquipmentCard extends StatefulWidget { class OptimizedEquipmentCard extends StatefulWidget {
@@ -0,0 +1,755 @@
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:convert';
/// Résultat retourné par le dialog après confirmation de la proposition IA.
class AiProposalResult {
final List<EventEquipment> equipment;
final List<String> containerIds;
const AiProposalResult({
required this.equipment,
required this.containerIds,
});
}
class AiEquipmentAssistantDialog extends StatefulWidget {
final DateTime startDate;
final DateTime endDate;
final String? eventTypeId;
final String? excludeEventId;
final List<EventEquipment> currentAssignedEquipment;
const AiEquipmentAssistantDialog({
super.key,
required this.startDate,
required this.endDate,
required this.currentAssignedEquipment,
this.eventTypeId,
this.excludeEventId,
});
@override
State<AiEquipmentAssistantDialog> createState() =>
_AiEquipmentAssistantDialogState();
}
class _AiEquipmentAssistantDialogState
extends State<AiEquipmentAssistantDialog> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final ScrollController _proposalScrollController = ScrollController();
final List<_AssistantChatMessage> _messages = [];
late final AiEquipmentAssistantService _assistantService;
bool _isLoading = false;
String? _errorMessage;
AiEquipmentProposal? _latestProposal;
late List<EventEquipment> _workingEquipment;
AiEquipmentDocument? _selectedDocument;
List<String> _sessionLogs = [];
Set<String> _selectedContainerIds = {};
@override
void initState() {
super.initState();
_assistantService = AiEquipmentAssistantService();
_workingEquipment = List<EventEquipment>.from(widget.currentAssignedEquipment);
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_proposalScrollController.dispose();
super.dispose();
}
bool get _isChatEmpty => _messages.isEmpty;
String get _actionButtonLabel {
return _isChatEmpty ? 'Generer la liste automatiquement' : 'Envoyer';
}
Future<void> _sendMessage() async {
if (_isLoading) {
return;
}
final rawInput = _messageController.text.trim();
final isAutoMode = _isChatEmpty;
final userMessage = isAutoMode
? (rawInput.isNotEmpty
? rawInput
: 'Genere automatiquement une proposition de materiel pour cet evenement.')
: rawInput;
if (userMessage.isEmpty) {
return;
}
_messageController.clear();
setState(() {
_errorMessage = null;
_messages.add(_AssistantChatMessage.user(userMessage));
if (_selectedDocument != null) {
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
}
_isLoading = true;
});
_scrollToBottom();
try {
final documentToSend = _selectedDocument;
_selectedDocument = null; // Clear after sending
final response = await _assistantService
.generateProposal(
startDate: widget.startDate,
endDate: widget.endDate,
eventTypeId: widget.eventTypeId,
excludeEventId: widget.excludeEventId,
currentAssignedEquipment: widget.currentAssignedEquipment,
workingProposalEquipment: _workingEquipment,
userMessage: userMessage,
document: documentToSend,
history: _messages
.map((message) => AiAssistantChatTurn(
isUser: message.isUser, text: message.text))
.toList(),
);
if (!mounted) {
return;
}
setState(() {
_messages
.add(_AssistantChatMessage.assistant(response.assistantMessage));
_latestProposal = response.proposal;
if (response.proposal != null) {
_workingEquipment = List<EventEquipment>.from(
response.proposal!.asEventEquipment,
);
// Préselectionner les containers non partiels
_selectedContainerIds = {
for (final c in response.proposal!.containers)
if (!c.partial) c.containerId
};
}
_sessionLogs.addAll(response.debugLogs);
_isLoading = false;
});
_scrollToBottom();
} on FormatException catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Reponse IA invalide: ${error.message}';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Erreur IA: $error';
});
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) {
return;
}
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
});
}
Future<void> _pickDocument() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.bytes != null) {
final base64String = base64Encode(file.bytes!);
String mimeType = 'application/octet-stream';
if (file.extension == 'pdf') mimeType = 'application/pdf';
else if (file.extension == 'txt') mimeType = 'text/plain';
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
else if (file.extension == 'png') mimeType = 'image/png';
setState(() {
_selectedDocument = AiEquipmentDocument(
base64Data: base64String,
mimeType: mimeType,
fileName: file.name,
);
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Erreur lors de la selection du document : $e';
});
}
}
}
void _showLogsDialog() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Logs de l\'IA'),
content: SizedBox(
width: 800,
height: 600,
child: ListView.builder(
itemCount: _sessionLogs.length,
itemBuilder: (context, index) {
final log = _sessionLogs[index];
final isError = log.startsWith('[ERROR]');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
log,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: isError ? Colors.red : Colors.black87,
),
),
);
},
),
),
actions: [
TextButton(
onPressed: () {
final fullLogs = _sessionLogs.join('\n');
Clipboard.setData(ClipboardData(text: fullLogs));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
);
},
child: const Text('Copier tout'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: SizedBox(
width: 760,
height: 640,
child: Column(
children: [
AppBar(
automaticallyImplyLeading: false,
title: const Text('(BETA) Assistant IA Logisticien'),
actions: [
if (_sessionLogs.isNotEmpty)
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Voir les logs',
onPressed: _showLogsDialog,
),
IconButton(
icon: const Icon(Icons.close),
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
),
],
),
Expanded(
child: Column(
children: [
Expanded(
child: Container(
color: Colors.grey.shade50,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return _buildMessageBubble(message);
},
),
),
),
if (_isLoading)
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Expanded(
child: const Text(
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
textAlign: TextAlign.left,
),
),
],
),
),
if (_errorMessage != null)
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade800),
),
),
if (_latestProposal != null)
_buildProposalSummary(_latestProposal!),
if (_selectedDocument != null)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: Row(
children: [
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedDocument!.fileName ?? 'Document joint',
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () {
setState(() {
_selectedDocument = null;
});
},
tooltip: 'Retirer le document',
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.attach_file),
onPressed: _isLoading ? null : _pickDocument,
tooltip: 'Joindre un devis ou document',
),
Expanded(
child: TextField(
controller: _messageController,
enabled: !_isLoading,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText:
'Precisez votre besoin (style, jauge, contraintes...)',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _isLoading ? null : _sendMessage,
child: Text(_actionButtonLabel),
),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildMessageBubble(_AssistantChatMessage message) {
final bubbleColor = message.isUser ? Colors.blue.shade600 : Colors.white;
final textColor = message.isUser ? Colors.white : Colors.black87;
return Align(
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
constraints: const BoxConstraints(maxWidth: 560),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
bottomRight: Radius.circular(message.isUser ? 4 : 16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border:
message.isUser ? null : Border.all(color: Colors.grey.shade200),
),
child: message.isUser
? Text(message.text, style: TextStyle(color: textColor))
: _buildAssistantMessageContent(message.text),
),
);
}
Widget _buildAssistantMessageContent(String text) {
// Si le message semble structuré par l'IA avec nos nouvelles règles
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
final sections = text.split('\n\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.map((section) {
final isAdded = section.contains('Matériel ajouté :');
final isMissing = section.contains('Matériel non trouvé');
if (isAdded) {
return _buildStatusSection(
title: section.split('\n').first,
content: section.split('\n').skip(1).join('\n'),
icon: Icons.check_circle_outline,
color: Colors.green.shade700,
bgColor: Colors.green.shade50,
);
} else if (isMissing) {
return _buildStatusSection(
title: section.split('\n').first,
content: section.split('\n').skip(1).join('\n'),
icon: Icons.warning_amber_rounded,
color: Colors.orange.shade800,
bgColor: Colors.orange.shade50,
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(section),
);
}).toList(),
);
}
return Text(text);
}
Widget _buildStatusSection({
required String title,
required String content,
required IconData icon,
required Color color,
required Color bgColor,
}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
title.replaceAll(':', '').trim(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
),
],
),
if (content.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
content.trim(),
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
),
],
],
),
);
}
void _confirmProposal({bool excludeAlternatives = false}) {
if (_latestProposal == null) return;
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
// Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
final List<String> containerIds = _selectedContainerIds.isNotEmpty
? _selectedContainerIds.toList()
: List.from(_latestProposal!.containerIds);
if (excludeAlternatives) {
// On utilise la liste des items d'origine pour savoir lesquels exclure
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
final idsToExclude = _latestProposal!.items
.where((item) {
final rationale = item.rationale.toLowerCase();
return rationale.contains('alternative') ||
rationale.contains('remplacement') ||
rationale.contains('indisponible');
})
.map((item) => item.equipmentId)
.toSet();
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
}
Navigator.of(context).pop(
AiProposalResult(
equipment: equipment,
containerIds: containerIds,
),
);
}
Widget _buildProposalSummary(AiEquipmentProposal proposal) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Récapitulatif de la proposition IA',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.indigo,
),
),
),
],
),
const SizedBox(height: 12),
Flexible(
child: Scrollbar(
controller: _proposalScrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _proposalScrollController,
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
proposal.summary,
style: const TextStyle(fontStyle: FontStyle.italic),
),
if (proposal.items.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Matériel individuel :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.items.map((item) {
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
size: 14,
color: isAlt ? Colors.orange : Colors.indigo,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${item.equipmentId} x${item.quantity}',
style: const TextStyle(fontWeight: FontWeight.w500)
),
),
],
),
);
}),
],
if (proposal.containers.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Fly-cases & Boîtes :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.containers.map((c) {
final isPartial = c.partial;
final isSelected = _selectedContainerIds.contains(c.containerId);
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 14,
color: c.available == false ? Colors.red : Colors.indigo,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
if (c.available == false)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
),
],
),
if (isPartial) ...[
const SizedBox(height: 4),
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
],
),
),
const SizedBox(width: 8),
if (isPartial)
Checkbox(
value: isSelected,
onChanged: (v) {
setState(() {
if (v == true) _selectedContainerIds.add(c.containerId);
else _selectedContainerIds.remove(c.containerId);
});
},
),
],
),
);
}),
],
],
),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _isLoading ? null : () => _confirmProposal(),
icon: const Icon(Icons.check),
label: const Text('Tout ajouter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
),
OutlinedButton.icon(
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
icon: const Icon(Icons.filter_list_off),
label: const Text('Ajouter sans alternatives'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.indigo,
side: const BorderSide(color: Colors.indigo),
),
),
],
),
],
),
);
}
}
class _AssistantChatMessage {
final bool isUser;
final String text;
const _AssistantChatMessage._({required this.isUser, required this.text});
factory _AssistantChatMessage.user(String text) {
return _AssistantChatMessage._(isUser: true, text: text);
}
factory _AssistantChatMessage.assistant(String text) {
return _AssistantChatMessage._(isUser: false, text: text);
}
}
@@ -6,12 +6,9 @@ import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart'; import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart'; import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart';
import 'package:em2rp/services/event_availability_service.dart';
/// Section pour afficher et gérer le matériel assigné à un événement /// Section pour afficher et gérer le matériel assigné à un événement
class EventAssignedEquipmentSection extends StatefulWidget { class EventAssignedEquipmentSection extends StatefulWidget {
@@ -21,6 +18,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
final DateTime? endDate; final DateTime? endDate;
final Function(List<EventEquipment>, List<String>) onChanged; final Function(List<EventEquipment>, List<String>) onChanged;
final String? eventId; // Pour exclure l'événement actuel de la vérification final String? eventId; // Pour exclure l'événement actuel de la vérification
final String? eventTypeId;
const EventAssignedEquipmentSection({ const EventAssignedEquipmentSection({
super.key, super.key,
@@ -30,18 +28,20 @@ class EventAssignedEquipmentSection extends StatefulWidget {
required this.endDate, required this.endDate,
required this.onChanged, required this.onChanged,
this.eventId, this.eventId,
this.eventTypeId,
}); });
@override @override
State<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState(); State<EventAssignedEquipmentSection> createState() =>
_EventAssignedEquipmentSectionState();
} }
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> { class _EventAssignedEquipmentSectionState
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null; extends State<EventAssignedEquipmentSection> {
final EventAvailabilityService _availabilityService = EventAvailabilityService(); bool get _canAddMaterial =>
final DataService _dataService = DataService(FirebaseFunctionsApiService()); widget.startDate != null && widget.endDate != null;
Map<String, EquipmentModel> _equipmentCache = {}; final Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {}; final Map<String, ContainerModel> _containerCache = {};
bool _isLoading = true; bool _isLoading = true;
@override @override
@@ -67,100 +67,63 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final equipmentProvider = context.read<EquipmentProvider>(); final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>(); final containerProvider = context.read<ContainerProvider>();
// 🔧 FIX: Si on a un eventId, utiliser getEventWithDetails pour charger les données complètes DebugLog.info(
if (widget.eventId != null && widget.eventId!.isNotEmpty) { '[EventAssignedEquipmentSection] Loading caches from assigned lists');
DebugLog.info('[EventAssignedEquipmentSection] Loading event with details: ${widget.eventId}');
final result = await _dataService.getEventWithDetails(widget.eventId!); // Toujours partir des données locales du formulaire pour éviter les décalages visuels.
final equipmentsMap = result['equipments'] as Map<String, dynamic>; final equipmentIds =
final containersMap = result['containers'] as Map<String, dynamic>; widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
final containers =
await containerProvider.getContainersByIds(widget.assignedContainers);
DebugLog.info('[EventAssignedEquipmentSection] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details'); final childEquipmentIds = <String>[];
for (final container in containers) {
childEquipmentIds.addAll(container.equipmentIds);
}
// Construire les caches à partir des données reçues final allEquipmentIds =
_equipmentCache.clear(); <String>{...equipmentIds, ...childEquipmentIds}.toList();
_containerCache.clear(); final equipment =
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
// Remplir le cache d'équipements _equipmentCache.clear();
equipmentsMap.forEach((id, data) { _containerCache.clear();
try {
_equipmentCache[id] = EquipmentModel.fromMap(data as Map<String, dynamic>, id);
} catch (e) {
DebugLog.error('[EventAssignedEquipmentSection] Error parsing equipment $id', e);
}
});
// Remplir le cache de containers for (final eq in widget.assignedEquipment) {
containersMap.forEach((id, data) { final equipmentItem = equipment.firstWhere(
try { (e) => e.id == eq.equipmentId,
_containerCache[id] = ContainerModel.fromMap(data as Map<String, dynamic>, id); orElse: () => EquipmentModel(
} catch (e) { id: eq.equipmentId,
DebugLog.error('[EventAssignedEquipmentSection] Error parsing container $id', e); name: 'Équipement inconnu',
} category: EquipmentCategory.other,
}); status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
_equipmentCache[eq.equipmentId] = equipmentItem;
}
DebugLog.info('[EventAssignedEquipmentSection] Caches populated: ${_equipmentCache.length} equipments, ${_containerCache.length} containers'); for (final containerId in widget.assignedContainers) {
final container = containers.firstWhere(
} else { (c) => c.id == containerId,
// Mode création d'événement : charger via les providers orElse: () => ContainerModel(
DebugLog.info('[EventAssignedEquipmentSection] Loading via providers (creation mode)'); id: containerId,
name: 'Conteneur inconnu',
// Extraire les IDs des équipements assignés type: ContainerType.flightCase,
final equipmentIds = widget.assignedEquipment status: EquipmentStatus.available,
.map((eq) => eq.equipmentId) equipmentIds: [],
.toList(); updatedAt: DateTime.now(),
createdAt: DateTime.now(),
// Charger les conteneurs ),
final containers = await containerProvider.getContainersByIds(widget.assignedContainers); );
_containerCache[containerId] = container;
// Extraire les IDs des équipements enfants des containers
final childEquipmentIds = <String>[];
for (var container in containers) {
childEquipmentIds.addAll(container.equipmentIds);
}
// Combiner les IDs des équipements assignés + enfants des containers
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
// Charger TOUS les équipements nécessaires
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
// Créer le cache des équipements
for (var eq in widget.assignedEquipment) {
final equipmentItem = equipment.firstWhere(
(e) => e.id == eq.equipmentId,
orElse: () => EquipmentModel(
id: eq.equipmentId,
name: 'Équipement inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
_equipmentCache[eq.equipmentId] = equipmentItem;
}
// Créer le cache des conteneurs
for (var containerId in widget.assignedContainers) {
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Conteneur inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
updatedAt: DateTime.now(),
createdAt: DateTime.now(),
),
);
_containerCache[containerId] = container;
}
} }
} catch (e) { } catch (e) {
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e); DebugLog.error(
'[EventAssignedEquipmentSection] Error loading equipment and containers',
e);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -188,7 +151,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
} }
Future<void> _processSelection(Map<String, SelectedItem> selection) async { Future<void> _processSelection(Map<String, SelectedItem> selection) async {
DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items'); DebugLog.info(
'[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
// Séparer équipements et conteneurs // Séparer équipements et conteneurs
final newEquipment = <EventEquipment>[]; final newEquipment = <EventEquipment>[];
@@ -205,23 +169,27 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
} }
} }
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)'); DebugLog.info(
'[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants // 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
if (newContainers.isNotEmpty) { if (newContainers.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>(); final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(newContainers); final containers =
await containerProvider.getContainersByIds(newContainers);
for (var container in containers) { for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) { for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste // Vérifier si l'équipement enfant n'est pas déjà dans la liste
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId); final existsInNew =
newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!existsInNew) { if (!existsInNew) {
newEquipment.add(EventEquipment( newEquipment.add(EventEquipment(
equipmentId: childEquipmentId, equipmentId: childEquipmentId,
quantity: 1, quantity: 1,
)); ));
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}'); DebugLog.info(
'[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
} }
} }
} }
@@ -236,7 +204,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// Pour chaque nouvel équipement // Pour chaque nouvel équipement
for (var eq in newEquipment) { for (var eq in newEquipment) {
final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId); final existingIndex =
updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
if (existingIndex != -1) { if (existingIndex != -1) {
// L'équipement existe déjà : mettre à jour la quantité // L'équipement existe déjà : mettre à jour la quantité
@@ -263,9 +232,74 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// Notifier le changement // Notifier le changement
widget.onChanged(updatedEquipment, updatedContainers); widget.onChanged(updatedEquipment, updatedContainers);
}
// Recharger le cache Future<void> _openAiAssistantDialog() async {
await _loadEquipmentAndContainers(); if (widget.startDate == null || widget.endDate == null) {
return;
}
final result = await showDialog<AiProposalResult>(
context: context,
builder: (context) => AiEquipmentAssistantDialog(
startDate: widget.startDate!,
endDate: widget.endDate!,
eventTypeId: widget.eventTypeId,
excludeEventId: widget.eventId,
currentAssignedEquipment: widget.assignedEquipment,
),
);
if (result == null) {
return;
}
_applyAiProposal(result);
}
void _applyAiProposal(AiProposalResult result) async {
final existingById = {
for (final equipment in widget.assignedEquipment)
equipment.equipmentId: equipment,
};
final updatedEquipment = result.equipment.map((proposed) {
final existing = existingById[proposed.equipmentId];
if (existing == null) {
return proposed;
}
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
}).toList();
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
if (result.containerIds.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(result.containerIds);
for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!exists) {
updatedEquipment.add(EventEquipment(
equipmentId: childEquipmentId,
quantity: 1,
rationale: 'Inclus dans ${container.id}',
));
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
}
}
}
}
final updatedContainers = [...widget.assignedContainers];
for (final containerId in result.containerIds) {
if (!updatedContainers.contains(containerId)) {
updatedContainers.add(containerId);
}
}
widget.onChanged(updatedEquipment, updatedContainers);
} }
void _removeEquipment(String equipmentId) { void _removeEquipment(String equipmentId) {
@@ -284,9 +318,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final container = _containerCache[containerId]; final container = _containerCache[containerId];
// Retirer le conteneur de la liste // Retirer le conteneur de la liste
final updatedContainers = widget.assignedContainers final updatedContainers =
.where((id) => id != containerId) widget.assignedContainers.where((id) => id != containerId).toList();
.toList();
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container // 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
final updatedEquipment = <EventEquipment>[]; final updatedEquipment = <EventEquipment>[];
@@ -305,8 +338,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// 1. Ne sont PAS dans le container supprimé OU // 1. Ne sont PAS dans le container supprimé OU
// 2. Sont dans le container supprimé MAIS aussi dans un autre container // 2. Sont dans le container supprimé MAIS aussi dans un autre container
for (var eq in widget.assignedEquipment) { for (var eq in widget.assignedEquipment) {
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId); final isInRemovedContainer =
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId); container.equipmentIds.contains(eq.equipmentId);
final isInOtherContainer =
equipmentIdsInOtherContainers.contains(eq.equipmentId);
if (!isInRemovedContainer || isInOtherContainer) { if (!isInRemovedContainer || isInOtherContainer) {
updatedEquipment.add(eq); updatedEquipment.add(eq);
@@ -324,7 +359,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
_containerCache.remove(containerId); _containerCache.remove(containerId);
// Nettoyer le cache uniquement pour les équipements effectivement supprimés // Nettoyer le cache uniquement pour les équipements effectivement supprimés
if (container != null) { if (container != null) {
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet(); final remainingEquipmentIds =
updatedEquipment.map((eq) => eq.equipmentId).toSet();
for (var equipmentId in container.equipmentIds) { for (var equipmentId in container.equipmentIds) {
if (!remainingEquipmentIds.contains(equipmentId)) { if (!remainingEquipmentIds.contains(equipmentId)) {
_equipmentCache.remove(equipmentId); _equipmentCache.remove(equipmentId);
@@ -354,7 +390,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length; final totalItems =
widget.assignedEquipment.length + widget.assignedContainers.length;
return Card( return Card(
elevation: 2, elevation: 2,
@@ -403,15 +440,25 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
], ],
), ),
), ),
ActionChip(
onPressed: _canAddMaterial ? _openAiAssistantDialog : null,
avatar: const Icon(Icons.auto_fix_high, size: 18),
label: const Text('Assistant IA'),
),
const SizedBox(width: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _canAddMaterial ? _openSelectionDialog : null, onPressed: _canAddMaterial ? _openSelectionDialog : null,
icon: Icon(Icons.add, color: _canAddMaterial ? Colors.white : Colors.grey), icon: Icon(Icons.add,
color: _canAddMaterial ? Colors.white : Colors.grey),
label: Text( label: Text(
'Ajouter', 'Ajouter',
style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey), style: TextStyle(
color: _canAddMaterial ? Colors.white : Colors.grey),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300, backgroundColor: _canAddMaterial
? AppColors.rouge
: Colors.grey.shade300,
), ),
), ),
], ],
@@ -491,7 +538,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
...widget.assignedContainers.map((containerId) { ...widget.assignedContainers.map((containerId) {
final container = _containerCache[containerId]; final container = _containerCache[containerId];
return _buildContainerItem(container); return _buildContainerItem(container);
}).toList(), }),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -508,7 +555,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
..._getStandaloneEquipment().map((eq) { ..._getStandaloneEquipment().map((eq) {
final equipment = _equipmentCache[eq.equipmentId]; final equipment = _equipmentCache[eq.equipmentId];
return _buildEquipmentItem(equipment, eq); return _buildEquipmentItem(equipment, eq);
}).toList(), }),
], ],
], ],
), ),
@@ -520,7 +567,14 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
Widget _buildContainerItem(ContainerModel? container) { Widget _buildContainerItem(ContainerModel? container) {
if (container == null) { if (container == null) {
return const SizedBox.shrink(); return const Card(
margin: EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(Icons.inventory_2, color: Colors.grey),
title: Text('Conteneur inconnu'),
subtitle: Text('Données du conteneur indisponibles'),
),
);
} }
return Card( return Card(
@@ -558,7 +612,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -583,7 +638,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
color: Colors.grey.shade600, color: Colors.grey.shade600,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
eq.category.getIcon(size: 16, color: eq.category.color), eq.category
.getIcon(size: 16, color: eq.category.color),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -597,7 +653,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
], ],
), ),
); );
}).toList(), }),
], ],
), ),
); );
@@ -608,23 +664,39 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
); );
} }
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) { Widget _buildEquipmentItem(
EquipmentModel? equipment, EventEquipment eventEq) {
if (equipment == null) { if (equipment == null) {
return const SizedBox.shrink(); return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Color(0xFFE0E0E0),
child: Icon(Icons.inventory_2, color: Colors.grey),
),
title: Text(
eventEq.equipmentId,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text('Équipement indisponible dans le cache local'),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeEquipment(eventEq.equipmentId),
),
),
);
} }
final isConsumable = equipment.category == EquipmentCategory.consumable || final isConsumable = equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable; equipment.category == EquipmentCategory.cable;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2), backgroundColor: equipment.category.color.withValues(alpha: 0.2),
child: equipment.category.getIconForAvatar( child: equipment.category
size: 24, .getIconForAvatar(size: 24, color: equipment.category.color),
color: equipment.category.color
),
), ),
title: Text( title: Text(
equipment.id, equipment.id,
@@ -663,4 +735,3 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
); );
} }
} }
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:em2rp/models/event_type_model.dart'; import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart'; import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
@@ -12,8 +12,7 @@ class OptionSelectorWidget extends StatefulWidget {
final bool isMobile; final bool isMobile;
final String? eventType; final String? eventType;
const OptionSelectorWidget({ const OptionSelectorWidget({super.key,
Key? key,
this.eventType, this.eventType,
required this.selectedOptions, required this.selectedOptions,
required this.onChanged, required this.onChanged,
@@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:em2rp/views/widgets/image/profile_picture.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/views/event_statistics_page.dart';
class MainDrawer extends StatelessWidget { class MainDrawer extends StatelessWidget {
final String currentPage; final String currentPage;
@@ -132,6 +133,24 @@ class MainDrawer extends StatelessWidget {
}, },
), ),
), ),
PermissionGate(
requiredPermissions: const ['generate_reports'],
child: ListTile(
leading: const Icon(Icons.bar_chart),
title: const Text('Statistiques evenements'),
selected: currentPage == '/event_statistics',
selectedColor: AppColors.rouge,
onTap: () {
Navigator.pop(context);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const EventStatisticsPage(),
),
);
},
),
),
ExpansionTileTheme( ExpansionTileTheme(
data: const ExpansionTileThemeData( data: const ExpansionTileThemeData(
iconColor: AppColors.noir, iconColor: AppColors.noir,
@@ -210,7 +210,7 @@ class _NotificationPreferencesWidgetState extends State<NotificationPreferencesW
), ),
value: value, value: value,
onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde
activeColor: Theme.of(context).primaryColor, activeThumbColor: Theme.of(context).primaryColor,
inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF
inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
+5 -4
View File
@@ -17,13 +17,14 @@ dependencies:
cloud_functions: ^6.0.4 cloud_functions: ^6.0.4
google_sign_in: ^7.2.0 google_sign_in: ^7.2.0
firebase_storage: ^13.0.3 firebase_storage: ^13.0.3
shared_preferences: ^2.0.15
# State Management # State Management
provider: ^6.1.2 provider: ^6.1.2
# UI Core # UI Core
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
google_fonts: ^6.1.0 google_fonts: ^8.0.2
flutter_svg: ^2.2.1 flutter_svg: ^2.2.1
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
flutter_slidable: ^4.0.0 flutter_slidable: ^4.0.0
@@ -37,7 +38,7 @@ dependencies:
# Storage & Files # Storage & Files
path_provider: ^2.1.2 path_provider: ^2.1.2
flutter_secure_storage: ^9.0.0 flutter_secure_storage: ^10.0.0
file_picker: ^10.1.9 file_picker: ^10.1.9
image_picker: ^1.1.2 image_picker: ^1.1.2
flutter_dropzone: ^4.2.1 flutter_dropzone: ^4.2.1
@@ -47,7 +48,7 @@ dependencies:
pdf: ^3.10.7 pdf: ^3.10.7
printing: ^5.11.1 printing: ^5.11.1
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
mobile_scanner: ^5.2.3 mobile_scanner: ^7.2.0
# Network & API # Network & API
http: ^1.1.2 http: ^1.1.2
@@ -59,7 +60,7 @@ dependencies:
share_plus: ^12.0.1 share_plus: ^12.0.1
# Notifications # Notifications
flutter_local_notifications: ^19.2.1 flutter_local_notifications: ^20.1.0
# Export/Import # Export/Import
@@ -0,0 +1,61 @@
import 'package:em2rp/views/widgets/event/equipment_selection_pagination.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('shouldAutoLoadNextPage', () {
test('returns false when there is no more data', () {
final result = shouldAutoLoadNextPage(
hasMoreData: false,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 100,
);
expect(result, isFalse);
});
test('returns false while a page is already loading', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: true,
hasClients: true,
maxScrollExtent: 0,
);
expect(result, isFalse);
});
test('returns true when list has no scroll client yet', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: false,
maxScrollExtent: 0,
);
expect(result, isTrue);
});
test('returns true when list is not scrollable yet', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 0,
);
expect(result, isTrue);
});
test('returns false when list is scrollable', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 250,
);
expect(result, isFalse);
});
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"version": "1.1.14", "version": "1.2.1",
"updateUrl": "https://app.em2events.fr", "updateUrl": "https://app.em2events.fr",
"forceUpdate": true, "forceUpdate": true,
"releaseNotes": "Ajout de la gestion des maintenance et synthèse vocale", "releaseNotes": "Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.",
"timestamp": "2026-03-03T10:13:12.014Z" "timestamp": "2026-05-25T21:50:50.578Z"
} }