Compare commits
2 Commits
a182f1b922
...
8cd4854924
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd4854924 | ||
|
|
a7e5f91a21 |
@@ -32,16 +32,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
|
|||||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||||
version.json,1768586208886,5a25871ae727f23c4b7258c34108085b8711aa94f6fcab512e0c3ca00a429a64
|
version.json,1770478530807,2cbfdf7f34574c2f9d4f1af02acb86d8d230af93790c97a3c7e1674c4db42ef4
|
||||||
index.html,1768586225248,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
index.html,1770478536326,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
flutter_service_worker.js,1768586307073,4ea31c373e15f13c2916a12d9d799905af2a79ff7ed0bcceb4334707910c7721
|
flutter_service_worker.js,1770478628965,cb72807cfcb05b0a2e7b3f4f0cf618a0284a3d2476c93672bd86ea99670b0f5d
|
||||||
flutter_bootstrap.js,1768586225225,e95b1b0bd493a475c8eed0e630e413d898f2ceff11cd9b24c6c564bbc2c5f5e9
|
assets/FontManifest.json,1770478624084,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/FontManifest.json,1768586302952,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
assets/AssetManifest.json,1770478624084,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
|
||||||
assets/AssetManifest.json,1768586302952,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
|
flutter_bootstrap.js,1770478536318,bf4a3b4bf79eaed1ce24892f20cfb270bcc22fb392bc9f6a1d17aeed42ed4ed8
|
||||||
assets/AssetManifest.bin.json,1768586302952,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
|
assets/AssetManifest.bin.json,1770478624084,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
|
||||||
assets/AssetManifest.bin,1768586302952,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
|
assets/AssetManifest.bin,1770478624084,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768586306083,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1770478628013,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/shaders/ink_sparkle.frag,1768586303187,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/shaders/ink_sparkle.frag,1770478624492,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1768586306096,33efc485968dd28630ace587c22d6df359c195821b1114aaa85383e4d5394eac
|
assets/fonts/MaterialIcons-Regular.otf,1770478628013,50e06fd231edee237d875cddbae1e22b682d32bb1284e3c32ca409fa489f9c21
|
||||||
assets/NOTICES,1768586302954,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c
|
assets/NOTICES,1770478624086,d02d64a466e62fdaeee2534a3f65541362ccf29beb495e2af0fdce41f4ae28d9
|
||||||
main.dart.js,1768586301774,9b399ba21ab3247d46cf7dbcd5873aa248636bcd7864a1a0cedf1aae08608f9a
|
main.dart.js,1770478620736,03d43aeaa96cfdbe5b7491f9610223ec95c29d47095570dd61cd6cddac863496
|
||||||
|
|||||||
@@ -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
103
em2rp/deploy_hosting.ps1
Normal 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
|
||||||
@@ -42,6 +42,25 @@
|
|||||||
"**/.*",
|
"**/.*",
|
||||||
"**/node_modules/**"
|
"**/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": [
|
"rewrites": [
|
||||||
{
|
{
|
||||||
"source": "**",
|
"source": "**",
|
||||||
|
|||||||
@@ -1,23 +1,97 @@
|
|||||||
{
|
{
|
||||||
"indexes": [
|
"indexes": [
|
||||||
{
|
{
|
||||||
"collectionGroup": "events",
|
"collectionGroup": "alerts",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "EndDateTime",
|
"fieldPath": "assignedTo",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isRead",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "StartDateTime",
|
"fieldPath": "createdAt",
|
||||||
"order": "ASCENDING"
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "alerts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "assignedTo",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "status",
|
"fieldPath": "status",
|
||||||
"order": "ASCENDING"
|
"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"
|
"order": "ASCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -27,7 +101,7 @@
|
|||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "status",
|
"fieldPath": "EndDateTime",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -35,7 +109,7 @@
|
|||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "EndDateTime",
|
"fieldPath": "status",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -43,4 +117,3 @@
|
|||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
em2rp/functions/.env
Normal file
9
em2rp/functions/.env
Normal 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"
|
||||||
|
|
||||||
@@ -46,7 +46,11 @@ const withCors = (handler) => {
|
|||||||
* Crée une alerte et envoie les notifications
|
* Crée une alerte et envoie les notifications
|
||||||
* Gère tout le processus côté backend de A à Z
|
* 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 {
|
try {
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const db = admin.firestore();
|
|||||||
const httpOptions = {
|
const httpOptions = {
|
||||||
cors: false,
|
cors: false,
|
||||||
invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase)
|
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
|
// 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
|
// 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
|
* Récupère un utilisateur spécifique par son ID
|
||||||
* Tout utilisateur authentifié peut accéder aux données publiques
|
* Tout utilisateur authentifié peut accéder aux données publiques
|
||||||
*/
|
*/
|
||||||
exports.getUser = onCall(async (request) => {
|
exports.getUser = onRequest(httpOptions, withCors(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await authenticateUser(request);
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
const db = getFirestore();
|
|
||||||
|
|
||||||
const { userId } = request.data;
|
const { userId } = req.body.data || req.body || {};
|
||||||
if (!userId) {
|
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) {
|
if (!userDoc.exists) {
|
||||||
throw new Error("User not found");
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userDoc.data();
|
const user = userDoc.data();
|
||||||
@@ -1916,11 +2072,11 @@ exports.getUser = onCall(async (request) => {
|
|||||||
const userData = {
|
const userData = {
|
||||||
id: userDoc.id,
|
id: userDoc.id,
|
||||||
uid: user.uid || userDoc.id,
|
uid: user.uid || userDoc.id,
|
||||||
email: user.email || "",
|
email: user.email || '',
|
||||||
firstName: user.firstName || "",
|
firstName: user.firstName || '',
|
||||||
lastName: user.lastName || "",
|
lastName: user.lastName || '',
|
||||||
phoneNumber: user.phoneNumber || "",
|
phoneNumber: user.phoneNumber || '',
|
||||||
profilePhotoUrl: user.profilePhotoUrl || "",
|
profilePhotoUrl: user.profilePhotoUrl || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inclure le rôle si disponible
|
// 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) {
|
} catch (error) {
|
||||||
logger.error("Error fetching user:", error);
|
logger.error('Error fetching user:', error);
|
||||||
throw new Error(error.message || "Failed to fetch user");
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -3334,6 +3490,7 @@ const {sendDailyDigest} = require('./sendDailyDigest');
|
|||||||
exports.sendDailyDigest = onSchedule({
|
exports.sendDailyDigest = onSchedule({
|
||||||
schedule: '0 8 * * *',
|
schedule: '0 8 * * *',
|
||||||
timeZone: 'Europe/Paris',
|
timeZone: 'Europe/Paris',
|
||||||
|
region: 'europe-west9',
|
||||||
retryCount: 2,
|
retryCount: 2,
|
||||||
memory: '512MiB'
|
memory: '512MiB'
|
||||||
}, async (context) => {
|
}, async (context) => {
|
||||||
@@ -3353,7 +3510,10 @@ exports.sendDailyDigest = onSchedule({
|
|||||||
* Trigger : Nouvel événement créé
|
* Trigger : Nouvel événement créé
|
||||||
* Envoie une notification à tous les membres de la workforce
|
* 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}`);
|
logger.info(`[onEventCreated] Événement créé: ${event.params.eventId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3393,7 +3553,10 @@ exports.onEventCreated = onDocumentCreated('events/{eventId}', async (event) =>
|
|||||||
* Trigger : Événement modifié (workforce changée)
|
* Trigger : Événement modifié (workforce changée)
|
||||||
* Envoie une notification aux nouveaux membres ajoutés à la workforce
|
* 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 before = event.data.before.data();
|
||||||
const after = event.data.after.data();
|
const after = event.data.after.data();
|
||||||
const eventId = event.params.eventId;
|
const eventId = event.params.eventId;
|
||||||
@@ -3444,7 +3607,10 @@ exports.onEventUpdated = onDocumentUpdated('events/{eventId}', async (event) =>
|
|||||||
* Trigger : Nouvelle alerte créée
|
* Trigger : Nouvelle alerte créée
|
||||||
* Envoie un email immédiat si l'alerte est critique
|
* 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 alertId = event.params.alertId;
|
||||||
const alertData = event.data.data();
|
const alertData = event.data.data();
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
|||||||
* Appelée par le client lors du chargement/déchargement
|
* Appelée par le client lors du chargement/déchargement
|
||||||
* Crée automatiquement les alertes nécessaires
|
* 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 {
|
try {
|
||||||
// L'authentification est automatique avec onCall
|
// L'authentification est automatique avec onCall
|
||||||
const {auth, data} = request;
|
const {auth, data} = request;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const functions = require('firebase-functions');
|
const {onCall} = require('firebase-functions/v2/https');
|
||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const handlebars = require('handlebars');
|
const handlebars = require('handlebars');
|
||||||
@@ -10,22 +10,19 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
|||||||
* Envoie un email d'alerte à un utilisateur
|
* Envoie un email d'alerte à un utilisateur
|
||||||
* Appelé par le client Dart via callable function
|
* 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
|
// Vérifier l'authentification
|
||||||
if (!context.auth) {
|
if (!request.auth) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('L\'utilisateur doit être authentifié');
|
||||||
'unauthenticated',
|
|
||||||
'L\'utilisateur doit être authentifié',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {alertId, userId, templateType} = data;
|
const {alertId, userId, templateType} = request.data;
|
||||||
|
|
||||||
if (!alertId || !userId) {
|
if (!alertId || !userId) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('alertId et userId sont requis');
|
||||||
'invalid-argument',
|
|
||||||
'alertId et userId sont requis',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,10 +33,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!alertDoc.exists) {
|
if (!alertDoc.exists) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('Alerte introuvable');
|
||||||
'not-found',
|
|
||||||
'Alerte introuvable',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const alert = alertDoc.data();
|
const alert = alertDoc.data();
|
||||||
@@ -51,10 +45,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
throw new functions.https.HttpsError(
|
throw new Error('Utilisateur introuvable');
|
||||||
'not-found',
|
|
||||||
'Utilisateur introuvable',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userDoc.data();
|
const user = userDoc.data();
|
||||||
@@ -112,10 +103,7 @@ exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur envoi email:', error);
|
console.error('Erreur envoi email:', error);
|
||||||
throw new functions.https.HttpsError(
|
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
|
||||||
'internal',
|
|
||||||
`Erreur lors de l'envoi de l'email: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const EMAIL_CONFIG = {
|
|||||||
},
|
},
|
||||||
replyTo: 'contact@em2events.fr',
|
replyTo: 'contact@em2events.fr',
|
||||||
// URL de l'application pour les liens
|
// 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 = {
|
module.exports = {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ class ApiConfig {
|
|||||||
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
||||||
|
|
||||||
// URL de base pour les Cloud Functions
|
// URL de base pour les Cloud Functions
|
||||||
static const String productionUrl = 'https://us-central1-em2rp-951dc.cloudfunctions.net';
|
static const String productionUrl = 'https://europe-west9-em2rp-951dc.cloudfunctions.net';
|
||||||
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/us-central1';
|
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/europe-west9';
|
||||||
|
|
||||||
/// Retourne l'URL de base selon l'environnement
|
/// Retourne l'URL de base selon l'environnement
|
||||||
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '1.0.4';
|
static const String version = '1.0.9';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -91,7 +91,20 @@ class EventFormController extends ChangeNotifier {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (existingEvent != null) {
|
if (existingEvent != null) {
|
||||||
_populateFromEvent(existingEvent);
|
// 🔧 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 {
|
} else {
|
||||||
_selectedStatus = EventStatus.waitingForApproval;
|
_selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
import 'package:em2rp/providers/alert_provider.dart';
|
import 'package:em2rp/providers/alert_provider.dart';
|
||||||
import 'package:em2rp/utils/auth_guard_widget.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/alerts_page.dart';
|
||||||
import 'package:em2rp/views/calendar_page.dart';
|
import 'package:em2rp/views/calendar_page.dart';
|
||||||
import 'package:em2rp/views/login_page.dart';
|
import 'package:em2rp/views/login_page.dart';
|
||||||
@@ -203,22 +204,22 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _autoLogin() async {
|
Future<void> _autoLogin() async {
|
||||||
|
PerformanceMonitor.start('App.autoLogin');
|
||||||
try {
|
try {
|
||||||
final localAuthProvider =
|
final localAuthProvider =
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
|
||||||
// Vérifier si l'utilisateur est déjà connecté
|
// Vérifier si l'utilisateur est déjà connecté
|
||||||
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
||||||
|
PerformanceMonitor.start('App.signIn');
|
||||||
// Connexion automatique en mode développement
|
// Connexion automatique en mode développement
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
Env.devAdminEmail,
|
Env.devAdminEmail,
|
||||||
Env.devAdminPassword,
|
Env.devAdminPassword,
|
||||||
);
|
);
|
||||||
|
PerformanceMonitor.end('App.signIn');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les données utilisateur
|
|
||||||
await localAuthProvider.loadUserData();
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||||
// En Flutter Web, on peut vérifier window.location.hash
|
// En Flutter Web, on peut vérifier window.location.hash
|
||||||
@@ -227,7 +228,7 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
|
|
||||||
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
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') {
|
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||||
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||||
Navigator.of(context).pushReplacementNamed(fragment);
|
Navigator.of(context).pushReplacementNamed(fragment);
|
||||||
@@ -236,9 +237,18 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
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) {
|
} catch (e) {
|
||||||
print('Auto login failed: $e');
|
print('Auto login failed: $e');
|
||||||
|
PerformanceMonitor.end('App.autoLogin');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushReplacementNamed('/login');
|
Navigator.of(context).pushReplacementNamed('/login');
|
||||||
}
|
}
|
||||||
@@ -247,9 +257,41 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
body: Center(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
63
em2rp/lib/models/qr_code_process_result.dart
Normal file
63
em2rp/lib/models/qr_code_process_result.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
|
|
||||||
class EventProvider with ChangeNotifier {
|
class EventProvider with ChangeNotifier {
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
@@ -15,19 +16,43 @@ class EventProvider with ChangeNotifier {
|
|||||||
// Cache des utilisateurs chargés depuis getEvents
|
// Cache des utilisateurs chargés depuis getEvents
|
||||||
Map<String, Map<String, dynamic>> _usersCache = {};
|
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
|
/// 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;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Sauvegarder les paramètres
|
|
||||||
_saveLastLoadParams(userId, canViewAllEvents);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
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
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
final result = await _dataService.getEvents(userId: userId);
|
final result = await _dataService.getEvents(userId: userId);
|
||||||
|
PerformanceMonitor.end('EventProvider.getEvents_API');
|
||||||
|
|
||||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
final usersData = result['users'] as Map<String, dynamic>;
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
@@ -38,6 +63,7 @@ class EventProvider with ChangeNotifier {
|
|||||||
|
|
||||||
print('Found ${eventsData.length} events from API');
|
print('Found ${eventsData.length} events from API');
|
||||||
|
|
||||||
|
PerformanceMonitor.start('EventProvider.parseEvents');
|
||||||
List<EventModel> allEvents = [];
|
List<EventModel> allEvents = [];
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
|
||||||
@@ -51,23 +77,30 @@ class EventProvider with ChangeNotifier {
|
|||||||
failedCount++;
|
failedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PerformanceMonitor.end('EventProvider.parseEvents');
|
||||||
|
|
||||||
_events = allEvents;
|
_events = allEvents;
|
||||||
|
_lastLoadTime = DateTime.now();
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading events: $e');
|
print('Error loading events: $e');
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recharger les événements (utilise le dernier userId)
|
/// Recharger les événements (utilise le dernier userId)
|
||||||
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
Future<void> refreshEvents(String userId, {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
|
/// Récupérer un événement spécifique par ID
|
||||||
@@ -157,16 +190,9 @@ class EventProvider with ChangeNotifier {
|
|||||||
/// Vider la liste des événements
|
/// Vider la liste des événements
|
||||||
void clearEvents() {
|
void clearEvents() {
|
||||||
_events = [];
|
_events = [];
|
||||||
|
_lastLoadTime = null;
|
||||||
|
_lastUserId = null;
|
||||||
|
_lastCanViewAll = false;
|
||||||
notifyListeners();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../models/notification_preferences_model.dart';
|
|||||||
import '../utils/firebase_storage_manager.dart';
|
import '../utils/firebase_storage_manager.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../services/data_service.dart';
|
import '../services/data_service.dart';
|
||||||
|
import '../utils/performance_monitor.dart';
|
||||||
|
|
||||||
class LocalUserProvider with ChangeNotifier {
|
class LocalUserProvider with ChangeNotifier {
|
||||||
UserModel? _currentUser;
|
UserModel? _currentUser;
|
||||||
@@ -15,6 +16,9 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||||
final DataService _dataService = DataService(apiService);
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
|
bool _isLoadingUserData = false;
|
||||||
|
DateTime? _lastUserDataLoad;
|
||||||
|
|
||||||
UserModel? get currentUser => _currentUser;
|
UserModel? get currentUser => _currentUser;
|
||||||
String? get uid => _currentUser?.uid;
|
String? get uid => _currentUser?.uid;
|
||||||
String? get firstName => _currentUser?.firstName;
|
String? get firstName => _currentUser?.firstName;
|
||||||
@@ -25,18 +29,46 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
String? get phoneNumber => _currentUser?.phoneNumber;
|
String? get phoneNumber => _currentUser?.phoneNumber;
|
||||||
RoleModel? get currentRole => _currentRole;
|
RoleModel? get currentRole => _currentRole;
|
||||||
List<String> get permissions => _currentRole?.permissions ?? [];
|
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
|
/// 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) {
|
if (_auth.currentUser == null) {
|
||||||
print('No current user in Auth');
|
print('No current user in Auth');
|
||||||
return;
|
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}');
|
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||||
try {
|
try {
|
||||||
// Utiliser la Cloud Function getCurrentUser
|
// Utiliser la Cloud Function getCurrentUser
|
||||||
|
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
|
||||||
final result = await apiService.call('getCurrentUser', {});
|
final result = await apiService.call('getCurrentUser', {});
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.getCurrentUser_API');
|
||||||
|
|
||||||
final userData = result['user'] as Map<String, dynamic>;
|
final userData = result['user'] as Map<String, dynamic>;
|
||||||
|
|
||||||
print('User data loaded from API: ${userData['uid']}');
|
print('User data loaded from API: ${userData['uid']}');
|
||||||
@@ -59,9 +91,14 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
print('User data loaded successfully');
|
print('User data loaded successfully');
|
||||||
|
_lastUserDataLoad = DateTime.now();
|
||||||
|
_isLoadingUserData = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.loadUserData');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading user data: $e');
|
print('Error loading user data: $e');
|
||||||
|
_isLoadingUserData = false;
|
||||||
|
PerformanceMonitor.end('LocalUserProvider.loadUserData');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +113,8 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
void clearUser() {
|
void clearUser() {
|
||||||
_currentUser = null;
|
_currentUser = null;
|
||||||
_currentRole = null;
|
_currentRole = null;
|
||||||
|
_lastUserDataLoad = null;
|
||||||
|
_isLoadingUserData = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
em2rp/lib/services/audio_feedback_service.dart
Normal file
46
em2rp/lib/services/audio_feedback_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,8 @@ class DataService {
|
|||||||
/// Met à jour un événement
|
/// Met à jour un événement
|
||||||
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||||
try {
|
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);
|
await _apiService.call('updateEvent', requestData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||||
@@ -195,7 +196,11 @@ class DataService {
|
|||||||
/// Crée une option
|
/// Crée une option
|
||||||
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||||
try {
|
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);
|
final result = await _apiService.call('createOption', requestData);
|
||||||
return result['id'] as String? ?? code;
|
return result['id'] as String? ?? code;
|
||||||
} catch (e) {
|
} 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)
|
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||||
Future<List<Map<String, dynamic>>> getEquipments() async {
|
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import 'package:cloud_functions/cloud_functions.dart';
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
|
||||||
/// Service d'envoi d'emails via Cloud Functions
|
/// Service d'envoi d'emails via Cloud Functions
|
||||||
class EmailService {
|
class EmailService {
|
||||||
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
|
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
|
||||||
|
|
||||||
/// Envoie un email d'alerte à un utilisateur
|
/// Envoie un email d'alerte à un utilisateur
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class EventFormService {
|
|||||||
required String sourcePath,
|
required String sourcePath,
|
||||||
required String destinationPath,
|
required String destinationPath,
|
||||||
}) async {
|
}) 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 user = FirebaseAuth.instance.currentUser;
|
||||||
final idToken = await user?.getIdToken();
|
final idToken = await user?.getIdToken();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:em2rp/config/app_version.dart';
|
import 'package:em2rp/config/app_version.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class IcsExportService {
|
class IcsExportService {
|
||||||
|
|||||||
240
em2rp/lib/services/qr_code_processing_service.dart
Normal file
240
em2rp/lib/services/qr_code_processing_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,8 +104,9 @@ class CalendarUtils {
|
|||||||
|
|
||||||
static List<EventModel> getEventsForDay(
|
static List<EventModel> getEventsForDay(
|
||||||
DateTime day, List<EventModel> events) {
|
DateTime day, List<EventModel> events) {
|
||||||
final dayStart = DateTime(day.year, day.month, day.day, 0, 0);
|
final nextDay = day.add(const Duration(days: 1));
|
||||||
final dayEnd = DateTime(day.year, day.month, day.day, 23, 59, 59);
|
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 events.where((event) {
|
||||||
return !(event.endDateTime.isBefore(dayStart) ||
|
return !(event.endDateTime.isBefore(dayStart) ||
|
||||||
|
|||||||
129
em2rp/lib/utils/performance_monitor.dart
Normal file
129
em2rp/lib/utils/performance_monitor.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
@@ -35,52 +36,75 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
initializeDateFormatting('fr_FR', null);
|
initializeDateFormatting('fr_FR', null);
|
||||||
Future.microtask(() => _loadEvents());
|
// Charger les événements de manière asynchrone sans bloquer l'UI
|
||||||
// Sélection automatique de l'événement le plus proche de maintenant
|
_loadEventsAsync();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
}
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
||||||
final events = eventProvider.events;
|
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||||
if (events.isNotEmpty) {
|
Future<void> _loadEventsAsync() async {
|
||||||
final now = DateTime.now();
|
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
|
||||||
// Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir
|
await _loadEvents();
|
||||||
final todayEvents = events
|
|
||||||
.where((e) =>
|
// Sélectionner l'événement approprié après le chargement
|
||||||
e.startDateTime.year == now.year &&
|
if (mounted) {
|
||||||
e.startDateTime.month == now.month &&
|
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||||
e.startDateTime.day == now.day)
|
_selectDefaultEvent();
|
||||||
.toList()
|
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
}
|
||||||
EventModel? selected;
|
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
|
||||||
DateTime? selectedDay;
|
}
|
||||||
if (todayEvents.isNotEmpty) {
|
|
||||||
selected = todayEvents[0];
|
/// Sélectionne automatiquement l'événement le plus proche de maintenant
|
||||||
selectedDay = DateTime(now.year, now.month, now.day);
|
void _selectDefaultEvent() {
|
||||||
} else {
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
// Chercher le prochain événement à venir
|
final events = eventProvider.events;
|
||||||
final futureEvents = events
|
|
||||||
.where((e) => e.startDateTime.isAfter(now))
|
if (events.isEmpty) return;
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
final now = DateTime.now();
|
||||||
if (futureEvents.isNotEmpty) {
|
|
||||||
selected = futureEvents[0];
|
// Trouver les événements d'aujourd'hui
|
||||||
selectedDay = DateTime(selected.startDateTime.year,
|
final todayEvents = events.where((e) {
|
||||||
selected.startDateTime.month, selected.startDateTime.day);
|
final start = e.startDateTime;
|
||||||
} else {
|
return start.year == now.year &&
|
||||||
// Aucun événement à venir, prendre le plus proche dans le passé
|
start.month == now.month &&
|
||||||
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
start.day == now.day;
|
||||||
selected = events.last;
|
}).toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
selectedDay = DateTime(selected.startDateTime.year,
|
|
||||||
selected.startDateTime.month, selected.startDateTime.day);
|
EventModel? selected;
|
||||||
}
|
DateTime? selectedDay;
|
||||||
}
|
|
||||||
setState(() {
|
if (todayEvents.isNotEmpty) {
|
||||||
_selectedDay = selectedDay;
|
selected = todayEvents[0];
|
||||||
_focusedDay = selectedDay!;
|
selectedDay = DateTime(now.year, now.month, now.day);
|
||||||
_selectedEventIndex = 0;
|
} else {
|
||||||
_selectedEvent = selected;
|
// Chercher le prochain événement à venir
|
||||||
});
|
final futureEvents = events
|
||||||
|
.where((e) => e.startDateTime.isAfter(now))
|
||||||
|
.toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
|
if (futureEvents.isNotEmpty) {
|
||||||
|
selected = futureEvents[0];
|
||||||
|
final start = selected.startDateTime;
|
||||||
|
selectedDay = DateTime(start.year, start.month, start.day);
|
||||||
|
} else {
|
||||||
|
// 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!;
|
||||||
|
_selectedEventIndex = 0;
|
||||||
|
_selectedEvent = selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadEvents() async {
|
Future<void> _loadEvents() async {
|
||||||
|
|||||||
@@ -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/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.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_card.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|||||||
@@ -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/views/widgets/equipment/equipment_status_badge.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.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/common/search_actions_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
// Éviter les appels multiples
|
// Éviter les appels multiples avec un flag simple (sans setState)
|
||||||
if (_isLoadingMore) return;
|
if (_isLoadingMore) return;
|
||||||
|
|
||||||
final provider = context.read<EquipmentProvider>();
|
final provider = context.read<EquipmentProvider>();
|
||||||
@@ -70,16 +69,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
|
|
||||||
// Vérifier qu'on peut charger plus
|
// Vérifier qu'on peut charger plus
|
||||||
if (provider.hasMore && !provider.isLoadingMore) {
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
setState(() => _isLoadingMore = true);
|
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
||||||
|
_isLoadingMore = true;
|
||||||
|
|
||||||
provider.loadNextPage().then((_) {
|
provider.loadNextPage().then((_) {
|
||||||
if (mounted) {
|
_isLoadingMore = false;
|
||||||
setState(() => _isLoadingMore = false);
|
|
||||||
}
|
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
if (mounted) {
|
_isLoadingMore = false;
|
||||||
setState(() => _isLoadingMore = false);
|
|
||||||
}
|
|
||||||
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -502,15 +498,18 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
itemCount: itemCount,
|
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) {
|
itemBuilder: (context, index) {
|
||||||
// Dernier élément = indicateur de chargement
|
// Dernier élément = indicateur de chargement
|
||||||
if (index == equipments.length) {
|
if (index == equipments.length) {
|
||||||
return Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
child: provider.isLoadingMore
|
child: CircularProgressIndicator(),
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -525,78 +524,81 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
||||||
final isSelected = isItemSelected(equipment.id);
|
final isSelected = isItemSelected(equipment.id);
|
||||||
|
|
||||||
return Card(
|
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
return RepaintBoundary(
|
||||||
color: isSelectionMode && isSelected
|
key: ValueKey(equipment.id),
|
||||||
? AppColors.rouge.withValues(alpha: 0.1)
|
child: Card(
|
||||||
: null,
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
color: isSelectionMode && isSelected
|
||||||
leading: isSelectionMode
|
? AppColors.rouge.withValues(alpha: 0.1)
|
||||||
? Checkbox(
|
: null,
|
||||||
value: isSelected,
|
child: ListTile(
|
||||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
leading: isSelectionMode
|
||||||
activeColor: AppColors.rouge,
|
? Checkbox(
|
||||||
)
|
value: isSelected,
|
||||||
: CircleAvatar(
|
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
activeColor: AppColors.rouge,
|
||||||
child: equipment.category.getIcon(
|
)
|
||||||
size: 20,
|
: CircleAvatar(
|
||||||
color: equipment.category.color,
|
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||||
),
|
child: equipment.category.getIcon(
|
||||||
),
|
size: 20,
|
||||||
title: Row(
|
color: equipment.category.color,
|
||||||
children: [
|
),
|
||||||
Expanded(
|
),
|
||||||
child: Text(
|
title: Row(
|
||||||
equipment.id,
|
children: [
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
),
|
equipment.id,
|
||||||
// Afficher le badge de statut calculé dynamiquement
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
if (equipment.category != EquipmentCategory.consumable &&
|
|
||||||
equipment.category != EquipmentCategory.cable)
|
|
||||||
EquipmentStatusBadge(equipment: equipment),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
|
||||||
.trim()
|
|
||||||
.isNotEmpty
|
|
||||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
|
||||||
: 'Marque/Modèle non défini',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
|
||||||
),
|
|
||||||
// Afficher la sous-catégorie si elle existe
|
|
||||||
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'📁 ${equipment.subCategory}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey[500],
|
|
||||||
fontSize: 12,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Afficher le badge de statut calculé dynamiquement
|
||||||
|
if (equipment.category != EquipmentCategory.consumable &&
|
||||||
|
equipment.category != EquipmentCategory.cable)
|
||||||
|
EquipmentStatusBadge(equipment: equipment),
|
||||||
],
|
],
|
||||||
// Afficher la quantité disponible pour les consommables/câbles
|
),
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
subtitle: Column(
|
||||||
equipment.category == EquipmentCategory.cable) ...[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildQuantityDisplay(equipment),
|
Text(
|
||||||
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||||
|
.trim()
|
||||||
|
.isNotEmpty
|
||||||
|
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
||||||
|
: 'Marque/Modèle non défini',
|
||||||
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||||
|
),
|
||||||
|
// Afficher la sous-catégorie si elle existe
|
||||||
|
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'📁 ${equipment.subCategory}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Afficher la quantité disponible pour les consommables/câbles
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildQuantityDisplay(equipment),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
trailing: isSelectionMode
|
||||||
trailing: isSelectionMode
|
? null
|
||||||
? null
|
: Row(
|
||||||
: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
|
||||||
equipment.category == EquipmentCategory.cable)
|
equipment.category == EquipmentCategory.cable)
|
||||||
PermissionGate(
|
PermissionGate(
|
||||||
requiredPermissions: const ['manage_equipment'],
|
requiredPermissions: const ['manage_equipment'],
|
||||||
@@ -640,6 +642,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
? () => toggleItemSelection(equipment.id)
|
? () => toggleItemSelection(equipment.id)
|
||||||
: () => _viewEquipmentDetails(equipment),
|
: () => _viewEquipmentDetails(equipment),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cloud_functions/cloud_functions.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/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/container_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/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import '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/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
||||||
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/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/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
@@ -40,6 +47,7 @@ class EventPreparationPage extends StatefulWidget {
|
|||||||
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
||||||
late AnimationController _animationController;
|
late AnimationController _animationController;
|
||||||
late final DataService _dataService;
|
late final DataService _dataService;
|
||||||
|
late final QRCodeProcessingService _qrCodeService;
|
||||||
|
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
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)
|
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
||||||
Map<String, bool> _localValidationState = {};
|
Map<String, bool> _localValidationState = {};
|
||||||
|
|
||||||
|
// Gestion des quantités par étape
|
||||||
// NOUVEAU : Gestion des quantités par étape
|
|
||||||
Map<String, int> _quantitiesAtPreparation = {};
|
Map<String, int> _quantitiesAtPreparation = {};
|
||||||
Map<String, int> _quantitiesAtLoading = {};
|
Map<String, int> _quantitiesAtLoading = {};
|
||||||
Map<String, int> _quantitiesAtUnloading = {};
|
Map<String, int> _quantitiesAtUnloading = {};
|
||||||
@@ -63,6 +70,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
// Stockage de l'événement actuel
|
// Stockage de l'événement actuel
|
||||||
late EventModel _currentEvent;
|
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
|
// Détermine l'étape actuelle selon le statut de l'événement
|
||||||
PreparationStep get _currentStep {
|
PreparationStep get _currentStep {
|
||||||
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
||||||
@@ -100,6 +111,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
super.initState();
|
super.initState();
|
||||||
_currentEvent = widget.initialEvent;
|
_currentEvent = widget.initialEvent;
|
||||||
_dataService = DataService(FirebaseFunctionsApiService());
|
_dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
_qrCodeService = QRCodeProcessingService();
|
||||||
_animationController = AnimationController(
|
_animationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
@@ -140,6 +152,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_manualCodeController.dispose();
|
||||||
|
_manualCodeFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,20 +161,46 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
// 🔧 FIX: Utiliser getEventWithDetails pour charger toutes les données d'un coup
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
DebugLog.info('[EventPreparationPage] Loading event with details: ${_currentEvent.id}');
|
||||||
|
|
||||||
// S'assurer que les équipements sont chargés
|
final result = await _dataService.getEventWithDetails(_currentEvent.id);
|
||||||
await equipmentProvider.ensureLoaded();
|
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
|
||||||
await containerProvider.ensureLoaded();
|
final containersMap = result['containers'] as Map<String, dynamic>;
|
||||||
|
|
||||||
final equipment = await equipmentProvider.equipmentStream.first;
|
DebugLog.info('[EventPreparationPage] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
|
||||||
final containers = await containerProvider.containersStream.first;
|
|
||||||
|
|
||||||
|
// 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) {
|
for (var eq in _currentEvent.assignedEquipment) {
|
||||||
final equipmentItem = equipment.firstWhere(
|
final equipmentItem = _equipmentCache[eq.equipmentId];
|
||||||
(e) => e.id == eq.equipmentId,
|
|
||||||
orElse: () => EquipmentModel(
|
// S'assurer que l'équipement est dans le cache (même si inconnu)
|
||||||
|
if (equipmentItem == null) {
|
||||||
|
_equipmentCache[eq.equipmentId] = EquipmentModel(
|
||||||
id: eq.equipmentId,
|
id: eq.equipmentId,
|
||||||
name: 'Équipement inconnu',
|
name: 'Équipement inconnu',
|
||||||
category: EquipmentCategory.other,
|
category: EquipmentCategory.other,
|
||||||
@@ -168,9 +208,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
maintenanceIds: [],
|
maintenanceIds: [],
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
_equipmentCache[eq.equipmentId] = equipmentItem;
|
|
||||||
|
|
||||||
// Initialiser l'état local de validation depuis l'événement
|
// Initialiser l'état local de validation depuis l'événement
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
@@ -190,15 +229,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
if ((_currentStep == PreparationStep.return_ ||
|
if ((_currentStep == PreparationStep.return_ ||
|
||||||
_currentStep == PreparationStep.unloadingReturn) &&
|
_currentStep == PreparationStep.unloadingReturn) &&
|
||||||
equipmentItem.hasQuantity) {
|
(equipmentItem?.hasQuantity ?? false)) {
|
||||||
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
_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) {
|
for (var containerId in _currentEvent.assignedContainers) {
|
||||||
final container = containers.firstWhere(
|
if (!_containerCache.containsKey(containerId)) {
|
||||||
(c) => c.id == containerId,
|
_containerCache[containerId] = ContainerModel(
|
||||||
orElse: () => ContainerModel(
|
|
||||||
id: containerId,
|
id: containerId,
|
||||||
name: 'Conteneur inconnu',
|
name: 'Conteneur inconnu',
|
||||||
type: ContainerType.flightCase,
|
type: ContainerType.flightCase,
|
||||||
@@ -206,9 +245,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
equipmentIds: [],
|
equipmentIds: [],
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
_containerCache[containerId] = container;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[EventPreparationPage] Error', e);
|
DebugLog.error('[EventPreparationPage] Error', e);
|
||||||
@@ -392,7 +430,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
|
final result = await FirebaseFunctions.instanceFor(region: 'europe-west9')
|
||||||
.httpsCallable('processEquipmentValidation')
|
.httpsCallable('processEquipmentValidation')
|
||||||
.call({
|
.call({
|
||||||
'eventId': _currentEvent.id,
|
'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 {
|
Future<void> _confirm() async {
|
||||||
// Vérifier s'il y a des équipements manquants (non cochés localement)
|
// Vérifier s'il y a des équipements manquants (non cochés localement)
|
||||||
final missingEquipmentIds = _currentEvent.assignedEquipment
|
final missingEquipmentIds = _currentEvent.assignedEquipment
|
||||||
@@ -842,6 +1185,50 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
contentPadding: EdgeInsets.zero,
|
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),
|
const SizedBox(height: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: allValidated ? null : _validateAllAndConfirm,
|
onPressed: allValidated ? null : _validateAllAndConfirm,
|
||||||
|
|||||||
@@ -58,41 +58,45 @@ class LoginPage extends StatelessWidget {
|
|||||||
Widget _buildLoginForm(BuildContext context) {
|
Widget _buildLoginForm(BuildContext context) {
|
||||||
return Consumer<LoginViewModel>(
|
return Consumer<LoginViewModel>(
|
||||||
builder: (context, loginViewModel, child) {
|
builder: (context, loginViewModel, child) {
|
||||||
return Column(
|
return AutofillGroup(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const LogoWidget(),
|
children: <Widget>[
|
||||||
const SizedBox(height: 30),
|
const LogoWidget(),
|
||||||
const WelcomeTextWidget(),
|
const SizedBox(height: 30),
|
||||||
const SizedBox(height: 40),
|
const WelcomeTextWidget(),
|
||||||
EmailTextFieldWidget(
|
const SizedBox(height: 40),
|
||||||
emailController: loginViewModel.emailController,
|
EmailTextFieldWidget(
|
||||||
highlightEmailField: loginViewModel.highlightEmailField,
|
emailController: loginViewModel.emailController,
|
||||||
),
|
highlightEmailField: loginViewModel.highlightEmailField,
|
||||||
const SizedBox(height: 20),
|
onSubmitted: () => loginViewModel.signIn(context),
|
||||||
PasswordTextFieldWidget(
|
|
||||||
passwordController: loginViewModel.passwordController,
|
|
||||||
obscurePassword: loginViewModel.obscurePassword,
|
|
||||||
highlightPasswordField: loginViewModel.highlightPasswordField,
|
|
||||||
onTogglePasswordVisibility:
|
|
||||||
loginViewModel.togglePasswordVisibility,
|
|
||||||
),
|
|
||||||
ForgotPasswordButtonWidget(
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) =>
|
|
||||||
const ForgotPasswordDialogWidget(),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 30),
|
PasswordTextFieldWidget(
|
||||||
LoginButtonWidget(
|
passwordController: loginViewModel.passwordController,
|
||||||
isLoading: loginViewModel.isLoading,
|
obscurePassword: loginViewModel.obscurePassword,
|
||||||
onPressed: () => loginViewModel.signIn(context),
|
highlightPasswordField: loginViewModel.highlightPasswordField,
|
||||||
),
|
onTogglePasswordVisibility:
|
||||||
const SizedBox(height: 20),
|
loginViewModel.togglePasswordVisibility,
|
||||||
ErrorMessageWidget(errorMessage: loginViewModel.errorMessage),
|
onSubmitted: () => loginViewModel.signIn(context),
|
||||||
],
|
),
|
||||||
|
ForgotPasswordButtonWidget(
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) =>
|
||||||
|
const ForgotPasswordDialogWidget(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
LoginButtonWidget(
|
||||||
|
isLoading: loginViewModel.isLoading,
|
||||||
|
onPressed: () => loginViewModel.signIn(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ErrorMessageWidget(errorMessage: loginViewModel.errorMessage),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
|
|||||||
class EmailTextFieldWidget extends StatelessWidget {
|
class EmailTextFieldWidget extends StatelessWidget {
|
||||||
final TextEditingController emailController;
|
final TextEditingController emailController;
|
||||||
final bool highlightEmailField;
|
final bool highlightEmailField;
|
||||||
|
final VoidCallback? onSubmitted;
|
||||||
|
|
||||||
const EmailTextFieldWidget({
|
const EmailTextFieldWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.emailController,
|
required this.emailController,
|
||||||
required this.highlightEmailField,
|
required this.highlightEmailField,
|
||||||
|
this.onSubmitted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -16,6 +18,9 @@ class EmailTextFieldWidget extends StatelessWidget {
|
|||||||
return TextField(
|
return TextField(
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [AutofillHints.email, AutofillHints.username],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => onSubmitted?.call(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class PasswordTextFieldWidget extends StatelessWidget {
|
|||||||
final bool obscurePassword;
|
final bool obscurePassword;
|
||||||
final bool highlightPasswordField;
|
final bool highlightPasswordField;
|
||||||
final VoidCallback onTogglePasswordVisibility;
|
final VoidCallback onTogglePasswordVisibility;
|
||||||
|
final VoidCallback? onSubmitted;
|
||||||
|
|
||||||
const PasswordTextFieldWidget({
|
const PasswordTextFieldWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -14,6 +15,7 @@ class PasswordTextFieldWidget extends StatelessWidget {
|
|||||||
required this.obscurePassword,
|
required this.obscurePassword,
|
||||||
required this.highlightPasswordField,
|
required this.highlightPasswordField,
|
||||||
required this.onTogglePasswordVisibility,
|
required this.onTogglePasswordVisibility,
|
||||||
|
this.onSubmitted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,6 +23,9 @@ class PasswordTextFieldWidget extends StatelessWidget {
|
|||||||
return TextField(
|
return TextField(
|
||||||
controller: passwordController,
|
controller: passwordController,
|
||||||
obscureText: obscurePassword,
|
obscureText: obscurePassword,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => onSubmitted?.call(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Mot de passe',
|
labelText: 'Mot de passe',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -23,39 +23,40 @@ class EventStatusButton extends StatefulWidget {
|
|||||||
|
|
||||||
class _EventStatusButtonState extends State<EventStatusButton> {
|
class _EventStatusButtonState extends State<EventStatusButton> {
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
EventStatus? _optimisticStatus;
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
Future<void> _changeStatus(EventStatus newStatus) async {
|
Future<void> _changeStatus(EventStatus newStatus) async {
|
||||||
if (widget.event.status == newStatus) return;
|
if ((widget.event.status == newStatus) || _loading) return;
|
||||||
setState(() => _loading = true);
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_optimisticStatus = newStatus;
|
||||||
|
});
|
||||||
|
final oldStatus = widget.event.status;
|
||||||
try {
|
try {
|
||||||
// Mettre à jour via l'API
|
|
||||||
await _dataService.updateEvent(widget.event.id, {
|
await _dataService.updateEvent(widget.event.id, {
|
||||||
'status': eventStatusToString(newStatus),
|
'status': eventStatusToString(newStatus),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Récupérer l'événement mis à jour via l'API
|
|
||||||
final result = await _dataService.getEvents();
|
final result = await _dataService.getEvents();
|
||||||
final eventsList = result['events'] as List<dynamic>;
|
final eventsList = result['events'] as List<dynamic>;
|
||||||
final eventData = eventsList.firstWhere(
|
final eventData = eventsList.firstWhere(
|
||||||
(e) => e['id'] == widget.event.id,
|
(e) => e['id'] == widget.event.id,
|
||||||
orElse: () => <String, dynamic>{},
|
orElse: () => <String, dynamic>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (eventData.isNotEmpty) {
|
if (eventData.isNotEmpty) {
|
||||||
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
|
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
|
||||||
|
|
||||||
widget.onSelectEvent(
|
widget.onSelectEvent(
|
||||||
updatedEvent,
|
updatedEvent,
|
||||||
widget.selectedDate ?? updatedEvent.startDateTime,
|
widget.selectedDate ?? updatedEvent.startDateTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
await Provider.of<EventProvider>(context, listen: false)
|
await Provider.of<EventProvider>(context, listen: false)
|
||||||
.updateEvent(updatedEvent);
|
.updateEvent(updatedEvent);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_optimisticStatus = oldStatus;
|
||||||
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Erreur lors du changement de statut: $e')),
|
SnackBar(content: Text('Erreur lors du changement de statut: $e')),
|
||||||
);
|
);
|
||||||
@@ -69,11 +70,22 @@ class _EventStatusButtonState extends State<EventStatusButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final status = widget.event.status;
|
final status = _optimisticStatus ?? widget.event.status;
|
||||||
String texte;
|
String texte;
|
||||||
Color couleurFond;
|
Color couleurFond;
|
||||||
List<Widget> enfants = [];
|
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) {
|
switch (status) {
|
||||||
case EventStatus.waitingForApproval:
|
case EventStatus.waitingForApproval:
|
||||||
texte = "En Attente";
|
texte = "En Attente";
|
||||||
|
|||||||
@@ -4,7 +4,17 @@ import 'package:em2rp/utils/colors.dart';
|
|||||||
|
|
||||||
/// Dialog pour scanner un QR code et récupérer l'ID
|
/// Dialog pour scanner un QR code et récupérer l'ID
|
||||||
class QRCodeScannerDialog extends StatefulWidget {
|
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
|
@override
|
||||||
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
|
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
|
||||||
@@ -45,12 +55,27 @@ class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
|
|||||||
_scannedCode = code;
|
_scannedCode = code;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retourner le code après un court délai pour montrer le feedback visuel
|
if (widget.multiScanMode && widget.onCodeScanned != null) {
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
// Mode multi-scan : appeler le callback et rester ouvert
|
||||||
if (mounted) {
|
widget.onCodeScanned!(code);
|
||||||
Navigator.of(context).pop(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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:em2rp/models/equipment_model.dart';
|
|||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart';
|
import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart';
|
||||||
@@ -37,6 +39,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
||||||
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
||||||
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
Map<String, ContainerModel> _containerCache = {};
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
@@ -64,52 +67,100 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
// Extraire les IDs des équipements assignés
|
// 🔧 FIX: Si on a un eventId, utiliser getEventWithDetails pour charger les données complètes
|
||||||
final equipmentIds = widget.assignedEquipment
|
if (widget.eventId != null && widget.eventId!.isNotEmpty) {
|
||||||
.map((eq) => eq.equipmentId)
|
DebugLog.info('[EventAssignedEquipmentSection] Loading event with details: ${widget.eventId}');
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Charger UNIQUEMENT les équipements nécessaires (optimisé)
|
final result = await _dataService.getEventWithDetails(widget.eventId!);
|
||||||
final equipment = await equipmentProvider.getEquipmentsByIds(equipmentIds);
|
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
|
||||||
|
final containersMap = result['containers'] as Map<String, dynamic>;
|
||||||
|
|
||||||
// Charger UNIQUEMENT les conteneurs nécessaires (optimisé)
|
DebugLog.info('[EventAssignedEquipmentSection] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
|
||||||
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
|
||||||
|
|
||||||
// Créer le cache des équipements
|
// Construire les caches à partir des données reçues
|
||||||
for (var eq in widget.assignedEquipment) {
|
_equipmentCache.clear();
|
||||||
final equipmentItem = equipment.firstWhere(
|
_containerCache.clear();
|
||||||
(e) => e.id == eq.equipmentId,
|
|
||||||
orElse: () => EquipmentModel(
|
|
||||||
id: eq.equipmentId,
|
|
||||||
name: 'Équipement inconnu',
|
|
||||||
category: EquipmentCategory.other,
|
|
||||||
status: EquipmentStatus.available,
|
|
||||||
maintenanceIds: [],
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_equipmentCache[eq.equipmentId] = equipmentItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Créer le cache des conteneurs
|
// Remplir le cache d'équipements
|
||||||
for (var containerId in widget.assignedContainers) {
|
equipmentsMap.forEach((id, data) {
|
||||||
final container = containers.firstWhere(
|
try {
|
||||||
(c) => c.id == containerId,
|
_equipmentCache[id] = EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
||||||
orElse: () => ContainerModel(
|
} catch (e) {
|
||||||
id: containerId,
|
DebugLog.error('[EventAssignedEquipmentSection] Error parsing equipment $id', e);
|
||||||
name: 'Conteneur inconnu',
|
}
|
||||||
type: ContainerType.flightCase,
|
});
|
||||||
status: EquipmentStatus.available,
|
|
||||||
equipmentIds: [],
|
// Remplir le cache de containers
|
||||||
updatedAt: DateTime.now(),
|
containersMap.forEach((id, data) {
|
||||||
createdAt: DateTime.now(),
|
try {
|
||||||
),
|
_containerCache[id] = ContainerModel.fromMap(data as Map<String, dynamic>, id);
|
||||||
);
|
} catch (e) {
|
||||||
_containerCache[containerId] = container;
|
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 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(
|
||||||
|
(e) => e.id == eq.equipmentId,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: eq.equipmentId,
|
||||||
|
name: 'Équipement inconnu',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_equipmentCache[eq.equipmentId] = equipmentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le cache des conteneurs
|
||||||
|
for (var containerId in widget.assignedContainers) {
|
||||||
|
final container = containers.firstWhere(
|
||||||
|
(c) => c.id == containerId,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: containerId,
|
||||||
|
name: 'Conteneur inconnu',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_containerCache[containerId] = container;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Erreur silencieuse - le cache restera vide
|
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
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)');
|
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
|
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
|
||||||
// On enregistre directement la sélection
|
// On enregistre directement la sélection
|
||||||
|
|
||||||
@@ -217,25 +288,47 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
.where((id) => id != containerId)
|
.where((id) => id != containerId)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Retirer les équipements enfants de la liste des équipements assignés
|
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
||||||
final updatedEquipment = widget.assignedEquipment.where((eq) {
|
final updatedEquipment = <EventEquipment>[];
|
||||||
if (container != null) {
|
|
||||||
// Garder uniquement les équipements qui ne sont PAS dans ce conteneur
|
|
||||||
return !container.equipmentIds.contains(eq.equipmentId);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
|
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
|
// Notifier le changement avec les deux listes mises à jour
|
||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_containerCache.remove(containerId);
|
_containerCache.remove(containerId);
|
||||||
// Retirer aussi les équipements enfants du cache
|
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
||||||
if (container != null) {
|
if (container != null) {
|
||||||
|
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
|
||||||
for (var equipmentId in container.equipmentIds) {
|
for (var equipmentId in container.equipmentIds) {
|
||||||
_equipmentCache.remove(equipmentId);
|
if (!remainingEquipmentIds.contains(equipmentId)) {
|
||||||
|
_equipmentCache.remove(equipmentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -444,79 +537,69 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
onPressed: () => _removeContainer(container.id),
|
onPressed: () => _removeContainer(container.id),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
// Afficher les équipements enfants (par composition)
|
// 🔧 FIX: Utiliser directement le cache local au lieu du provider stream
|
||||||
Consumer<EquipmentProvider>(
|
Builder(
|
||||||
builder: (context, provider, child) {
|
builder: (context) {
|
||||||
return StreamBuilder<List<EquipmentModel>>(
|
// Récupérer les équipements enfants depuis le cache local
|
||||||
stream: provider.equipmentStream,
|
final childEquipments = container.equipmentIds
|
||||||
builder: (context, snapshot) {
|
.map((id) => _equipmentCache[id])
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
.where((eq) => eq != null)
|
||||||
return const Padding(
|
.cast<EquipmentModel>()
|
||||||
padding: EdgeInsets.all(16),
|
.toList();
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final allEquipment = snapshot.data ?? [];
|
if (childEquipments.isEmpty) {
|
||||||
final childEquipments = allEquipment
|
return Padding(
|
||||||
.where((eq) => container.equipmentIds.contains(eq.id))
|
padding: const EdgeInsets.all(16),
|
||||||
.toList();
|
child: Text(
|
||||||
|
'Aucun équipement dans ce conteneur (${container.equipmentIds.length} attendu(s))',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (childEquipments.isEmpty) {
|
return Padding(
|
||||||
return const Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
padding: EdgeInsets.all(16),
|
child: Column(
|
||||||
child: Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'Aucun équipement dans ce conteneur',
|
children: [
|
||||||
style: TextStyle(color: Colors.grey),
|
Text(
|
||||||
|
'Contenu (${childEquipments.length} équipement(s))',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Contenu (${childEquipments.length} équipement(s))',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...childEquipments.map((eq) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Icon(
|
|
||||||
Icons.subdirectory_arrow_right,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
eq.category.getIcon(size: 16, color: eq.category.color),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
eq.id,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 8),
|
||||||
},
|
...childEquipments.map((eq) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Icon(
|
||||||
|
Icons.subdirectory_arrow_right,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
eq.category.getIcon(size: 16, color: eq.category.color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
eq.id,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: em2rp
|
name: em2rp
|
||||||
description: "A new Flutter project."
|
description: "L'app de gestion d'événements et matériel par EM2 Events"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ dependencies:
|
|||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
timeago: ^3.6.1
|
timeago: ^3.6.1
|
||||||
|
audioplayers: ^6.1.0
|
||||||
|
|
||||||
path: any
|
path: any
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -5,18 +5,23 @@
|
|||||||
* - Bascule en mode PRODUCTION
|
* - Bascule en mode PRODUCTION
|
||||||
* - Incrémente la version
|
* - Incrémente la version
|
||||||
* - Build l'application Flutter pour le web
|
* - 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
|
* - Rebascule en mode DÉVELOPPEMENT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const { incrementVersion } = require('./increment_version');
|
const { incrementVersion } = require('./increment_version');
|
||||||
const { setProductionMode, setDevelopmentMode } = require('./toggle_env');
|
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');
|
console.log('🚀 Démarrage du déploiement Firebase Hosting...\n');
|
||||||
|
|
||||||
// Étape 0: Basculer en mode production
|
// É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()) {
|
if (!setProductionMode()) {
|
||||||
console.error('❌ Impossible de basculer en mode production');
|
console.error('❌ Impossible de basculer en mode production');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -24,12 +29,12 @@ if (!setProductionMode()) {
|
|||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Étape 1: Incrémenter la version
|
// É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();
|
const newVersion = incrementVersion();
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Étape 2: Build Flutter pour le web
|
// Étape 2: Build Flutter pour le web
|
||||||
console.log('🔨 Étape 2/4: Build Flutter Web');
|
console.log('🔨 Étape 2/5: Build Flutter Web');
|
||||||
try {
|
try {
|
||||||
execSync('flutter build web --release', {
|
execSync('flutter build web --release', {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
@@ -43,9 +48,42 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 3: Déploiement Firebase
|
// Étape 2.5: Vérifier que version.json est bien présent dans build/web
|
||||||
console.log('🌐 Étape 3/4: Déploiement sur Firebase Hosting');
|
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 {
|
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', {
|
execSync('firebase deploy --only hosting', {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
cwd: process.cwd()
|
cwd: process.cwd()
|
||||||
@@ -59,8 +97,48 @@ try {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 4: Rebascule en mode développement
|
// Étape 4: Vérifier que version.json est accessible avec CORS
|
||||||
console.log('\n🔓 Étape 4/4: Retour en mode DÉVELOPPEMENT');
|
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()) {
|
if (!setDevelopmentMode()) {
|
||||||
console.warn('⚠️ Impossible de rebascule en mode développement');
|
console.warn('⚠️ Impossible de rebascule en mode développement');
|
||||||
console.warn('⚠️ Exécutez manuellement: npm run env:dev');
|
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('\n✨ Processus de déploiement terminé!');
|
||||||
|
console.log('📝 Les utilisateurs recevront une notification de mise à jour au prochain chargement.');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.4",
|
"version": "1.0.9",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"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.",
|
"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"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user