Compare commits

...

2 Commits

Author SHA1 Message Date
ElPoyo
8cd4854924 refactor: Amélioration des performances et migration des Cloud Functions
Cette mise à jour majeure vise à améliorer significativement les performances de l'application, en particulier au démarrage, et à standardiser l'infrastructure backend. Les principaux changements incluent la migration de toutes les Cloud Functions vers une région européenne (`europe-west9`), l'optimisation du chargement des données, et l'introduction d'un moniteur de performance pour le débogage.

**Changements Backend (Cloud Functions) :**

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

**Améliorations des Performances Frontend :**

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

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

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

**Mise à jour de la version :**

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

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

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

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

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

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

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

**Refactorisation et corrections :**

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

**Déploiement et version :**

-   **Scripts de déploiement :** Ajout d'un script PowerShell (`deploy_hosting.ps1`) et amélioration du script Node.js pour automatiser et fiabiliser les déploiements sur Firebase Hosting.
-   **Configuration CORS :** Les en-têtes CORS sont désormais configurés pour `version.json`, assurant le bon fonctionnement du mécanisme de mise à jour de l'application.
-   **Version de l'application :** Incrémentée à `1.0.6`.
2026-01-20 14:33:37 +01:00
42 changed files with 2241 additions and 882 deletions

View File

@@ -32,16 +32,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1768586208886,5a25871ae727f23c4b7258c34108085b8711aa94f6fcab512e0c3ca00a429a64
index.html,1768586225248,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1768586307073,4ea31c373e15f13c2916a12d9d799905af2a79ff7ed0bcceb4334707910c7721
flutter_bootstrap.js,1768586225225,e95b1b0bd493a475c8eed0e630e413d898f2ceff11cd9b24c6c564bbc2c5f5e9
assets/FontManifest.json,1768586302952,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1768586302952,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
assets/AssetManifest.bin.json,1768586302952,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
assets/AssetManifest.bin,1768586302952,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768586306083,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1768586303187,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1768586306096,33efc485968dd28630ace587c22d6df359c195821b1114aaa85383e4d5394eac
assets/NOTICES,1768586302954,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c
main.dart.js,1768586301774,9b399ba21ab3247d46cf7dbcd5873aa248636bcd7864a1a0cedf1aae08608f9a
version.json,1770478530807,2cbfdf7f34574c2f9d4f1af02acb86d8d230af93790c97a3c7e1674c4db42ef4
index.html,1770478536326,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1770478628965,cb72807cfcb05b0a2e7b3f4f0cf618a0284a3d2476c93672bd86ea99670b0f5d
assets/FontManifest.json,1770478624084,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1770478624084,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
flutter_bootstrap.js,1770478536318,bf4a3b4bf79eaed1ce24892f20cfb270bcc22fb392bc9f6a1d17aeed42ed4ed8
assets/AssetManifest.bin.json,1770478624084,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
assets/AssetManifest.bin,1770478624084,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1770478628013,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1770478624492,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1770478628013,50e06fd231edee237d875cddbae1e22b682d32bb1284e3c32ca409fa489f9c21
assets/NOTICES,1770478624086,d02d64a466e62fdaeee2534a3f65541362ccf29beb495e2af0fdce41f4ae28d9
main.dart.js,1770478620736,03d43aeaa96cfdbe5b7491f9610223ec95c29d47095570dd61cd6cddac863496

View File

@@ -1,337 +0,0 @@
# Système de Gestion des Mises à Jour - EM2RP
## 📋 Vue d'ensemble
Ce système permet de gérer automatiquement les mises à jour de l'application web Flutter, en notifiant les utilisateurs et en forçant le rechargement du cache si nécessaire.
---
## 🔧 Architecture
### Fichiers impliqués
#### Configuration
- **`lib/config/app_version.dart`** : Fichier source de vérité pour la version
- **`web/version.json`** : Fichier déployé avec l'app pour vérification côté serveur
#### Services
- **`lib/services/update_service.dart`** : Service de vérification des mises à jour
- **`lib/views/widgets/common/update_dialog.dart`** : Widget d'affichage du dialog de mise à jour
#### Scripts
- **`scripts/increment_version.js`** : Incrémente automatiquement la version
- **`scripts/update_version_json.js`** : Génère version.json depuis app_version.dart
- **`deploy.bat`** : Script de déploiement complet
#### Documentation
- **`CHANGELOG.md`** : Notes de version (utilisées dans le dialog)
---
## 🚀 Workflow de déploiement
### 1. Développement normal
Travaillez normalement sur votre code en mode développement.
### 2. Déploiement d'une nouvelle version
```bash
deploy.bat
```
Ce script exécute automatiquement :
1. ✅ Bascule en mode PRODUCTION
2.**Incrémente la version** (0.3.8 → 0.3.9)
3.**Incrémente le buildNumber** (1 → 2)
4.**Génère version.json** depuis app_version.dart
5. ✅ Build Flutter Web
6. ✅ Déploie sur Firebase Hosting
7. ✅ Retour en mode DÉVELOPPEMENT
### 3. Mise à jour côté utilisateur
Au prochain chargement de l'app (ou après 2 secondes) :
- L'app vérifie `https://em2rp.web.app/version.json`
- Compare avec la version locale dans `app_version.dart`
- Si `buildNumber serveur > buildNumber local` → Affiche le dialog
---
## 📝 Format de version
### app_version.dart
```dart
class AppVersion {
static const String version = '0.3.8'; // Version sémantique
static const int buildNumber = 1; // Numéro de build (incrémenté automatiquement)
static String get fullVersion => 'v$version';
static String get fullVersionWithBuild => 'v$version+$buildNumber';
}
```
### version.json (déployé)
```json
{
"version": "0.3.8",
"buildNumber": 1,
"updateUrl": "https://em2rp.web.app",
"forceUpdate": false,
"releaseNotes": "• Scanner QR Code\n• Génération QR conteneurs\n• Performance améliorée"
}
```
---
## 🔄 Comparaison des versions
Le système compare uniquement le **buildNumber** :
- `buildNumber serveur > buildNumber local` → Mise à jour disponible
- Ignore les versions identiques même si la version sémantique change
**Exemple** :
- Local : `0.3.8+1`
- Serveur : `0.3.9+2`
- Résultat : Mise à jour proposée (2 > 1) ✅
---
## 🎨 Expérience utilisateur
### Mise à jour normale (forceUpdate: false)
```
┌────────────────────────────────────┐
│ 🔄 Mise à jour disponible │
├────────────────────────────────────┤
│ Version actuelle : 0.3.8 (1) │
│ Nouvelle version : 0.3.9 (2) │
│ │
│ Nouveautés : │
│ • Scanner QR Code │
│ • Performance améliorée │
│ │
│ [Plus tard] [Mettre à jour] 🔄 │
└────────────────────────────────────┘
```
### Mise à jour forcée (forceUpdate: true)
```
┌────────────────────────────────────┐
│ ⚠️ Mise à jour requise │
├────────────────────────────────────┤
│ Version actuelle : 0.3.8 (1) │
│ Nouvelle version : 0.3.9 (2) │
│ │
│ ⚠️ Cette mise à jour est │
│ obligatoire pour continuer │
│ │
│ [Mettre à jour] 🔄 │
└────────────────────────────────────┘
```
---
## 🛠️ Utilisation avancée
### Forcer une mise à jour critique
Si vous déployez un correctif critique :
1. Modifiez `web/version.json` **après le déploiement** :
```json
{
"version": "0.3.9",
"buildNumber": 2,
"forceUpdate": true, // ← Changer à true
"releaseNotes": "🔴 Correctif de sécurité important"
}
```
2. Les utilisateurs ne pourront plus fermer le dialog jusqu'à la mise à jour
### Personnaliser les notes de version
Éditez `CHANGELOG.md` avant le déploiement :
```markdown
## [0.3.9] - 2026-01-16
### Ajouté
- Scanner QR Code pour équipements
- Génération QR pour conteneurs
### Amélioré
- Performance du dialog de sélection
- Gestion du cache
### Corrigé
- Bug de cache des équipements
```
Les 5 premières lignes de la section seront utilisées dans le dialog.
---
## 🧪 Tests
### Test 1 : Vérification de version locale
```dart
// Dans n'importe quel fichier
import 'package:em2rp/config/app_version.dart';
print('Version: ${AppVersion.version}');
print('Build: ${AppVersion.buildNumber}');
print('Full: ${AppVersion.fullVersionWithBuild}');
```
### Test 2 : Forcer l'affichage du dialog
Modifiez temporairement `web/version.json` :
```json
{
"buildNumber": 999 // Très grand nombre
}
```
Rechargez l'app → Le dialog s'affiche immédiatement
### Test 3 : Tester le rechargement
1. Cliquez sur "Mettre à jour"
2. Vérifiez que la page se recharge
3. Vérifiez que le cache est vidé (nouvelles ressources chargées)
---
## 📊 Logs de debug
En mode debug, des logs sont affichés dans la console :
```
[UpdateService] Current version: 0.3.8+1
[UpdateService] Server version: 0.3.9+2
```
Si pas de mise à jour disponible, rien ne s'affiche.
---
## 🔐 Sécurité
### Headers HTTP pour forcer le non-cache
Le fichier `web/index.html` contient :
```html
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
```
### Cache-busting sur version.json
Chaque requête ajoute un timestamp :
```dart
final timestamp = DateTime.now().millisecondsSinceEpoch;
Uri.parse('$versionUrl?t=$timestamp')
```
Garantit que la version la plus récente est toujours récupérée.
---
## 🚨 Résolution de problèmes
### Problème : Le dialog ne s'affiche pas
**Causes possibles :**
1. Le `buildNumber` serveur n'est pas supérieur au local
2. Erreur réseau (timeout 10s)
3. Le fichier `version.json` n'existe pas sur le serveur
**Solution :**
```bash
# Vérifier la version déployée
curl https://em2rp.web.app/version.json
# Forcer un nouveau déploiement
deploy.bat
```
### Problème : Le cache ne se vide pas
**Causes possibles :**
1. Service Worker actif (ancienne version)
2. Cache navigateur très persistant
**Solution :**
```javascript
// Dans les DevTools du navigateur
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(r => r.unregister());
});
// Puis CTRL+SHIFT+R (rechargement forcé)
```
### Problème : Le script increment_version.js échoue
**Solution :**
```bash
# Vérifier la syntaxe du fichier app_version.dart
# Doit contenir exactement :
static const String version = '0.3.8';
static const int buildNumber = 1;
```
---
## 📈 Évolution future
### Fonctionnalités possibles
- [ ] Afficher un changelog complet dans le dialog
- [ ] Permettre de sauter une version (skip this version)
- [ ] Notifications push pour les mises à jour critiques
- [ ] Analytics sur le taux d'adoption des mises à jour
- [ ] Support des mises à jour en arrière-plan
### Améliorations techniques
- [ ] Utiliser un CDN pour version.json
- [ ] Implémenter un rollback automatique si erreur
- [ ] Ajouter une vérification de santé post-déploiement
---
## 🎯 Commandes rapides
```bash
# Déployer une nouvelle version
deploy.bat
# Incrémenter manuellement la version
node scripts\increment_version.js
# Générer version.json manuellement
node scripts\update_version_json.js
# Vérifier la version actuelle
type lib\config\app_version.dart
# Vérifier la version déployée
curl https://em2rp.web.app/version.json
```
---
## ✅ Checklist de déploiement
Avant chaque déploiement :
- [ ] Tester l'application en local
- [ ] Mettre à jour `CHANGELOG.md` avec les nouveautés
- [ ] Vérifier que tous les tests passent
- [ ] Exécuter `deploy.bat`
- [ ] Vérifier le déploiement sur https://em2rp.web.app
- [ ] Tester la mise à jour sur un navigateur propre
- [ ] Informer l'équipe de la nouvelle version
---
## 📞 Support
En cas de problème avec le système de mise à jour, vérifier :
1. Les logs dans la console du navigateur
2. Le fichier `version.json` déployé
3. Le fichier `app_version.dart` local
4. La connexion réseau de l'utilisateur
**Le système est conçu pour échouer silencieusement** : Si une erreur se produit, l'utilisateur peut continuer à utiliser l'app normalement sans être bloqué.

103
em2rp/deploy_hosting.ps1 Normal file
View File

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

View File

@@ -42,6 +42,25 @@
"**/.*",
"**/node_modules/**"
],
"headers": [
{
"source": "version.json",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{
"key": "Access-Control-Allow-Methods",
"value": "GET, OPTIONS"
},
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
],
"rewrites": [
{
"source": "**",

View File

@@ -1,23 +1,97 @@
{
"indexes": [
{
"collectionGroup": "events",
"collectionGroup": "alerts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "EndDateTime",
"fieldPath": "assignedTo",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "isRead",
"order": "ASCENDING"
},
{
"fieldPath": "StartDateTime",
"order": "ASCENDING"
"fieldPath": "createdAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "alerts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "assignedTo",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "__name__",
"fieldPath": "createdAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "containers",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "containers",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "type",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "containers",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "type",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "equipments",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "category",
"order": "ASCENDING"
},
{
"fieldPath": "id",
"order": "ASCENDING"
}
]
@@ -27,7 +101,7 @@
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"fieldPath": "EndDateTime",
"order": "ASCENDING"
},
{
@@ -35,7 +109,7 @@
"order": "ASCENDING"
},
{
"fieldPath": "EndDateTime",
"fieldPath": "status",
"order": "ASCENDING"
}
]
@@ -43,4 +117,3 @@
],
"fieldOverrides": []
}

9
em2rp/functions/.env Normal file
View File

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

View File

@@ -46,7 +46,11 @@ const withCors = (handler) => {
* Crée une alerte et envoie les notifications
* Gère tout le processus côté backend de A à Z
*/
exports.createAlert = onRequest({cors: false, invoker: 'public'}, withCors(async (req, res) => {
exports.createAlert = onRequest({
cors: false,
invoker: 'public',
region: 'europe-west9'
}, withCors(async (req, res) => {
try {
// Vérifier l'authentification
const decodedToken = await auth.authenticateUser(req);

View File

@@ -28,6 +28,7 @@ const db = admin.firestore();
const httpOptions = {
cors: false,
invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase)
region: 'europe-west9', // Région européenne (Paris)
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
};
@@ -1725,6 +1726,160 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
}
}));
/**
* 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é
*/
exports.getEventWithDetails = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { eventId } = req.body.data || {};
if (!eventId) {
res.status(400).json({ error: 'eventId is required' });
return;
}
// Récupérer l'événement
const eventDoc = await db.collection('events').doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({ error: 'Event not found' });
return;
}
const eventData = eventDoc.data();
// Vérifier les permissions
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
if (!canViewAll) {
// Vérifier si l'utilisateur est dans la workforce
const userRef = db.collection('users').doc(decodedToken.uid);
const isInWorkforce = eventData.workforce && eventData.workforce.some(ref =>
(ref.id && ref.id === decodedToken.uid) ||
(typeof ref === 'string' && ref === `users/${decodedToken.uid}`)
);
if (!isInWorkforce) {
res.status(403).json({ error: 'Forbidden: Not assigned to this event' });
return;
}
}
logger.info(`[getEventWithDetails] Loading details for event ${eventId}`);
// Collecter tous les IDs d'équipements et de containers
const equipmentIds = new Set();
const containerIds = new Set();
if (eventData.assignedEquipment && Array.isArray(eventData.assignedEquipment)) {
eventData.assignedEquipment.forEach(eq => {
if (eq.equipmentId) {
equipmentIds.add(eq.equipmentId);
}
});
}
if (eventData.assignedContainers && Array.isArray(eventData.assignedContainers)) {
eventData.assignedContainers.forEach(id => containerIds.add(id));
}
logger.info(`[getEventWithDetails] Loading ${equipmentIds.size} equipments and ${containerIds.size} containers`);
// Charger tous les équipements en parallèle
const equipmentPromises = Array.from(equipmentIds).map(id =>
db.collection('equipments').doc(id).get()
);
const equipmentDocs = await Promise.all(equipmentPromises);
const equipmentMap = {};
for (const doc of equipmentDocs) {
if (doc.exists) {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
equipmentMap[doc.id] = data;
}
}
// Charger tous les containers en parallèle
const containerPromises = Array.from(containerIds).map(id =>
db.collection('containers').doc(id).get()
);
const containerDocs = await Promise.all(containerPromises);
// Collecter les IDs des équipements enfants des containers
const childEquipmentIds = new Set();
for (const doc of containerDocs) {
if (doc.exists) {
const containerData = doc.data();
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
containerData.equipmentIds.forEach(id => childEquipmentIds.add(id));
}
}
}
logger.info(`[getEventWithDetails] Loading ${childEquipmentIds.size} child equipments from containers`);
// Charger les équipements enfants des containers
const childEquipmentPromises = Array.from(childEquipmentIds).map(id =>
db.collection('equipments').doc(id).get()
);
const childEquipmentDocs = await Promise.all(childEquipmentPromises);
// Ajouter les enfants au map d'équipements
for (const doc of childEquipmentDocs) {
if (doc.exists && !equipmentMap[doc.id]) {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
equipmentMap[doc.id] = data;
}
}
// Construire les containers avec leurs enfants complets
const containerMap = {};
for (const doc of containerDocs) {
if (doc.exists) {
let containerData = { id: doc.id, ...doc.data() };
containerData = helpers.serializeTimestamps(containerData);
containerData = helpers.serializeReferences(containerData);
// Ajouter les équipements enfants complets
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
containerData.children = containerData.equipmentIds
.map(id => equipmentMap[id])
.filter(eq => eq !== undefined);
} else {
containerData.children = [];
}
containerMap[doc.id] = containerData;
}
}
// Construire la réponse finale
const event = {
id: eventDoc.id,
...helpers.serializeTimestamps(eventData),
workforce: eventData.workforce ? eventData.workforce.map(ref =>
(ref.id || (typeof ref === 'string' ? ref.split('/')[1] : null))
).filter(uid => uid !== null) : [],
};
logger.info(`[getEventWithDetails] Returning event with ${Object.keys(equipmentMap).length} equipments and ${Object.keys(containerMap).length} containers`);
res.status(200).json({
event,
equipments: equipmentMap,
containers: containerMap,
});
} catch (error) {
logger.error("Error getting event with details:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// MAINTENANCES - Read with permissions
// ============================================================================
@@ -1895,19 +2050,20 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
* Récupère un utilisateur spécifique par son ID
* Tout utilisateur authentifié peut accéder aux données publiques
*/
exports.getUser = onCall(async (request) => {
exports.getUser = onRequest(httpOptions, withCors(async (req, res) => {
try {
await authenticateUser(request);
const db = getFirestore();
const decodedToken = await auth.authenticateUser(req);
const { userId } = request.data;
const { userId } = req.body.data || req.body || {};
if (!userId) {
throw new Error("userId is required");
res.status(400).json({ error: 'userId is required' });
return;
}
const userDoc = await db.collection("users").doc(userId).get();
const userDoc = await db.collection('users').doc(userId).get();
if (!userDoc.exists) {
throw new Error("User not found");
res.status(404).json({ error: 'User not found' });
return;
}
const user = userDoc.data();
@@ -1916,11 +2072,11 @@ exports.getUser = onCall(async (request) => {
const userData = {
id: userDoc.id,
uid: user.uid || userDoc.id,
email: user.email || "",
firstName: user.firstName || "",
lastName: user.lastName || "",
phoneNumber: user.phoneNumber || "",
profilePhotoUrl: user.profilePhotoUrl || "",
email: user.email || '',
firstName: user.firstName || '',
lastName: user.lastName || '',
phoneNumber: user.phoneNumber || '',
profilePhotoUrl: user.profilePhotoUrl || '',
};
// Inclure le rôle si disponible
@@ -1934,12 +2090,12 @@ exports.getUser = onCall(async (request) => {
}
}
return { user: userData };
res.status(200).json({ user: userData });
} catch (error) {
logger.error("Error fetching user:", error);
throw new Error(error.message || "Failed to fetch user");
logger.error('Error fetching user:', error);
res.status(500).json({ error: error.message });
}
});
}));
// ============================================================================
@@ -3334,6 +3490,7 @@ const {sendDailyDigest} = require('./sendDailyDigest');
exports.sendDailyDigest = onSchedule({
schedule: '0 8 * * *',
timeZone: 'Europe/Paris',
region: 'europe-west9',
retryCount: 2,
memory: '512MiB'
}, async (context) => {
@@ -3353,7 +3510,10 @@ exports.sendDailyDigest = onSchedule({
* Trigger : Nouvel événement créé
* Envoie une notification à tous les membres de la workforce
*/
exports.onEventCreated = onDocumentCreated('events/{eventId}', async (event) => {
exports.onEventCreated = onDocumentCreated({
document: 'events/{eventId}',
region: 'europe-west9'
}, async (event) => {
logger.info(`[onEventCreated] Événement créé: ${event.params.eventId}`);
try {
@@ -3393,7 +3553,10 @@ exports.onEventCreated = onDocumentCreated('events/{eventId}', async (event) =>
* Trigger : Événement modifié (workforce changée)
* Envoie une notification aux nouveaux membres ajoutés à la workforce
*/
exports.onEventUpdated = onDocumentUpdated('events/{eventId}', async (event) => {
exports.onEventUpdated = onDocumentUpdated({
document: 'events/{eventId}',
region: 'europe-west9'
}, async (event) => {
const before = event.data.before.data();
const after = event.data.after.data();
const eventId = event.params.eventId;
@@ -3444,7 +3607,10 @@ exports.onEventUpdated = onDocumentUpdated('events/{eventId}', async (event) =>
* Trigger : Nouvelle alerte créée
* Envoie un email immédiat si l'alerte est critique
*/
exports.onAlertCreated = onDocumentCreated('alerts/{alertId}', async (event) => {
exports.onAlertCreated = onDocumentCreated({
document: 'alerts/{alertId}',
region: 'europe-west9'
}, async (event) => {
const alertId = event.params.alertId;
const alertData = event.data.data();

View File

@@ -8,7 +8,10 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
* Appelée par le client lors du chargement/déchargement
* Crée automatiquement les alertes nécessaires
*/
exports.processEquipmentValidation = onCall({cors: true}, async (request) => {
exports.processEquipmentValidation = onCall({
cors: true,
region: 'europe-west9'
}, async (request) => {
try {
// L'authentification est automatique avec onCall
const {auth, data} = request;

View File

@@ -1,4 +1,4 @@
const functions = require('firebase-functions');
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
@@ -10,22 +10,19 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
* Envoie un email d'alerte à un utilisateur
* Appelé par le client Dart via callable function
*/
exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
exports.sendAlertEmail = onCall({
region: 'europe-west9',
cors: true
}, async (request) => {
// Vérifier l'authentification
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'L\'utilisateur doit être authentifié',
);
if (!request.auth) {
throw new Error('L\'utilisateur doit être authentifié');
}
const {alertId, userId, templateType} = data;
const {alertId, userId, templateType} = request.data;
if (!alertId || !userId) {
throw new functions.https.HttpsError(
'invalid-argument',
'alertId et userId sont requis',
);
throw new Error('alertId et userId sont requis');
}
try {
@@ -36,10 +33,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
.get();
if (!alertDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'Alerte introuvable',
);
throw new Error('Alerte introuvable');
}
const alert = alertDoc.data();
@@ -51,10 +45,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
.get();
if (!userDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'Utilisateur introuvable',
);
throw new Error('Utilisateur introuvable');
}
const user = userDoc.data();
@@ -112,10 +103,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
};
} catch (error) {
console.error('Erreur envoi email:', error);
throw new functions.https.HttpsError(
'internal',
`Erreur lors de l'envoi de l'email: ${error.message}`,
);
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
}
});

View File

@@ -29,7 +29,7 @@ const EMAIL_CONFIG = {
},
replyTo: 'contact@em2events.fr',
// URL de l'application pour les liens
appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app',
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
};
module.exports = {

View File

@@ -4,8 +4,8 @@ class ApiConfig {
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
// URL de base pour les Cloud Functions
static const String productionUrl = 'https://us-central1-em2rp-951dc.cloudfunctions.net';
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/us-central1';
static const String productionUrl = 'https://europe-west9-em2rp-951dc.cloudfunctions.net';
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/europe-west9';
/// Retourne l'URL de base selon l'environnement
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;

View File

@@ -1,6 +1,6 @@
/// Configuration de la version de l'application
class AppVersion {
static const String version = '1.0.4';
static const String version = '1.0.9';
/// Retourne la version complète de l'application
static String get fullVersion => 'v$version';

View File

@@ -91,7 +91,20 @@ class EventFormController extends ChangeNotifier {
]);
if (existingEvent != null) {
// 🔧 FIX: Recharger l'événement avec tous les détails (équipements + containers avec enfants)
try {
final dataService = DataService(FirebaseFunctionsApiService());
final result = await dataService.getEventWithDetails(existingEvent.id);
final eventData = result['event'] as Map<String, dynamic>;
// Reconstruire l'événement avec les données complètes
final completeEvent = EventModel.fromMap(eventData, eventData['id'] as String);
_populateFromEvent(completeEvent);
} catch (e) {
// Si erreur, utiliser l'événement existant (fallback)
print('[EventFormController] Error loading event with details, using existing: $e');
_populateFromEvent(existingEvent);
}
} else {
_selectedStatus = EventStatus.waitingForApproval;

View File

@@ -5,6 +5,7 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/maintenance_provider.dart';
import 'package:em2rp/providers/alert_provider.dart';
import 'package:em2rp/utils/auth_guard_widget.dart';
import 'package:em2rp/utils/performance_monitor.dart';
import 'package:em2rp/views/alerts_page.dart';
import 'package:em2rp/views/calendar_page.dart';
import 'package:em2rp/views/login_page.dart';
@@ -203,22 +204,22 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
}
Future<void> _autoLogin() async {
PerformanceMonitor.start('App.autoLogin');
try {
final localAuthProvider =
Provider.of<LocalUserProvider>(context, listen: false);
// Vérifier si l'utilisateur est déjà connecté
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
PerformanceMonitor.start('App.signIn');
// Connexion automatique en mode développement
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
PerformanceMonitor.end('App.signIn');
}
// Charger les données utilisateur
await localAuthProvider.loadUserData();
if (mounted) {
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
// En Flutter Web, on peut vérifier window.location.hash
@@ -227,7 +228,7 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
print('[AutoLoginWrapper] Fragment URL: $fragment');
// Si une route spécifique est demandée (autre que / ou vide)
// 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);
@@ -236,9 +237,18 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
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');
}
@@ -247,9 +257,41 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
Widget build(BuildContext context) {
return const Scaffold(
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo de l'application
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 200,
height: 200,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 80,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 40),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 20),
const Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
),
],
),
),
);
}

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/performance_monitor.dart';
class EventProvider with ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
@@ -15,19 +16,43 @@ class EventProvider with ChangeNotifier {
// Cache des utilisateurs chargés depuis getEvents
Map<String, Map<String, dynamic>> _usersCache = {};
// Cache pour éviter les rechargements inutiles
DateTime? _lastLoadTime;
String? _lastUserId;
bool _lastCanViewAll = false;
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
final now = DateTime.now();
final difference = now.difference(_lastLoadTime!);
return difference.inSeconds > 30;
}
/// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false}) async {
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
PerformanceMonitor.end('EventProvider.loadUserEvents');
return;
}
_isLoading = true;
notifyListeners();
// Sauvegarder les paramètres
_saveLastLoadParams(userId, canViewAllEvents);
try {
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur
final result = await _dataService.getEvents(userId: userId);
PerformanceMonitor.end('EventProvider.getEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>;
@@ -38,6 +63,7 @@ class EventProvider with ChangeNotifier {
print('Found ${eventsData.length} events from API');
PerformanceMonitor.start('EventProvider.parseEvents');
List<EventModel> allEvents = [];
int failedCount = 0;
@@ -51,23 +77,30 @@ class EventProvider with ChangeNotifier {
failedCount++;
}
}
PerformanceMonitor.end('EventProvider.parseEvents');
_events = allEvents;
_lastLoadTime = DateTime.now();
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
_isLoading = false;
notifyListeners();
PerformanceMonitor.end('EventProvider.loadUserEvents');
} catch (e) {
print('Error loading events: $e');
_isLoading = false;
notifyListeners();
PerformanceMonitor.end('EventProvider.loadUserEvents');
rethrow;
}
}
/// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
}
/// Récupérer un événement spécifique par ID
@@ -157,16 +190,9 @@ class EventProvider with ChangeNotifier {
/// Vider la liste des événements
void clearEvents() {
_events = [];
_lastLoadTime = null;
_lastUserId = null;
_lastCanViewAll = false;
notifyListeners();
}
// Variables pour stocker le dernier appel
String? _lastUserId;
bool _lastCanViewAll = false;
/// Sauvegarder les paramètres du dernier chargement
void _saveLastLoadParams(String userId, bool canViewAllEvents) {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
}
}

View File

@@ -7,6 +7,7 @@ import '../models/notification_preferences_model.dart';
import '../utils/firebase_storage_manager.dart';
import '../services/api_service.dart';
import '../services/data_service.dart';
import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser;
@@ -15,6 +16,9 @@ class LocalUserProvider with ChangeNotifier {
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService);
bool _isLoadingUserData = false;
DateTime? _lastUserDataLoad;
UserModel? get currentUser => _currentUser;
String? get uid => _currentUser?.uid;
String? get firstName => _currentUser?.firstName;
@@ -25,18 +29,46 @@ class LocalUserProvider with ChangeNotifier {
String? get phoneNumber => _currentUser?.phoneNumber;
RoleModel? get currentRole => _currentRole;
List<String> get permissions => _currentRole?.permissions ?? [];
bool get isLoadingUserData => _isLoadingUserData;
/// Vérifie si les données utilisateur doivent être rechargées
bool _shouldReloadUserData() {
if (_currentUser == null) return true;
if (_lastUserDataLoad == null) return true;
final now = DateTime.now();
final difference = now.difference(_lastUserDataLoad!);
return difference.inMinutes > 5; // Cache de 5 minutes pour les données utilisateur
}
/// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData() async {
Future<void> loadUserData({bool forceReload = false}) async {
if (_auth.currentUser == null) {
print('No current user in Auth');
return;
}
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data');
return;
}
// Éviter les appels simultanés
if (_isLoadingUserData) {
print('User data already loading, skipping');
return;
}
_isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}');
try {
// Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
final result = await apiService.call('getCurrentUser', {});
PerformanceMonitor.end('LocalUserProvider.getCurrentUser_API');
final userData = result['user'] as Map<String, dynamic>;
print('User data loaded from API: ${userData['uid']}');
@@ -59,9 +91,14 @@ class LocalUserProvider with ChangeNotifier {
);
print('User data loaded successfully');
_lastUserDataLoad = DateTime.now();
_isLoadingUserData = false;
notifyListeners();
PerformanceMonitor.end('LocalUserProvider.loadUserData');
} catch (e) {
print('Error loading user data: $e');
_isLoadingUserData = false;
PerformanceMonitor.end('LocalUserProvider.loadUserData');
rethrow;
}
}
@@ -76,6 +113,8 @@ class LocalUserProvider with ChangeNotifier {
void clearUser() {
_currentUser = null;
_currentRole = null;
_lastUserDataLoad = null;
_isLoadingUserData = false;
notifyListeners();
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/services.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Service pour émettre des feedbacks sonores lors des interactions
class AudioFeedbackService {
/// Jouer un son de succès (clic système)
static Future<void> playSuccessBeep() async {
try {
await SystemSound.play(SystemSoundType.click);
} catch (e) {
DebugLog.error('[AudioFeedbackService] Error playing success beep', e);
}
}
/// Jouer un son d'erreur (alerte système)
static Future<void> playErrorBeep() async {
try {
// Note: SystemSoundType.alert n'existe pas sur toutes les plateformes
// On utilise click pour l'instant, peut être amélioré avec audioplayers
await SystemSound.play(SystemSoundType.click);
await Future.delayed(const Duration(milliseconds: 100));
await SystemSound.play(SystemSoundType.click);
} catch (e) {
DebugLog.error('[AudioFeedbackService] Error playing error beep', e);
}
}
/// Jouer une vibration haptique (si disponible)
static Future<void> playHapticFeedback() async {
try {
await HapticFeedback.mediumImpact();
} catch (e) {
DebugLog.error('[AudioFeedbackService] Error playing haptic feedback', e);
}
}
/// Jouer un feedback complet (son + vibration)
static Future<void> playFullFeedback({bool isSuccess = true}) async {
await playHapticFeedback();
if (isSuccess) {
await playSuccessBeep();
} else {
await playErrorBeep();
}
}
}

View File

@@ -88,7 +88,8 @@ class DataService {
/// Met à jour un événement
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
try {
final requestData = {'eventId': eventId, 'data': data};
// Correction : fusionner eventId et les champs de data à la racine
final requestData = {'eventId': eventId, ...data};
await _apiService.call('updateEvent', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
@@ -195,7 +196,11 @@ class DataService {
/// Crée une option
Future<String> createOption(String code, Map<String, dynamic> data) async {
try {
final requestData = {'code': code, ...data};
final requestData = {
'id': code, // Ajouter l'ID en utilisant le code comme identifiant
'code': code,
...data
};
final result = await _apiService.call('createOption', requestData);
return result['id'] as String? ?? code;
} catch (e) {
@@ -248,6 +253,35 @@ class DataService {
}
}
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try {
print('[DataService] Getting event with details: $eventId');
final result = await _apiService.call('getEventWithDetails', {
'eventId': eventId,
});
final event = result['event'] as Map<String, dynamic>?;
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
final containers = result['containers'] as Map<String, dynamic>? ?? {};
if (event == null) {
throw Exception('Event not found');
}
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
return {
'event': event,
'equipments': equipments,
'containers': containers,
};
} catch (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');
}
}
/// Récupère tous les équipements (avec masquage des prix selon permissions)
Future<List<Map<String, dynamic>>> getEquipments() async {
try {

View File

@@ -1,12 +1,11 @@
import 'package:cloud_functions/cloud_functions.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions
class EmailService {
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
/// Envoie un email d'alerte à un utilisateur
///

View File

@@ -71,7 +71,7 @@ class EventFormService {
required String sourcePath,
required String destinationPath,
}) async {
final url = Uri.parse('https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
final url = Uri.parse('https://europe-west9-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
final user = FirebaseAuth.instance.currentUser;
final idToken = await user?.getIdToken();

View File

@@ -1,112 +0,0 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/services/equipment_status_calculator.dart';
import 'package:em2rp/services/api_service.dart';
/// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour
class EventPreparationServiceExtended {
final ApiService _apiService = apiService;
// === CHARGEMENT (LOADING) ===
/// Valider un équipement individuel pour le chargement
Future<void> validateEquipmentLoading(String eventId, String equipmentId) async {
try {
await _apiService.call('validateEquipmentLoading', {
'eventId': eventId,
'equipmentId': equipmentId,
});
} catch (e) {
print('Error validating equipment loading: $e');
rethrow;
}
}
/// Valider tous les équipements pour le chargement
Future<void> validateAllLoading(String eventId) async {
try {
await _apiService.call('validateAllLoading', {
'eventId': eventId,
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all loading: $e');
rethrow;
}
}
// === DÉCHARGEMENT (UNLOADING) ===
/// Valider un équipement individuel pour le déchargement
Future<void> validateEquipmentUnloading(String eventId, String equipmentId) async {
try {
await _apiService.call('validateEquipmentUnloading', {
'eventId': eventId,
'equipmentId': equipmentId,
});
} catch (e) {
print('Error validating equipment unloading: $e');
rethrow;
}
}
/// Valider tous les équipements pour le déchargement
Future<void> validateAllUnloading(String eventId) async {
try {
await _apiService.call('validateAllUnloading', {
'eventId': eventId,
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all unloading: $e');
rethrow;
}
}
// === PRÉPARATION + CHARGEMENT ===
/// Valider préparation ET chargement en même temps
Future<void> validateAllPreparationAndLoading(String eventId) async {
try {
// Note: On pourrait créer une fonction cloud dédiée pour ça,
// mais pour l'instant on appelle les deux séquentiellement
await _apiService.call('validateAllPreparation', {'eventId': eventId});
await _apiService.call('validateAllLoading', {'eventId': eventId});
// Invalider le cache
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all preparation and loading: $e');
rethrow;
}
}
// === DÉCHARGEMENT + RETOUR ===
/// Valider déchargement ET retour en même temps
Future<void> validateAllUnloadingAndReturn(
String eventId,
Map<String, int>? returnedQuantities,
) async {
try {
// Note: On pourrait créer une fonction cloud dédiée pour ça,
// mais pour l'instant on appelle les deux séquentiellement
await _apiService.call('validateAllUnloading', {'eventId': eventId});
await _apiService.call('validateAllReturn', {
'eventId': eventId,
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
});
// Invalider le cache
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all unloading and return: $e');
rethrow;
}
}
}

View File

@@ -1,7 +1,6 @@
import 'package:em2rp/config/app_version.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class IcsExportService {

View File

@@ -0,0 +1,240 @@
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/qr_code_process_result.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Service pour traiter les codes QR scannés ou saisis manuellement
/// pendant la préparation d'un événement
class QRCodeProcessingService {
/// Traiter un code (équipement ou container)
Future<QRCodeProcessResult> processCode({
required String code,
required EventModel event,
required dynamic step, // Changed to dynamic to accept any PreparationStep enum
required Map<String, EquipmentModel> equipmentCache,
required Map<String, ContainerModel> containerCache,
required Map<String, bool> validationState,
required Map<String, int> currentQuantities,
}) async {
try {
DebugLog.info('[QRCodeProcessingService] Processing code: $code');
// Identifier le type selon le préfixe
final isContainer = code.startsWith('BOX_');
if (isContainer) {
return await _processContainer(
code: code,
event: event,
step: step,
equipmentCache: equipmentCache,
containerCache: containerCache,
validationState: validationState,
currentQuantities: currentQuantities,
);
} else {
return await _processEquipment(
code: code,
event: event,
step: step,
equipmentCache: equipmentCache,
validationState: validationState,
currentQuantities: currentQuantities,
);
}
} catch (e) {
DebugLog.error('[QRCodeProcessingService] Error processing code', e);
return QRCodeProcessResult.error('Erreur lors du traitement du code: $e');
}
}
/// Traiter un code d'équipement
Future<QRCodeProcessResult> _processEquipment({
required String code,
required EventModel event,
required dynamic step,
required Map<String, EquipmentModel> equipmentCache,
required Map<String, bool> validationState,
required Map<String, int> currentQuantities,
}) async {
// Chercher l'équipement dans les équipements assignés
final eventEquipment = event.assignedEquipment
.cast<EventEquipment?>()
.firstWhere(
(eq) => eq?.equipmentId == code,
orElse: () => null,
);
if (eventEquipment == null) {
DebugLog.info('[QRCodeProcessingService] Equipment $code not found in event');
return QRCodeProcessResult.notFoundInEvent(code);
}
final equipment = equipmentCache[code];
final equipmentName = equipment?.name ?? 'Équipement inconnu';
// Vérifier si l'équipement a des quantités
if (equipment?.hasQuantity ?? false) {
return _processQuantitativeEquipment(
code: code,
equipmentName: equipmentName,
eventEquipment: eventEquipment,
step: step,
validationState: validationState,
currentQuantities: currentQuantities,
);
} else {
return _processNonQuantitativeEquipment(
code: code,
equipmentName: equipmentName,
validationState: validationState,
);
}
}
/// Traiter un équipement quantitatif (incrémenter la quantité)
QRCodeProcessResult _processQuantitativeEquipment({
required String code,
required String equipmentName,
required EventEquipment eventEquipment,
required dynamic step,
required Map<String, bool> validationState,
required Map<String, int> currentQuantities,
}) {
final currentQty = currentQuantities[code] ?? 0;
final targetQty = _getTargetQuantity(eventEquipment, step);
// Vérifier si on a déjà atteint la quantité cible
if (currentQty >= targetQty) {
return QRCodeProcessResult.error(
'Quantité cible déjà atteinte pour $equipmentName ($currentQty/$targetQty)',
);
}
// Incrémenter la quantité
final newQty = currentQty + 1;
final shouldCheck = newQty >= targetQty;
return QRCodeProcessResult.success(
message: '$equipmentName : $newQty/$targetQty${shouldCheck ? "" : ""}',
affectedEquipmentIds: [code],
updatedQuantities: {code: newQty},
updatedValidationState: shouldCheck ? {code: true} : null,
);
}
/// Traiter un équipement non quantitatif (cocher)
QRCodeProcessResult _processNonQuantitativeEquipment({
required String code,
required String equipmentName,
required Map<String, bool> validationState,
}) {
// Vérifier si déjà coché
if (validationState[code] == true) {
return QRCodeProcessResult.error('$equipmentName est déjà coché');
}
return QRCodeProcessResult.success(
message: '$equipmentName a été coché ✓',
affectedEquipmentIds: [code],
updatedValidationState: {code: true},
);
}
/// Traiter un code de container (cocher tous les enfants)
Future<QRCodeProcessResult> _processContainer({
required String code,
required EventModel event,
required dynamic step,
required Map<String, EquipmentModel> equipmentCache,
required Map<String, ContainerModel> containerCache,
required Map<String, bool> validationState,
required Map<String, int> currentQuantities,
}) async {
// Vérifier que le container est assigné à l'événement
if (!event.assignedContainers.contains(code)) {
DebugLog.info('[QRCodeProcessingService] Container $code not found in event');
return QRCodeProcessResult.notFoundInEvent(code);
}
final container = containerCache[code];
if (container == null) {
return QRCodeProcessResult.error('Container introuvable dans le cache');
}
// Traiter tous les équipements enfants
final updatedValidation = <String, bool>{};
final updatedQuantities = <String, int>{};
int processedCount = 0;
for (final childId in container.equipmentIds) {
final childEventEq = event.assignedEquipment
.cast<EventEquipment?>()
.firstWhere(
(eq) => eq?.equipmentId == childId,
orElse: () => null,
);
if (childEventEq == null) continue;
final childEquipment = equipmentCache[childId];
// Si quantitatif, mettre la quantité actuelle = quantité cible
if (childEquipment?.hasQuantity ?? false) {
final targetQty = _getTargetQuantity(childEventEq, step);
updatedQuantities[childId] = targetQty;
}
// Cocher l'enfant
updatedValidation[childId] = true;
processedCount++;
}
if (processedCount == 0) {
return QRCodeProcessResult.error(
'Aucun équipement trouvé dans le container ${container.name}',
);
}
return QRCodeProcessResult.success(
message: 'Container ${container.name} : $processedCount équipement(s) validé(s) ✓',
affectedEquipmentIds: updatedValidation.keys.toList(),
updatedValidationState: updatedValidation,
updatedQuantities: updatedQuantities.isNotEmpty ? updatedQuantities : null,
);
}
/// Obtenir la quantité requise selon l'étape
/// Logique: chaque étape utilise la quantité actuelle de l'étape N-1
int _getTargetQuantity(EventEquipment eventEquipment, dynamic step) {
// Convertir l'enum en string pour comparer
final stepString = step.toString().split('.').last;
switch (stepString) {
case 'preparation':
// Étape 1 : Quantité définie à la création de l'événement
return eventEquipment.quantity;
case 'loadingOutbound':
// Étape 2 : Quantité validée à l'étape 1 (préparation)
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
case 'unloadingReturn':
// Étape 3 : Quantité validée à l'étape 2 (chargement)
return eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
case 'return_':
// Étape 4 : Quantité validée à l'étape 3 (déchargement)
return eventEquipment.quantityAtUnloading ??
eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
default:
return eventEquipment.quantity;
}
}
}

View File

@@ -104,8 +104,9 @@ class CalendarUtils {
static List<EventModel> getEventsForDay(
DateTime day, List<EventModel> events) {
final dayStart = DateTime(day.year, day.month, day.day, 0, 0);
final dayEnd = DateTime(day.year, day.month, day.day, 23, 59, 59);
final nextDay = day.add(const Duration(days: 1));
final dayStart = DateTime(day.year, day.month, day.day, 2, 0);
final dayEnd = DateTime(nextDay.year, nextDay.month, nextDay.day, 2, 59, 59);
return events.where((event) {
return !(event.endDateTime.isBefore(dayStart) ||

View File

@@ -0,0 +1,129 @@
import 'package:flutter/foundation.dart';
/// Service de monitoring des performances de l'application
/// Permet de mesurer les temps de chargement et d'identifier les goulots d'étranglement
class PerformanceMonitor {
static final Map<String, DateTime> _timings = {};
static final Map<String, Duration> _results = {};
static bool _enabled = kDebugMode; // Actif uniquement en mode debug par défaut
/// Active ou désactive le monitoring
static void setEnabled(bool enabled) {
_enabled = enabled;
}
/// Démarre le chronomètre pour une opération
static void start(String key) {
if (!_enabled) return;
_timings[key] = DateTime.now();
if (kDebugMode) {
print('[PerformanceMonitor] START: $key');
}
}
/// Arrête le chronomètre et affiche le résultat
static void end(String key) {
if (!_enabled) return;
if (_timings.containsKey(key)) {
final duration = DateTime.now().difference(_timings[key]!);
_results[key] = duration;
_timings.remove(key);
if (kDebugMode) {
final color = _getColorForDuration(duration);
print('[PerformanceMonitor] $color END: $key - ${duration.inMilliseconds}ms');
}
} else {
if (kDebugMode) {
print('[PerformanceMonitor] ⚠️ No start time found for: $key');
}
}
}
/// Marque un point dans le temps (pour mesurer des étapes)
static void mark(String key) {
if (!_enabled) return;
if (kDebugMode) {
print('[PerformanceMonitor] 📍 MARK: $key');
}
}
/// Récupère les résultats de toutes les mesures
static Map<String, Duration> getResults() {
return Map.unmodifiable(_results);
}
/// Affiche un résumé des performances
static void printSummary() {
if (!_enabled || _results.isEmpty) return;
print('\n' + '=' * 60);
print('PERFORMANCE SUMMARY');
print('=' * 60);
// Trier par durée décroissante
final sortedResults = _results.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
for (var entry in sortedResults) {
final color = _getColorForDuration(entry.value);
final ms = entry.value.inMilliseconds;
print('$color ${entry.key.padRight(40)} : ${ms.toString().padLeft(6)}ms');
}
final total = _results.values.fold<Duration>(
Duration.zero,
(sum, duration) => sum + duration,
);
print('${'=' * 60}');
print('TOTAL: ${total.inMilliseconds}ms');
print('=' * 60 + '\n');
}
/// Réinitialise toutes les mesures
static void reset() {
_timings.clear();
_results.clear();
if (kDebugMode) {
print('[PerformanceMonitor] 🔄 Reset');
}
}
/// Retourne une couleur basée sur la durée (pour les logs)
static String _getColorForDuration(Duration duration) {
final ms = duration.inMilliseconds;
if (ms < 100) return '🟢'; // Rapide
if (ms < 500) return '🟡'; // Moyen
if (ms < 1000) return '🟠'; // Lent
return '🔴'; // Très lent
}
/// Mesure une opération asynchrone
static Future<T> measure<T>(String key, Future<T> Function() operation) async {
start(key);
try {
final result = await operation();
end(key);
return result;
} catch (e) {
end(key);
rethrow;
}
}
/// Mesure une opération synchrone
static T measureSync<T>(String key, T Function() operation) {
start(key);
try {
final result = operation();
end(key);
return result;
} catch (e) {
end(key);
rethrow;
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/utils/performance_monitor.dart';
import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
@@ -35,23 +36,44 @@ class _CalendarPageState extends State<CalendarPage> {
void initState() {
super.initState();
initializeDateFormatting('fr_FR', null);
Future.microtask(() => _loadEvents());
// Sélection automatique de l'événement le plus proche de maintenant
WidgetsBinding.instance.addPostFrameCallback((_) {
// Charger les événements de manière asynchrone sans bloquer l'UI
_loadEventsAsync();
}
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
Future<void> _loadEventsAsync() async {
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
await _loadEvents();
// Sélectionner l'événement approprié après le chargement
if (mounted) {
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
_selectDefaultEvent();
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
}
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
}
/// Sélectionne automatiquement l'événement le plus proche de maintenant
void _selectDefaultEvent() {
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final events = eventProvider.events;
if (events.isNotEmpty) {
if (events.isEmpty) return;
final now = DateTime.now();
// Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir
final todayEvents = events
.where((e) =>
e.startDateTime.year == now.year &&
e.startDateTime.month == now.month &&
e.startDateTime.day == now.day)
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
// Trouver les événements d'aujourd'hui
final todayEvents = events.where((e) {
final start = e.startDateTime;
return start.year == now.year &&
start.month == now.month &&
start.day == now.day;
}).toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
EventModel? selected;
DateTime? selectedDay;
if (todayEvents.isNotEmpty) {
selected = todayEvents[0];
selectedDay = DateTime(now.year, now.month, now.day);
@@ -59,20 +81,23 @@ class _CalendarPageState extends State<CalendarPage> {
// Chercher le prochain événement à venir
final futureEvents = events
.where((e) => e.startDateTime.isAfter(now))
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
.toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
if (futureEvents.isNotEmpty) {
selected = futureEvents[0];
selectedDay = DateTime(selected.startDateTime.year,
selected.startDateTime.month, selected.startDateTime.day);
final start = selected.startDateTime;
selectedDay = DateTime(start.year, start.month, start.day);
} else {
// Aucun événement à venir, prendre le plus proche dans le passé
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
selected = events.last;
selectedDay = DateTime(selected.startDateTime.year,
selected.startDateTime.month, selected.startDateTime.day);
// Aucun événement à venir, prendre le plus récent
final sortedEvents = events.toList()
..sort((a, b) => b.startDateTime.compareTo(a.startDateTime));
selected = sortedEvents.first;
final start = selected.startDateTime;
selectedDay = DateTime(start.year, start.month, start.day);
}
}
if (mounted) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = selectedDay!;
@@ -80,7 +105,6 @@ class _CalendarPageState extends State<CalendarPage> {
_selectedEvent = selected;
});
}
});
}
Future<void> _loadEvents() async {

View File

@@ -14,7 +14,6 @@ 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/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_card.dart';
import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';

View File

@@ -17,7 +17,6 @@ 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/utils/debug_log.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
@@ -58,7 +57,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
}
void _onScroll() {
// Éviter les appels multiples
// Éviter les appels multiples avec un flag simple (sans setState)
if (_isLoadingMore) return;
final provider = context.read<EquipmentProvider>();
@@ -70,16 +69,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
_isLoadingMore = true;
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
_isLoadingMore = false;
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
_isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
});
}
@@ -502,15 +498,18 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
// Note : À ajuster selon la hauteur réelle de vos cartes
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
// ✅ Augmenter le cache pour un scroll plus fluide
cacheExtent: 500, // Précharger 500px en plus
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == equipments.length) {
return Center(
return const Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
@@ -525,7 +524,10 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentCard(EquipmentModel equipment) {
final isSelected = isItemSelected(equipment.id);
return Card(
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
return RepaintBoundary(
key: ValueKey(equipment.id),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.1)
@@ -640,6 +642,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
)
);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
@@ -10,8 +11,14 @@ import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/equipment_service.dart';
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/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/event_preparation/code_not_found_dialog.dart';
import 'package:em2rp/views/widgets/event_preparation/add_equipment_to_event_dialog.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
import 'package:em2rp/utils/colors.dart';
@@ -40,6 +47,7 @@ class EventPreparationPage extends StatefulWidget {
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late final DataService _dataService;
late final QRCodeProcessingService _qrCodeService;
Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {};
@@ -48,8 +56,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// État local des validations (non sauvegardé jusqu'à la validation finale)
Map<String, bool> _localValidationState = {};
// NOUVEAU : Gestion des quantités par étape
// Gestion des quantités par étape
Map<String, int> _quantitiesAtPreparation = {};
Map<String, int> _quantitiesAtLoading = {};
Map<String, int> _quantitiesAtUnloading = {};
@@ -63,6 +70,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Stockage de l'événement actuel
late EventModel _currentEvent;
// 🆕 Pour la saisie manuelle de codes
final TextEditingController _manualCodeController = TextEditingController();
final FocusNode _manualCodeFocusNode = FocusNode();
// Détermine l'étape actuelle selon le statut de l'événement
PreparationStep get _currentStep {
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
@@ -100,6 +111,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
super.initState();
_currentEvent = widget.initialEvent;
_dataService = DataService(FirebaseFunctionsApiService());
_qrCodeService = QRCodeProcessingService();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
@@ -140,6 +152,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
@override
void dispose() {
_animationController.dispose();
_manualCodeController.dispose();
_manualCodeFocusNode.dispose();
super.dispose();
}
@@ -147,20 +161,46 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
setState(() => _isLoading = true);
try {
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
// 🔧 FIX: Utiliser getEventWithDetails pour charger toutes les données d'un coup
DebugLog.info('[EventPreparationPage] Loading event with details: ${_currentEvent.id}');
// S'assurer que les équipements sont chargés
await equipmentProvider.ensureLoaded();
await containerProvider.ensureLoaded();
final result = await _dataService.getEventWithDetails(_currentEvent.id);
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
final containersMap = result['containers'] as Map<String, dynamic>;
final equipment = await equipmentProvider.equipmentStream.first;
final containers = await containerProvider.containersStream.first;
DebugLog.info('[EventPreparationPage] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
// Remplir les caches
_equipmentCache.clear();
_containerCache.clear();
// Remplir le cache d'équipements
equipmentsMap.forEach((id, data) {
try {
final equipment = EquipmentModel.fromMap(data as Map<String, dynamic>, id);
_equipmentCache[id] = equipment;
} catch (e) {
DebugLog.error('[EventPreparationPage] Error parsing equipment $id', e);
}
});
// Remplir le cache de containers
containersMap.forEach((id, data) {
try {
final container = ContainerModel.fromMap(data as Map<String, dynamic>, id);
_containerCache[id] = container;
} catch (e) {
DebugLog.error('[EventPreparationPage] Error parsing container $id', e);
}
});
// Initialiser les états de validation et quantités pour chaque équipement assigné
for (var eq in _currentEvent.assignedEquipment) {
final equipmentItem = equipment.firstWhere(
(e) => e.id == eq.equipmentId,
orElse: () => EquipmentModel(
final equipmentItem = _equipmentCache[eq.equipmentId];
// S'assurer que l'équipement est dans le cache (même si inconnu)
if (equipmentItem == null) {
_equipmentCache[eq.equipmentId] = EquipmentModel(
id: eq.equipmentId,
name: 'Équipement inconnu',
category: EquipmentCategory.other,
@@ -168,9 +208,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
_equipmentCache[eq.equipmentId] = equipmentItem;
}
// Initialiser l'état local de validation depuis l'événement
switch (_currentStep) {
@@ -190,15 +229,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
if ((_currentStep == PreparationStep.return_ ||
_currentStep == PreparationStep.unloadingReturn) &&
equipmentItem.hasQuantity) {
(equipmentItem?.hasQuantity ?? false)) {
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
}
}
// S'assurer que les containers assignés sont dans le cache (même si inconnus)
for (var containerId in _currentEvent.assignedContainers) {
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
if (!_containerCache.containsKey(containerId)) {
_containerCache[containerId] = ContainerModel(
id: containerId,
name: 'Conteneur inconnu',
type: ContainerType.flightCase,
@@ -206,9 +245,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
equipmentIds: [],
updatedAt: DateTime.now(),
createdAt: DateTime.now(),
),
);
_containerCache[containerId] = container;
}
}
} catch (e) {
DebugLog.error('[EventPreparationPage] Error', e);
@@ -392,7 +430,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
};
}).toList();
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
final result = await FirebaseFunctions.instanceFor(region: 'europe-west9')
.httpsCallable('processEquipmentValidation')
.call({
'eventId': _currentEvent.id,
@@ -564,6 +602,311 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
}
}
// ========================================================================
// 🆕 NOUVELLES MÉTHODES POUR LE SCAN QR ET LA SAISIE MANUELLE
// ========================================================================
/// Ouvrir le scanner QR en mode multi-scan
Future<void> _openQRScanner() async {
await showDialog(
context: context,
builder: (context) => QRCodeScannerDialog(
multiScanMode: true,
onCodeScanned: _handleScannedCode,
),
);
}
/// Traiter un code (scanné ou saisi manuellement)
Future<void> _handleScannedCode(String code) async {
final result = await _qrCodeService.processCode(
code: code.trim(),
event: _currentEvent,
step: _currentStep,
equipmentCache: _equipmentCache,
containerCache: _containerCache,
validationState: _localValidationState,
currentQuantities: _getCurrentQuantitiesMap(),
);
if (result.success) {
// ✅ Succès : mettre à jour l'état
setState(() {
if (result.updatedValidationState != null) {
_localValidationState.addAll(result.updatedValidationState!);
}
if (result.updatedQuantities != null) {
_updateQuantitiesMap(result.updatedQuantities!);
}
});
// 🔊 Jouer le feedback sonore et haptique
await AudioFeedbackService.playFullFeedback(isSuccess: true);
// Feedback visuel
_showSuccessFeedback(result.message ?? 'Code traité avec succès');
} else if (result.codeNotFoundInEvent) {
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
await _handleCodeNotFoundInEvent(code.trim());
} else {
// ❌ Erreur (ex: quantité déjà atteinte, déjà coché)
await AudioFeedbackService.playFullFeedback(isSuccess: false);
_showErrorFeedback(result.message ?? 'Erreur lors du traitement');
}
}
/// Gérer un code non trouvé dans l'événement
Future<void> _handleCodeNotFoundInEvent(String code) async {
// Afficher le dialog de confirmation
final shouldSearch = await showDialog<bool>(
context: context,
builder: (context) => CodeNotFoundDialog(scannedCode: code),
);
if (shouldSearch != true) return;
// Afficher le dialog de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AddEquipmentToEventDialog(
state: AddEquipmentState.loading,
),
);
try {
// Identifier le type selon le préfixe
final isContainer = code.startsWith('BOX_');
if (isContainer) {
await _addContainerToEvent(code);
} else {
await _addEquipmentToEvent(code);
}
// 🔊 Bip de succès
await AudioFeedbackService.playFullFeedback(isSuccess: true);
} catch (e) {
DebugLog.error('[EventPreparationPage] Error adding item to event', e);
// Fermer le dialog de chargement et afficher l'erreur
if (mounted) Navigator.of(context).pop();
await showDialog(
context: context,
builder: (context) => AddEquipmentToEventDialog(
state: AddEquipmentState.error,
errorMessage: e.toString(),
),
);
// 🔊 Bip d'erreur
await AudioFeedbackService.playFullFeedback(isSuccess: false);
}
}
/// Ajouter un équipement à l'événement
Future<void> _addEquipmentToEvent(String equipmentId) async {
// Rechercher l'équipement dans la base de données
final equipmentProvider = context.read<EquipmentProvider>();
await equipmentProvider.ensureLoaded();
// Chercher d'abord dans le cache
EquipmentModel? equipment = equipmentProvider.allEquipment
.cast<EquipmentModel?>()
.firstWhere(
(eq) => eq?.id == equipmentId,
orElse: () => null,
);
// Si pas dans le cache, charger depuis Firestore
if (equipment == null) {
final equipmentService = EquipmentService();
equipment = await equipmentService.getEquipmentById(equipmentId);
}
if (equipment == null) {
throw Exception('Équipement non trouvé dans la base de données');
}
// Ajouter l'équipement à l'événement
final newEventEquipment = EventEquipment(
equipmentId: equipmentId,
quantity: 1,
);
final updatedEquipment = List<EventEquipment>.from(_currentEvent.assignedEquipment)
..add(newEventEquipment);
await _dataService.updateEvent(_currentEvent.id, {
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
});
// Mettre à jour l'état local
setState(() {
_currentEvent = _currentEvent.copyWith(
assignedEquipment: updatedEquipment,
);
_equipmentCache[equipmentId] = equipment!;
_localValidationState[equipmentId] = false;
});
// Fermer le dialog de chargement et afficher le succès
if (mounted) Navigator.of(context).pop();
await showDialog(
context: context,
builder: (context) => AddEquipmentToEventDialog(
state: AddEquipmentState.success,
itemName: equipment!.name,
),
);
}
/// Ajouter un container à l'événement
Future<void> _addContainerToEvent(String containerId) async {
// Rechercher le container dans la base de données
final containerProvider = context.read<ContainerProvider>();
await containerProvider.ensureLoaded();
final container = await containerProvider.getContainerById(containerId);
if (container == null) {
throw Exception('Container non trouvé dans la base de données');
}
// Ajouter le container à l'événement
final updatedContainers = List<String>.from(_currentEvent.assignedContainers)
..add(containerId);
await _dataService.updateEvent(_currentEvent.id, {
'assignedContainers': updatedContainers,
});
// Mettre à jour l'état local
setState(() {
_currentEvent = _currentEvent.copyWith(
assignedContainers: updatedContainers,
);
_containerCache[containerId] = container;
});
// Fermer le dialog de chargement et afficher le succès
if (mounted) Navigator.of(context).pop();
await showDialog(
context: context,
builder: (context) => AddEquipmentToEventDialog(
state: AddEquipmentState.success,
itemName: 'Container ${container.name}',
),
);
}
/// Traiter la saisie manuelle d'un code
Future<void> _handleManualCodeEntry(String code) async {
if (code.trim().isEmpty) return;
await _handleScannedCode(code.trim());
// Effacer le champ après traitement
_manualCodeController.clear();
}
/// Obtenir les quantités actuelles selon l'étape
Map<String, int> _getCurrentQuantitiesMap() {
switch (_currentStep) {
case PreparationStep.preparation:
return _quantitiesAtPreparation;
case PreparationStep.loadingOutbound:
return _quantitiesAtLoading;
case PreparationStep.unloadingReturn:
return _quantitiesAtUnloading;
case PreparationStep.return_:
return _quantitiesAtReturn;
}
}
/// Mettre à jour les quantités selon l'étape
void _updateQuantitiesMap(Map<String, int> quantities) {
switch (_currentStep) {
case PreparationStep.preparation:
_quantitiesAtPreparation.addAll(quantities);
break;
case PreparationStep.loadingOutbound:
_quantitiesAtLoading.addAll(quantities);
break;
case PreparationStep.unloadingReturn:
_quantitiesAtUnloading.addAll(quantities);
break;
case PreparationStep.return_:
_quantitiesAtReturn.addAll(quantities);
break;
}
}
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
int _getTargetQuantity(EventEquipment eventEquipment) {
switch (_currentStep) {
case PreparationStep.preparation:
return eventEquipment.quantity; // Quantité initiale
case PreparationStep.loadingOutbound:
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
case PreparationStep.unloadingReturn:
return eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
case PreparationStep.return_:
return eventEquipment.quantityAtUnloading ??
eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
}
}
/// Afficher un message de succès
void _showSuccessFeedback(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
/// Afficher un message d'erreur
void _showErrorFeedback(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
),
);
}
// ========================================================================
// FIN DES NOUVELLES MÉTHODES
// ========================================================================
Future<void> _confirm() async {
// Vérifier s'il y a des équipements manquants (non cochés localement)
final missingEquipmentIds = _currentEvent.assignedEquipment
@@ -842,6 +1185,50 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
contentPadding: EdgeInsets.zero,
),
// 🆕 Champ de saisie manuelle de code
const SizedBox(height: 16),
TextField(
controller: _manualCodeController,
focusNode: _manualCodeFocusNode,
decoration: InputDecoration(
labelText: 'Saisie manuelle d\'un code',
hintText: 'Entrez un ID d\'équipement ou container',
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
suffixIcon: _manualCodeController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_manualCodeController.clear();
setState(() {});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onSubmitted: _handleManualCodeEntry,
onChanged: (value) => setState(() {}),
textInputAction: TextInputAction.done,
),
// 🆕 Bouton Scanner QR Code
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _openQRScanner,
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Scanner QR Code'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue[700],
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: allValidated ? null : _validateAllAndConfirm,

View File

@@ -58,7 +58,8 @@ class LoginPage extends StatelessWidget {
Widget _buildLoginForm(BuildContext context) {
return Consumer<LoginViewModel>(
builder: (context, loginViewModel, child) {
return Column(
return AutofillGroup(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
@@ -69,6 +70,7 @@ class LoginPage extends StatelessWidget {
EmailTextFieldWidget(
emailController: loginViewModel.emailController,
highlightEmailField: loginViewModel.highlightEmailField,
onSubmitted: () => loginViewModel.signIn(context),
),
const SizedBox(height: 20),
PasswordTextFieldWidget(
@@ -77,6 +79,7 @@ class LoginPage extends StatelessWidget {
highlightPasswordField: loginViewModel.highlightPasswordField,
onTogglePasswordVisibility:
loginViewModel.togglePasswordVisibility,
onSubmitted: () => loginViewModel.signIn(context),
),
ForgotPasswordButtonWidget(
onPressed: () => showDialog(
@@ -93,6 +96,7 @@ class LoginPage extends StatelessWidget {
const SizedBox(height: 20),
ErrorMessageWidget(errorMessage: loginViewModel.errorMessage),
],
),
);
},
);

View File

@@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
class EmailTextFieldWidget extends StatelessWidget {
final TextEditingController emailController;
final bool highlightEmailField;
final VoidCallback? onSubmitted;
const EmailTextFieldWidget({
super.key,
required this.emailController,
required this.highlightEmailField,
this.onSubmitted,
});
@override
@@ -16,6 +18,9 @@ class EmailTextFieldWidget extends StatelessWidget {
return TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email, AutofillHints.username],
textInputAction: TextInputAction.next,
onSubmitted: (_) => onSubmitted?.call(),
decoration: InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(

View File

@@ -7,6 +7,7 @@ class PasswordTextFieldWidget extends StatelessWidget {
final bool obscurePassword;
final bool highlightPasswordField;
final VoidCallback onTogglePasswordVisibility;
final VoidCallback? onSubmitted;
const PasswordTextFieldWidget({
super.key,
@@ -14,6 +15,7 @@ class PasswordTextFieldWidget extends StatelessWidget {
required this.obscurePassword,
required this.highlightPasswordField,
required this.onTogglePasswordVisibility,
this.onSubmitted,
});
@override
@@ -21,6 +23,9 @@ class PasswordTextFieldWidget extends StatelessWidget {
return TextField(
controller: passwordController,
obscureText: obscurePassword,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSubmitted?.call(),
decoration: InputDecoration(
labelText: 'Mot de passe',
border: OutlineInputBorder(

View File

@@ -23,39 +23,40 @@ class EventStatusButton extends StatefulWidget {
class _EventStatusButtonState extends State<EventStatusButton> {
bool _loading = false;
EventStatus? _optimisticStatus;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
Future<void> _changeStatus(EventStatus newStatus) async {
if (widget.event.status == newStatus) return;
setState(() => _loading = true);
if ((widget.event.status == newStatus) || _loading) return;
setState(() {
_loading = true;
_optimisticStatus = newStatus;
});
final oldStatus = widget.event.status;
try {
// Mettre à jour via l'API
await _dataService.updateEvent(widget.event.id, {
'status': eventStatusToString(newStatus),
});
// Récupérer l'événement mis à jour via l'API
final result = await _dataService.getEvents();
final eventsList = result['events'] as List<dynamic>;
final eventData = eventsList.firstWhere(
(e) => e['id'] == widget.event.id,
orElse: () => <String, dynamic>{},
);
if (eventData.isNotEmpty) {
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
widget.onSelectEvent(
updatedEvent,
widget.selectedDate ?? updatedEvent.startDateTime,
);
await Provider.of<EventProvider>(context, listen: false)
.updateEvent(updatedEvent);
}
} catch (e) {
if (mounted) {
setState(() {
_optimisticStatus = oldStatus;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du changement de statut: $e')),
);
@@ -69,11 +70,22 @@ class _EventStatusButtonState extends State<EventStatusButton> {
@override
Widget build(BuildContext context) {
final status = widget.event.status;
final status = _optimisticStatus ?? widget.event.status;
String texte;
Color couleurFond;
List<Widget> enfants = [];
if (_loading) {
return Container(
padding: const EdgeInsets.all(8),
child: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
switch (status) {
case EventStatus.waitingForApproval:
texte = "En Attente";

View File

@@ -4,7 +4,17 @@ import 'package:em2rp/utils/colors.dart';
/// Dialog pour scanner un QR code et récupérer l'ID
class QRCodeScannerDialog extends StatefulWidget {
const QRCodeScannerDialog({super.key});
/// Callback appelé quand un code est scanné (mode multi-scan)
final Function(String code)? onCodeScanned;
/// Active le mode scan continu (ne ferme pas automatiquement)
final bool multiScanMode;
const QRCodeScannerDialog({
super.key,
this.onCodeScanned,
this.multiScanMode = false,
});
@override
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
@@ -45,7 +55,21 @@ class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
_scannedCode = code;
});
// Retourner le code après un court délai pour montrer le feedback visuel
if (widget.multiScanMode && widget.onCodeScanned != null) {
// Mode multi-scan : appeler le callback et rester ouvert
widget.onCodeScanned!(code);
// Réinitialiser après un délai pour permettre un nouveau scan
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) {
setState(() {
_isProcessing = false;
_scannedCode = null;
});
}
});
} else {
// Mode simple : retourner le code et fermer
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.of(context).pop(code);
@@ -53,6 +77,7 @@ class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
});
}
}
}
@override
Widget build(BuildContext context) {

View File

@@ -6,6 +6,8 @@ import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/equipment_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/views/widgets/event/equipment_selection_dialog.dart';
import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart';
@@ -37,6 +39,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
final EventAvailabilityService _availabilityService = EventAvailabilityService();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {};
bool _isLoading = true;
@@ -64,17 +67,64 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
// 🔧 FIX: Si on a un eventId, utiliser getEventWithDetails pour charger les données complètes
if (widget.eventId != null && widget.eventId!.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Loading event with details: ${widget.eventId}');
final result = await _dataService.getEventWithDetails(widget.eventId!);
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
final containersMap = result['containers'] as Map<String, dynamic>;
DebugLog.info('[EventAssignedEquipmentSection] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
// Construire les caches à partir des données reçues
_equipmentCache.clear();
_containerCache.clear();
// Remplir le cache d'équipements
equipmentsMap.forEach((id, data) {
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
containersMap.forEach((id, data) {
try {
_containerCache[id] = ContainerModel.fromMap(data as Map<String, dynamic>, id);
} catch (e) {
DebugLog.error('[EventAssignedEquipmentSection] Error parsing container $id', e);
}
});
DebugLog.info('[EventAssignedEquipmentSection] Caches populated: ${_equipmentCache.length} equipments, ${_containerCache.length} containers');
} else {
// Mode création d'événement : charger via les providers
DebugLog.info('[EventAssignedEquipmentSection] Loading via providers (creation mode)');
// Extraire les IDs des équipements assignés
final equipmentIds = widget.assignedEquipment
.map((eq) => eq.equipmentId)
.toList();
// Charger UNIQUEMENT les équipements nécessaires (optimisé)
final equipment = await equipmentProvider.getEquipmentsByIds(equipmentIds);
// Charger UNIQUEMENT les conteneurs nécessaires (optimisé)
// Charger les conteneurs
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
// 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(
@@ -108,8 +158,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
);
_containerCache[containerId] = container;
}
}
} catch (e) {
// Erreur silencieuse - le cache restera vide
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
} finally {
setState(() => _isLoading = false);
}
@@ -156,6 +207,26 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
if (newContainers.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(newContainers);
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
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!existsInNew) {
newEquipment.add(EventEquipment(
equipmentId: childEquipmentId,
quantity: 1,
));
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
}
}
}
}
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
// On enregistre directement la sélection
@@ -217,27 +288,49 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
.where((id) => id != containerId)
.toList();
// Retirer les équipements enfants de la liste des équipements assignés
final updatedEquipment = widget.assignedEquipment.where((eq) {
if (container != null) {
// Garder uniquement les équipements qui ne sont PAS dans ce conteneur
return !container.equipmentIds.contains(eq.equipmentId);
}
return true;
}).toList();
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
final updatedEquipment = <EventEquipment>[];
if (container != null) {
// Collecter les IDs d'équipements dans les autres containers
final Set<String> equipmentIdsInOtherContainers = {};
for (var otherContainerId in updatedContainers) {
final otherContainer = _containerCache[otherContainerId];
if (otherContainer != null) {
equipmentIdsInOtherContainers.addAll(otherContainer.equipmentIds);
}
}
// Garder les équipements qui :
// 1. Ne sont PAS dans le container supprimé OU
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
for (var eq in widget.assignedEquipment) {
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
if (!isInRemovedContainer || isInOtherContainer) {
updatedEquipment.add(eq);
}
}
} else {
// Si le container n'est pas dans le cache, garder tous les équipements
updatedEquipment.addAll(widget.assignedEquipment);
}
// Notifier le changement avec les deux listes mises à jour
widget.onChanged(updatedEquipment, updatedContainers);
setState(() {
_containerCache.remove(containerId);
// Retirer aussi les équipements enfants du cache
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
if (container != null) {
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
for (var equipmentId in container.equipmentIds) {
if (!remainingEquipmentIds.contains(equipmentId)) {
_equipmentCache.remove(equipmentId);
}
}
}
});
}
@@ -444,30 +537,22 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
onPressed: () => _removeContainer(container.id),
),
children: [
// Afficher les équipements enfants (par composition)
Consumer<EquipmentProvider>(
builder: (context, provider, child) {
return StreamBuilder<List<EquipmentModel>>(
stream: provider.equipmentStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final allEquipment = snapshot.data ?? [];
final childEquipments = allEquipment
.where((eq) => container.equipmentIds.contains(eq.id))
// 🔧 FIX: Utiliser directement le cache local au lieu du provider stream
Builder(
builder: (context) {
// Récupérer les équipements enfants depuis le cache local
final childEquipments = container.equipmentIds
.map((id) => _equipmentCache[id])
.where((eq) => eq != null)
.cast<EquipmentModel>()
.toList();
if (childEquipments.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Aucun équipement dans ce conteneur',
style: TextStyle(color: Colors.grey),
'Aucun équipement dans ce conteneur (${container.equipmentIds.length} attendu(s))',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
);
}
@@ -517,8 +602,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
),
);
},
);
},
),
],
),

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// États possibles lors de l'ajout d'un équipement
enum AddEquipmentState {
loading,
success,
error,
}
/// Dialog pour afficher le résultat de l'ajout d'un équipement/container
class AddEquipmentToEventDialog extends StatelessWidget {
final AddEquipmentState state;
final String? itemName;
final String? errorMessage;
const AddEquipmentToEventDialog({
super.key,
required this.state,
this.itemName,
this.errorMessage,
});
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildIcon(),
const SizedBox(height: 16),
_buildMessage(),
if (state != AddEquipmentState.loading) ...[
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
child: const Text('OK'),
),
],
],
),
),
);
}
Widget _buildIcon() {
switch (state) {
case AddEquipmentState.loading:
return const SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
color: AppColors.rouge,
strokeWidth: 4,
),
);
case AddEquipmentState.success:
return const Icon(
Icons.check_circle,
size: 64,
color: Colors.green,
);
case AddEquipmentState.error:
return const Icon(
Icons.error,
size: 64,
color: Colors.red,
);
}
}
Widget _buildMessage() {
switch (state) {
case AddEquipmentState.loading:
return const Text(
'Recherche en cours...',
style: TextStyle(fontSize: 16),
);
case AddEquipmentState.success:
return Column(
children: [
const Text(
'Ajouté avec succès !',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
if (itemName != null) ...[
const SizedBox(height: 8),
Text(
itemName!,
style: const TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
],
],
);
case AddEquipmentState.error:
return Column(
children: [
const Text(
'Non trouvé',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
if (errorMessage != null) ...[
const SizedBox(height: 8),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
],
],
);
}
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Dialog affiché quand un code scanné n'est pas trouvé dans l'événement
class CodeNotFoundDialog extends StatelessWidget {
final String scannedCode;
const CodeNotFoundDialog({
super.key,
required this.scannedCode,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
icon: const Icon(
Icons.warning_amber_rounded,
size: 64,
color: Colors.orange,
),
title: const Text('Code non reconnu'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Le code scanné n\'est pas assigné à cet événement :',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
scannedCode,
style: const TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
const SizedBox(height: 16),
const Text(
'Voulez-vous le rechercher dans la base de données et l\'ajouter à l\'événement ?',
textAlign: TextAlign.center,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Non'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.rouge),
child: const Text('Oui, rechercher'),
),
],
);
}
}

View File

@@ -1,5 +1,5 @@
name: em2rp
description: "A new Flutter project."
description: "L'app de gestion d'événements et matériel par EM2 Events"
publish_to: 'none'
version: 1.0.0+1
@@ -58,6 +58,7 @@ dependencies:
flutter_localizations:
sdk: flutter
timeago: ^3.6.1
audioplayers: ^6.1.0
path: any
dev_dependencies:

View File

@@ -5,18 +5,23 @@
* - Bascule en mode PRODUCTION
* - Incrémente la version
* - Build l'application Flutter pour le web
* - Déploie sur Firebase Hosting
* - Vérifie que version.json est bien présent
* - Déploie sur Firebase Hosting (avec en-têtes CORS pour version.json)
* - Vérifie que version.json est accessible avec CORS
* - Rebascule en mode DÉVELOPPEMENT
*/
const { execSync } = require('child_process');
const { incrementVersion } = require('./increment_version');
const { setProductionMode, setDevelopmentMode } = require('./toggle_env');
const fs = require('fs');
const path = require('path');
const https = require('https');
console.log('🚀 Démarrage du déploiement Firebase Hosting...\n');
// Étape 0: Basculer en mode production
console.log('🔒 Étape 0/4: Basculement en mode PRODUCTION');
console.log('🔒 Étape 0/5: Basculement en mode PRODUCTION');
if (!setProductionMode()) {
console.error('❌ Impossible de basculer en mode production');
process.exit(1);
@@ -24,12 +29,12 @@ if (!setProductionMode()) {
console.log('');
// Étape 1: Incrémenter la version
console.log('📝 Étape 1/4: Incrémentation de la version');
console.log('📝 Étape 1/5: Incrémentation de la version');
const newVersion = incrementVersion();
console.log('');
// Étape 2: Build Flutter pour le web
console.log('🔨 Étape 2/4: Build Flutter Web');
console.log('🔨 Étape 2/5: Build Flutter Web');
try {
execSync('flutter build web --release', {
stdio: 'inherit',
@@ -43,9 +48,42 @@ try {
process.exit(1);
}
// Étape 3: Déploiement Firebase
console.log('🌐 Étape 3/4: Déploiement sur Firebase Hosting');
// Étape 2.5: Vérifier que version.json est bien présent dans build/web
console.log('🔍 Étape 2.5/5: Vérification de version.json');
const versionJsonPath = path.join(process.cwd(), 'build', 'web', 'version.json');
if (!fs.existsSync(versionJsonPath)) {
console.warn('⚠️ version.json n\'a pas été copié dans build/web/');
// Copier manuellement depuis web/version.json
const sourceVersionJsonPath = path.join(process.cwd(), 'web', 'version.json');
if (fs.existsSync(sourceVersionJsonPath)) {
console.log(' → Copie de web/version.json vers build/web/...');
fs.copyFileSync(sourceVersionJsonPath, versionJsonPath);
console.log('✅ Fichier version.json copié avec succès');
} else {
console.error('❌ Impossible de trouver web/version.json');
setDevelopmentMode();
process.exit(1);
}
} else {
console.log('✅ version.json est présent dans build/web/');
}
// Afficher la version qui va être déployée
try {
const versionContent = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8'));
console.log(` 📦 Version: ${versionContent.version}`);
console.log(` 🔒 Force update: ${versionContent.forceUpdate}`);
} catch (error) {
console.warn('⚠️ Impossible de lire version.json');
}
console.log('');
// Étape 3: Déploiement Firebase
console.log('🌐 Étape 3/5: Déploiement sur Firebase Hosting');
console.log(' Les en-têtes CORS pour version.json seront appliqués automatiquement');
try {
execSync('firebase deploy --only hosting', {
stdio: 'inherit',
cwd: process.cwd()
@@ -59,8 +97,48 @@ try {
process.exit(1);
}
// Étape 4: Rebascule en mode développement
console.log('\n🔓 Étape 4/4: Retour en mode DÉVELOPPEMENT');
// Étape 4: Vérifier que version.json est accessible avec CORS
console.log('\n🔍 Étape 4/5: Vérification de l\'accès à version.json');
setTimeout(() => {
https.get('https://app.em2events.fr/version.json', {
headers: {
'Origin': 'http://localhost'
}
}, (res) => {
if (res.statusCode === 200) {
console.log('✅ version.json est accessible (statut 200)');
// Vérifier les en-têtes CORS
const corsHeader = res.headers['access-control-allow-origin'];
if (corsHeader) {
console.log(`✅ En-têtes CORS configurés: ${corsHeader}`);
} else {
console.warn('⚠️ En-têtes CORS non détectés (peuvent prendre quelques minutes pour se propager)');
}
// Lire et afficher la version déployée
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
const deployed = JSON.parse(body);
console.log(`📦 Version déployée: ${deployed.version}`);
} catch (e) {
// Ignore
}
});
} else {
console.warn(`⚠️ Statut HTTP: ${res.statusCode}`);
}
}).on('error', (err) => {
console.warn('⚠️ Impossible de vérifier l\'accès à version.json');
console.warn(` ${err.message}`);
console.warn(' Le fichier peut prendre quelques minutes pour être accessible');
});
}, 2000); // Attendre 2 secondes pour que le déploiement se propage
// Étape 5: Rebascule en mode développement
console.log('\n🔓 Étape 5/5: Retour en mode DÉVELOPPEMENT');
if (!setDevelopmentMode()) {
console.warn('⚠️ Impossible de rebascule en mode développement');
console.warn('⚠️ Exécutez manuellement: npm run env:dev');
@@ -69,3 +147,4 @@ if (!setDevelopmentMode()) {
}
console.log('\n✨ Processus de déploiement terminé!');
console.log('📝 Les utilisateurs recevront une notification de mise à jour au prochain chargement.');

View File

@@ -1,7 +1,7 @@
{
"version": "1.0.4",
"version": "1.0.9",
"updateUrl": "https://app.em2events.fr",
"forceUpdate": true,
"releaseNotes": "Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :\r\n\r\n* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.\r\n* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.",
"timestamp": "2026-01-16T17:56:48.878Z"
"timestamp": "2026-02-07T15:35:30.790Z"
}