diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index 212dfcd..8c7bf2f 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -32,16 +32,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1768586208886,5a25871ae727f23c4b7258c34108085b8711aa94f6fcab512e0c3ca00a429a64 -index.html,1768586225248,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_service_worker.js,1768586307073,4ea31c373e15f13c2916a12d9d799905af2a79ff7ed0bcceb4334707910c7721 -flutter_bootstrap.js,1768586225225,e95b1b0bd493a475c8eed0e630e413d898f2ceff11cd9b24c6c564bbc2c5f5e9 -assets/FontManifest.json,1768586302952,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.json,1768586302952,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067 -assets/AssetManifest.bin.json,1768586302952,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a -assets/AssetManifest.bin,1768586302952,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768586306083,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/shaders/ink_sparkle.frag,1768586303187,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/fonts/MaterialIcons-Regular.otf,1768586306096,33efc485968dd28630ace587c22d6df359c195821b1114aaa85383e4d5394eac -assets/NOTICES,1768586302954,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c -main.dart.js,1768586301774,9b399ba21ab3247d46cf7dbcd5873aa248636bcd7864a1a0cedf1aae08608f9a +version.json,1768738172901,f258e76dbf34b4a64999cb6d1d983255ad592c590e53f7c4fe380b2bfef82762 +index.html,1768738180374,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_service_worker.js,1768738281912,ad5fcbc95e3f4e31b6c3ae92df0a872c24434ba7ac7448fdd9359f2e3bf7d76c +assets/FontManifest.json,1768738277185,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +flutter_bootstrap.js,1768738180360,f1963883a54097e939404b503b6a9963408fe0187a18d73adb648f6ef0f81578 +assets/AssetManifest.bin.json,1768738277185,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a +assets/AssetManifest.bin,1768738277184,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3 +assets/AssetManifest.json,1768738277185,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067 +assets/shaders/ink_sparkle.frag,1768738277454,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768738280959,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/fonts/MaterialIcons-Regular.otf,1768738280969,9e7c35e587de73a0aee5675d5aef4c6830478af0aa31ad0da76b84a503906b03 +assets/NOTICES,1768738277188,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c +main.dart.js,1768738275891,4ef7f90056f38602de6430a68a479a005268f9d83395ad9b444337c214a3710c diff --git a/em2rp/deploy_hosting.ps1 b/em2rp/deploy_hosting.ps1 new file mode 100644 index 0000000..95c2e8c --- /dev/null +++ b/em2rp/deploy_hosting.ps1 @@ -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 diff --git a/em2rp/firebase.json b/em2rp/firebase.json index ac42fb7..3ce740e 100644 --- a/em2rp/firebase.json +++ b/em2rp/firebase.json @@ -42,6 +42,25 @@ "**/.*", "**/node_modules/**" ], + "headers": [ + { + "source": "version.json", + "headers": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, OPTIONS" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, must-revalidate" + } + ] + } + ], "rewrites": [ { "source": "**", diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 3c5f147..0abdb5d 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -1725,6 +1725,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 // ============================================================================ diff --git a/em2rp/functions/utils/emailConfig.js b/em2rp/functions/utils/emailConfig.js index cf76e9d..f0c0d3b 100644 --- a/em2rp/functions/utils/emailConfig.js +++ b/em2rp/functions/utils/emailConfig.js @@ -29,7 +29,7 @@ const EMAIL_CONFIG = { }, replyTo: 'contact@em2events.fr', // URL de l'application pour les liens - appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app', + appUrl: process.env.APP_URL || 'https://app.em2events.fr', }; module.exports = { diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index db988fe..c8bd4dd 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '1.0.4'; + static const String version = '1.0.6'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 3819404..1319a38 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -91,7 +91,20 @@ class EventFormController extends ChangeNotifier { ]); 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; + + // Reconstruire l'événement avec les données complètes + final completeEvent = EventModel.fromMap(eventData, eventData['id'] as String); + _populateFromEvent(completeEvent); + } catch (e) { + // Si erreur, utiliser l'événement existant (fallback) + print('[EventFormController] Error loading event with details, using existing: $e'); + _populateFromEvent(existingEvent); + } } else { _selectedStatus = EventStatus.waitingForApproval; diff --git a/em2rp/lib/models/qr_code_process_result.dart b/em2rp/lib/models/qr_code_process_result.dart new file mode 100644 index 0000000..c706109 --- /dev/null +++ b/em2rp/lib/models/qr_code_process_result.dart @@ -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 affectedEquipmentIds; + + /// Mises à jour des états de validation (équipements cochés) + final Map? updatedValidationState; + + /// Mises à jour des quantités actuelles + final Map? 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 affectedEquipmentIds, + Map? updatedValidationState, + Map? 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, + ); + } +} diff --git a/em2rp/lib/services/audio_feedback_service.dart b/em2rp/lib/services/audio_feedback_service.dart new file mode 100644 index 0000000..59cc976 --- /dev/null +++ b/em2rp/lib/services/audio_feedback_service.dart @@ -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 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 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 playHapticFeedback() async { + try { + await HapticFeedback.mediumImpact(); + } catch (e) { + DebugLog.error('[AudioFeedbackService] Error playing haptic feedback', e); + } + } + + /// Jouer un feedback complet (son + vibration) + static Future playFullFeedback({bool isSuccess = true}) async { + await playHapticFeedback(); + if (isSuccess) { + await playSuccessBeep(); + } else { + await playErrorBeep(); + } + } +} diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index 964b3da..4cf4318 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -88,7 +88,8 @@ class DataService { /// Met à jour un événement Future updateEvent(String eventId, Map data) async { try { - final requestData = {'eventId': eventId, 'data': data}; + // Correction : fusionner eventId et les champs de data à la racine + final requestData = {'eventId': eventId, ...data}; await _apiService.call('updateEvent', requestData); } catch (e) { throw Exception('Erreur lors de la mise à jour de l\'événement: $e'); @@ -248,6 +249,35 @@ class DataService { } } + /// Récupère un événement avec tous les détails (équipements complets + containers avec enfants) + Future> 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?; + final equipments = result['equipments'] as Map? ?? {}; + final containers = result['containers'] as Map? ?? {}; + + if (event == null) { + throw Exception('Event not found'); + } + + print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); + + return { + 'event': event, + 'equipments': equipments, + 'containers': containers, + }; + } catch (e) { + print('[DataService] Error getting event with details: $e'); + throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e'); + } + } + /// Récupère tous les équipements (avec masquage des prix selon permissions) Future>> getEquipments() async { try { diff --git a/em2rp/lib/services/email_service.dart b/em2rp/lib/services/email_service.dart index f2a135a..826f555 100644 --- a/em2rp/lib/services/email_service.dart +++ b/em2rp/lib/services/email_service.dart @@ -1,6 +1,5 @@ import 'package:cloud_functions/cloud_functions.dart'; import 'package:em2rp/models/alert_model.dart'; -import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/utils/debug_log.dart'; import 'package:firebase_auth/firebase_auth.dart'; diff --git a/em2rp/lib/services/event_preparation_service_extended.dart b/em2rp/lib/services/event_preparation_service_extended.dart deleted file mode 100644 index fbbeaf3..0000000 --- a/em2rp/lib/services/event_preparation_service_extended.dart +++ /dev/null @@ -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 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 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 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 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 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 validateAllUnloadingAndReturn( - String eventId, - Map? 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; - } - } -} - diff --git a/em2rp/lib/services/ics_export_service.dart b/em2rp/lib/services/ics_export_service.dart index b221dee..30269d2 100644 --- a/em2rp/lib/services/ics_export_service.dart +++ b/em2rp/lib/services/ics_export_service.dart @@ -1,7 +1,6 @@ import 'package:em2rp/config/app_version.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:intl/intl.dart'; -import 'package:em2rp/utils/debug_log.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; class IcsExportService { diff --git a/em2rp/lib/services/qr_code_processing_service.dart b/em2rp/lib/services/qr_code_processing_service.dart new file mode 100644 index 0000000..7475cf6 --- /dev/null +++ b/em2rp/lib/services/qr_code_processing_service.dart @@ -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 processCode({ + required String code, + required EventModel event, + required dynamic step, // Changed to dynamic to accept any PreparationStep enum + required Map equipmentCache, + required Map containerCache, + required Map validationState, + required Map 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 _processEquipment({ + required String code, + required EventModel event, + required dynamic step, + required Map equipmentCache, + required Map validationState, + required Map currentQuantities, + }) async { + // Chercher l'équipement dans les équipements assignés + final eventEquipment = event.assignedEquipment + .cast() + .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 validationState, + required Map 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 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 _processContainer({ + required String code, + required EventModel event, + required dynamic step, + required Map equipmentCache, + required Map containerCache, + required Map validationState, + required Map 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 = {}; + final updatedQuantities = {}; + int processedCount = 0; + + for (final childId in container.equipmentIds) { + final childEventEq = event.assignedEquipment + .cast() + .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; + } + } +} diff --git a/em2rp/lib/utils/calendar_utils.dart b/em2rp/lib/utils/calendar_utils.dart index 409377a..3cb2aaf 100644 --- a/em2rp/lib/utils/calendar_utils.dart +++ b/em2rp/lib/utils/calendar_utils.dart @@ -104,8 +104,9 @@ class CalendarUtils { static List getEventsForDay( DateTime day, List events) { - final dayStart = DateTime(day.year, day.month, day.day, 0, 0); - final dayEnd = DateTime(day.year, day.month, day.day, 23, 59, 59); + final nextDay = day.add(const Duration(days: 1)); + final dayStart = DateTime(day.year, day.month, day.day, 2, 0); + final dayEnd = DateTime(nextDay.year, nextDay.month, nextDay.day, 2, 59, 59); return events.where((event) { return !(event.endDateTime.isBefore(dayStart) || diff --git a/em2rp/lib/views/container_management_page.dart b/em2rp/lib/views/container_management_page.dart index 8c62a6f..c79c953 100644 --- a/em2rp/lib/views/container_management_page.dart +++ b/em2rp/lib/views/container_management_page.dart @@ -14,7 +14,6 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/views/widgets/management/management_card.dart'; -import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; import 'package:em2rp/views/widgets/notification_badge.dart'; diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index f6a3c10..d9e56fd 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.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/utils/debug_log.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart'; -import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; import 'package:em2rp/views/widgets/notification_badge.dart'; @@ -58,7 +57,7 @@ class _EquipmentManagementPageState extends State } void _onScroll() { - // Éviter les appels multiples + // Éviter les appels multiples avec un flag simple (sans setState) if (_isLoadingMore) return; final provider = context.read(); @@ -70,16 +69,13 @@ class _EquipmentManagementPageState extends State // Vérifier qu'on peut charger plus if (provider.hasMore && !provider.isLoadingMore) { - setState(() => _isLoadingMore = true); + // ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll + _isLoadingMore = true; provider.loadNextPage().then((_) { - if (mounted) { - setState(() => _isLoadingMore = false); - } + _isLoadingMore = false; }).catchError((error) { - if (mounted) { - setState(() => _isLoadingMore = false); - } + _isLoadingMore = false; DebugLog.error('[EquipmentManagementPage] Error loading next page', error); }); } @@ -502,15 +498,18 @@ class _EquipmentManagementPageState extends State return ListView.builder( controller: _scrollController, itemCount: itemCount, + // ✅ Ajouter une estimation de la hauteur pour améliorer le scroll + // Note : À ajuster selon la hauteur réelle de vos cartes + // itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur + // ✅ Augmenter le cache pour un scroll plus fluide + cacheExtent: 500, // Précharger 500px en plus itemBuilder: (context, index) { // Dernier élément = indicateur de chargement if (index == equipments.length) { - return Center( + return const Center( child: Padding( - padding: const EdgeInsets.all(16.0), - child: provider.isLoadingMore - ? const CircularProgressIndicator() - : const SizedBox.shrink(), + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), ), ); } @@ -525,78 +524,81 @@ class _EquipmentManagementPageState extends State Widget _buildEquipmentCard(EquipmentModel equipment) { final isSelected = isItemSelected(equipment.id); - return Card( - margin: const EdgeInsets.only(bottom: 12), - color: isSelectionMode && isSelected - ? AppColors.rouge.withValues(alpha: 0.1) - : null, - child: ListTile( - leading: isSelectionMode - ? Checkbox( - value: isSelected, - onChanged: (value) => toggleItemSelection(equipment.id), - activeColor: AppColors.rouge, - ) - : CircleAvatar( - backgroundColor: equipment.category.color.withValues(alpha: 0.2), - child: equipment.category.getIcon( - size: 20, - color: equipment.category.color, - ), - ), - title: Row( - children: [ - Expanded( - child: Text( - equipment.id, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - // Afficher le badge de statut calculé dynamiquement - if (equipment.category != EquipmentCategory.consumable && - equipment.category != EquipmentCategory.cable) - EquipmentStatusBadge(equipment: equipment), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}' - .trim() - .isNotEmpty - ? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim() - : 'Marque/Modèle non défini', - style: TextStyle(color: Colors.grey[600], fontSize: 14), - ), - // Afficher la sous-catégorie si elle existe - if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - '📁 ${equipment.subCategory}', - style: TextStyle( - color: Colors.grey[500], - fontSize: 12, - fontStyle: FontStyle.italic, + // ✅ RepaintBoundary pour isoler le repaint de chaque carte + return RepaintBoundary( + key: ValueKey(equipment.id), + child: Card( + margin: const EdgeInsets.only(bottom: 12), + color: isSelectionMode && isSelected + ? AppColors.rouge.withValues(alpha: 0.1) + : null, + child: ListTile( + leading: isSelectionMode + ? Checkbox( + value: isSelected, + onChanged: (value) => toggleItemSelection(equipment.id), + activeColor: AppColors.rouge, + ) + : CircleAvatar( + backgroundColor: equipment.category.color.withValues(alpha: 0.2), + child: equipment.category.getIcon( + size: 20, + color: equipment.category.color, + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), + // Afficher le badge de statut calculé dynamiquement + if (equipment.category != EquipmentCategory.consumable && + equipment.category != EquipmentCategory.cable) + EquipmentStatusBadge(equipment: equipment), ], - // Afficher la quantité disponible pour les consommables/câbles - if (equipment.category == EquipmentCategory.consumable || - equipment.category == EquipmentCategory.cable) ...[ + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ 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 - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Bouton Restock (uniquement pour consommables/câbles avec permission) - if (equipment.category == EquipmentCategory.consumable || + ), + trailing: isSelectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bouton Restock (uniquement pour consommables/câbles avec permission) + if (equipment.category == EquipmentCategory.consumable || equipment.category == EquipmentCategory.cable) PermissionGate( requiredPermissions: const ['manage_equipment'], @@ -640,6 +642,7 @@ class _EquipmentManagementPageState extends State ? () => toggleItemSelection(equipment.id) : () => _viewEquipmentDetails(equipment), ), + ) ); } diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index fc7ea74..e094ff7 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cloud_functions/cloud_functions.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/container_model.dart'; @@ -10,8 +11,14 @@ import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; +import 'package:em2rp/services/qr_code_processing_service.dart'; +import 'package:em2rp/services/audio_feedback_service.dart'; +import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart'; +import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart'; +import 'package:em2rp/views/widgets/event_preparation/code_not_found_dialog.dart'; +import 'package:em2rp/views/widgets/event_preparation/add_equipment_to_event_dialog.dart'; import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart'; import 'package:em2rp/utils/colors.dart'; @@ -40,6 +47,7 @@ class EventPreparationPage extends StatefulWidget { class _EventPreparationPageState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; late final DataService _dataService; + late final QRCodeProcessingService _qrCodeService; Map _equipmentCache = {}; Map _containerCache = {}; @@ -48,8 +56,7 @@ class _EventPreparationPageState extends State with Single // État local des validations (non sauvegardé jusqu'à la validation finale) Map _localValidationState = {}; - - // NOUVEAU : Gestion des quantités par étape + // Gestion des quantités par étape Map _quantitiesAtPreparation = {}; Map _quantitiesAtLoading = {}; Map _quantitiesAtUnloading = {}; @@ -63,6 +70,10 @@ class _EventPreparationPageState extends State with Single // Stockage de l'événement actuel late EventModel _currentEvent; + // 🆕 Pour la saisie manuelle de codes + final TextEditingController _manualCodeController = TextEditingController(); + final FocusNode _manualCodeFocusNode = FocusNode(); + // Détermine l'étape actuelle selon le statut de l'événement PreparationStep get _currentStep { final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted; @@ -100,6 +111,7 @@ class _EventPreparationPageState extends State with Single super.initState(); _currentEvent = widget.initialEvent; _dataService = DataService(FirebaseFunctionsApiService()); + _qrCodeService = QRCodeProcessingService(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), @@ -140,6 +152,8 @@ class _EventPreparationPageState extends State with Single @override void dispose() { _animationController.dispose(); + _manualCodeController.dispose(); + _manualCodeFocusNode.dispose(); super.dispose(); } @@ -147,20 +161,46 @@ class _EventPreparationPageState extends State with Single setState(() => _isLoading = true); try { - final equipmentProvider = context.read(); - final containerProvider = context.read(); + // 🔧 FIX: Utiliser getEventWithDetails pour charger toutes les données d'un coup + DebugLog.info('[EventPreparationPage] Loading event with details: ${_currentEvent.id}'); - // S'assurer que les équipements sont chargés - await equipmentProvider.ensureLoaded(); - await containerProvider.ensureLoaded(); + final result = await _dataService.getEventWithDetails(_currentEvent.id); + final equipmentsMap = result['equipments'] as Map; + final containersMap = result['containers'] as Map; - final equipment = await equipmentProvider.equipmentStream.first; - final containers = await containerProvider.containersStream.first; + DebugLog.info('[EventPreparationPage] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details'); + // Remplir les caches + _equipmentCache.clear(); + _containerCache.clear(); + + // Remplir le cache d'équipements + equipmentsMap.forEach((id, data) { + try { + final equipment = EquipmentModel.fromMap(data as Map, 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, id); + _containerCache[id] = container; + } catch (e) { + DebugLog.error('[EventPreparationPage] Error parsing container $id', e); + } + }); + + // Initialiser les états de validation et quantités pour chaque équipement assigné for (var eq in _currentEvent.assignedEquipment) { - final equipmentItem = equipment.firstWhere( - (e) => e.id == eq.equipmentId, - orElse: () => EquipmentModel( + final equipmentItem = _equipmentCache[eq.equipmentId]; + + // S'assurer que l'équipement est dans le cache (même si inconnu) + if (equipmentItem == null) { + _equipmentCache[eq.equipmentId] = EquipmentModel( id: eq.equipmentId, name: 'Équipement inconnu', category: EquipmentCategory.other, @@ -168,9 +208,8 @@ class _EventPreparationPageState extends State with Single maintenanceIds: [], createdAt: DateTime.now(), updatedAt: DateTime.now(), - ), - ); - _equipmentCache[eq.equipmentId] = equipmentItem; + ); + } // Initialiser l'état local de validation depuis l'événement switch (_currentStep) { @@ -190,15 +229,15 @@ class _EventPreparationPageState extends State with Single if ((_currentStep == PreparationStep.return_ || _currentStep == PreparationStep.unloadingReturn) && - equipmentItem.hasQuantity) { + (equipmentItem?.hasQuantity ?? false)) { _returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity; } } + // S'assurer que les containers assignés sont dans le cache (même si inconnus) for (var containerId in _currentEvent.assignedContainers) { - final container = containers.firstWhere( - (c) => c.id == containerId, - orElse: () => ContainerModel( + if (!_containerCache.containsKey(containerId)) { + _containerCache[containerId] = ContainerModel( id: containerId, name: 'Conteneur inconnu', type: ContainerType.flightCase, @@ -206,9 +245,8 @@ class _EventPreparationPageState extends State with Single equipmentIds: [], updatedAt: DateTime.now(), createdAt: DateTime.now(), - ), - ); - _containerCache[containerId] = container; + ); + } } } catch (e) { DebugLog.error('[EventPreparationPage] Error', e); @@ -564,6 +602,311 @@ class _EventPreparationPageState extends State with Single } } + // ======================================================================== + // 🆕 NOUVELLES MÉTHODES POUR LE SCAN QR ET LA SAISIE MANUELLE + // ======================================================================== + + /// Ouvrir le scanner QR en mode multi-scan + Future _openQRScanner() async { + await showDialog( + context: context, + builder: (context) => QRCodeScannerDialog( + multiScanMode: true, + onCodeScanned: _handleScannedCode, + ), + ); + } + + /// Traiter un code (scanné ou saisi manuellement) + Future _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 _handleCodeNotFoundInEvent(String code) async { + // Afficher le dialog de confirmation + final shouldSearch = await showDialog( + 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 _addEquipmentToEvent(String equipmentId) async { + // Rechercher l'équipement dans la base de données + final equipmentProvider = context.read(); + await equipmentProvider.ensureLoaded(); + + // Chercher d'abord dans le cache + EquipmentModel? equipment = equipmentProvider.allEquipment + .cast() + .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.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 _addContainerToEvent(String containerId) async { + // Rechercher le container dans la base de données + final containerProvider = context.read(); + 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.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 _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 _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 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 _confirm() async { // Vérifier s'il y a des équipements manquants (non cochés localement) final missingEquipmentIds = _currentEvent.assignedEquipment @@ -842,6 +1185,50 @@ class _EventPreparationPageState extends State with Single contentPadding: EdgeInsets.zero, ), + // 🆕 Champ de saisie manuelle de code + const SizedBox(height: 16), + TextField( + controller: _manualCodeController, + focusNode: _manualCodeFocusNode, + decoration: InputDecoration( + labelText: 'Saisie manuelle d\'un code', + hintText: 'Entrez un ID d\'équipement ou container', + prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce), + suffixIcon: _manualCodeController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _manualCodeController.clear(); + setState(() {}); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onSubmitted: _handleManualCodeEntry, + onChanged: (value) => setState(() {}), + textInputAction: TextInputAction.done, + ), + + // 🆕 Bouton Scanner QR Code + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _openQRScanner, + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scanner QR Code'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[700], + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + const SizedBox(height: 8), ElevatedButton.icon( onPressed: allValidated ? null : _validateAllAndConfirm, diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_status_button.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_status_button.dart index 49fe869..75b96c1 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_status_button.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_status_button.dart @@ -23,39 +23,40 @@ class EventStatusButton extends StatefulWidget { class _EventStatusButtonState extends State { bool _loading = false; + EventStatus? _optimisticStatus; final DataService _dataService = DataService(FirebaseFunctionsApiService()); Future _changeStatus(EventStatus newStatus) async { - if (widget.event.status == newStatus) return; - setState(() => _loading = true); - + if ((widget.event.status == newStatus) || _loading) return; + setState(() { + _loading = true; + _optimisticStatus = newStatus; + }); + final oldStatus = widget.event.status; try { - // Mettre à jour via l'API await _dataService.updateEvent(widget.event.id, { 'status': eventStatusToString(newStatus), }); - - // Récupérer l'événement mis à jour via l'API final result = await _dataService.getEvents(); final eventsList = result['events'] as List; final eventData = eventsList.firstWhere( (e) => e['id'] == widget.event.id, orElse: () => {}, ); - if (eventData.isNotEmpty) { final updatedEvent = EventModel.fromMap(eventData, widget.event.id); - widget.onSelectEvent( updatedEvent, widget.selectedDate ?? updatedEvent.startDateTime, ); - await Provider.of(context, listen: false) .updateEvent(updatedEvent); } } catch (e) { if (mounted) { + setState(() { + _optimisticStatus = oldStatus; + }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Erreur lors du changement de statut: $e')), ); @@ -69,11 +70,22 @@ class _EventStatusButtonState extends State { @override Widget build(BuildContext context) { - final status = widget.event.status; + final status = _optimisticStatus ?? widget.event.status; String texte; Color couleurFond; List enfants = []; + if (_loading) { + return Container( + padding: const EdgeInsets.all(8), + child: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + switch (status) { case EventStatus.waitingForApproval: texte = "En Attente"; diff --git a/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart index 6dba6e0..0f1eaf9 100644 --- a/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart +++ b/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart @@ -4,7 +4,17 @@ import 'package:em2rp/utils/colors.dart'; /// Dialog pour scanner un QR code et récupérer l'ID class QRCodeScannerDialog extends StatefulWidget { - const QRCodeScannerDialog({super.key}); + /// Callback appelé quand un code est scanné (mode multi-scan) + final Function(String code)? onCodeScanned; + + /// Active le mode scan continu (ne ferme pas automatiquement) + final bool multiScanMode; + + const QRCodeScannerDialog({ + super.key, + this.onCodeScanned, + this.multiScanMode = false, + }); @override State createState() => _QRCodeScannerDialogState(); @@ -45,12 +55,27 @@ class _QRCodeScannerDialogState extends State { _scannedCode = code; }); - // Retourner le code après un court délai pour montrer le feedback visuel - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - Navigator.of(context).pop(code); - } - }); + if (widget.multiScanMode && widget.onCodeScanned != null) { + // Mode multi-scan : appeler le callback et rester ouvert + widget.onCodeScanned!(code); + + // Réinitialiser après un délai pour permettre un nouveau scan + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted) { + setState(() { + _isProcessing = false; + _scannedCode = null; + }); + } + }); + } else { + // Mode simple : retourner le code et fermer + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + Navigator.of(context).pop(code); + } + }); + } } } diff --git a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart index edfa206..0e39857 100644 --- a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart @@ -6,6 +6,8 @@ import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/services/data_service.dart'; +import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart'; import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart'; @@ -37,6 +39,7 @@ class EventAssignedEquipmentSection extends StatefulWidget { class _EventAssignedEquipmentSectionState extends State { bool get _canAddMaterial => widget.startDate != null && widget.endDate != null; final EventAvailabilityService _availabilityService = EventAvailabilityService(); + final DataService _dataService = DataService(FirebaseFunctionsApiService()); Map _equipmentCache = {}; Map _containerCache = {}; bool _isLoading = true; @@ -64,52 +67,100 @@ class _EventAssignedEquipmentSectionState extends State(); final containerProvider = context.read(); - // Extraire les IDs des équipements assignés - final equipmentIds = widget.assignedEquipment - .map((eq) => eq.equipmentId) - .toList(); + // 🔧 FIX: Si on a un eventId, utiliser getEventWithDetails pour charger les données complètes + if (widget.eventId != null && widget.eventId!.isNotEmpty) { + DebugLog.info('[EventAssignedEquipmentSection] Loading event with details: ${widget.eventId}'); - // Charger UNIQUEMENT les équipements nécessaires (optimisé) - final equipment = await equipmentProvider.getEquipmentsByIds(equipmentIds); + final result = await _dataService.getEventWithDetails(widget.eventId!); + final equipmentsMap = result['equipments'] as Map; + final containersMap = result['containers'] as Map; - // Charger UNIQUEMENT les conteneurs nécessaires (optimisé) - final containers = await containerProvider.getContainersByIds(widget.assignedContainers); + DebugLog.info('[EventAssignedEquipmentSection] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details'); - // 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; - } + // Construire les caches à partir des données reçues + _equipmentCache.clear(); + _containerCache.clear(); - // 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; + // Remplir le cache d'équipements + equipmentsMap.forEach((id, data) { + try { + _equipmentCache[id] = EquipmentModel.fromMap(data as Map, id); + } catch (e) { + DebugLog.error('[EventAssignedEquipmentSection] Error parsing equipment $id', e); + } + }); + + // Remplir le cache de containers + containersMap.forEach((id, data) { + try { + _containerCache[id] = ContainerModel.fromMap(data as Map, id); + } catch (e) { + DebugLog.error('[EventAssignedEquipmentSection] Error parsing container $id', e); + } + }); + + DebugLog.info('[EventAssignedEquipmentSection] Caches populated: ${_equipmentCache.length} equipments, ${_containerCache.length} containers'); + + } else { + // Mode création d'événement : charger via les providers + DebugLog.info('[EventAssignedEquipmentSection] Loading via providers (creation mode)'); + + // Extraire les IDs des équipements assignés + final equipmentIds = widget.assignedEquipment + .map((eq) => eq.equipmentId) + .toList(); + + // Charger les conteneurs + final containers = await containerProvider.getContainersByIds(widget.assignedContainers); + + // Extraire les IDs des équipements enfants des containers + final childEquipmentIds = []; + for (var container in containers) { + childEquipmentIds.addAll(container.equipmentIds); + } + + // Combiner les IDs des équipements assignés + enfants des containers + final allEquipmentIds = {...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) { - // Erreur silencieuse - le cache restera vide + DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e); } finally { setState(() => _isLoading = false); } @@ -156,6 +207,26 @@ class _EventAssignedEquipmentSectionState extends State(); + final containers = await containerProvider.getContainersByIds(newContainers); + + for (var container in containers) { + for (var childEquipmentId in container.equipmentIds) { + // Vérifier si l'équipement enfant n'est pas déjà dans la liste + final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId); + if (!existsInNew) { + newEquipment.add(EventEquipment( + equipmentId: childEquipmentId, + quantity: 1, + )); + DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}'); + } + } + } + } + // ✅ Pas de vérification de conflits : déjà fait dans le pop-up // On enregistre directement la sélection @@ -217,25 +288,47 @@ class _EventAssignedEquipmentSectionState extends State id != containerId) .toList(); - // Retirer les équipements enfants de la liste des équipements assignés - final updatedEquipment = widget.assignedEquipment.where((eq) { - if (container != null) { - // Garder uniquement les équipements qui ne sont PAS dans ce conteneur - return !container.equipmentIds.contains(eq.equipmentId); - } - return true; - }).toList(); + // 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container + final updatedEquipment = []; + if (container != null) { + // Collecter les IDs d'équipements dans les autres containers + final Set equipmentIdsInOtherContainers = {}; + for (var otherContainerId in updatedContainers) { + final otherContainer = _containerCache[otherContainerId]; + if (otherContainer != null) { + equipmentIdsInOtherContainers.addAll(otherContainer.equipmentIds); + } + } + + // Garder les équipements qui : + // 1. Ne sont PAS dans le container supprimé OU + // 2. Sont dans le container supprimé MAIS aussi dans un autre container + for (var eq in widget.assignedEquipment) { + final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId); + final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId); + + if (!isInRemovedContainer || isInOtherContainer) { + updatedEquipment.add(eq); + } + } + } else { + // Si le container n'est pas dans le cache, garder tous les équipements + updatedEquipment.addAll(widget.assignedEquipment); + } // Notifier le changement avec les deux listes mises à jour widget.onChanged(updatedEquipment, updatedContainers); setState(() { _containerCache.remove(containerId); - // Retirer aussi les équipements enfants du cache + // Nettoyer le cache uniquement pour les équipements effectivement supprimés if (container != null) { + final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet(); for (var equipmentId in container.equipmentIds) { - _equipmentCache.remove(equipmentId); + if (!remainingEquipmentIds.contains(equipmentId)) { + _equipmentCache.remove(equipmentId); + } } } }); @@ -444,79 +537,69 @@ class _EventAssignedEquipmentSectionState extends State _removeContainer(container.id), ), children: [ - // Afficher les équipements enfants (par composition) - Consumer( - builder: (context, provider, child) { - return StreamBuilder>( - stream: provider.equipmentStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ); - } + // 🔧 FIX: Utiliser directement le cache local au lieu du provider stream + Builder( + builder: (context) { + // Récupérer les équipements enfants depuis le cache local + final childEquipments = container.equipmentIds + .map((id) => _equipmentCache[id]) + .where((eq) => eq != null) + .cast() + .toList(); - final allEquipment = snapshot.data ?? []; - final childEquipments = allEquipment - .where((eq) => container.equipmentIds.contains(eq.id)) - .toList(); + if (childEquipments.isEmpty) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Aucun équipement dans ce conteneur (${container.equipmentIds.length} attendu(s))', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ); + } - if (childEquipments.isEmpty) { - return const Padding( - padding: EdgeInsets.all(16), - child: Text( - 'Aucun équipement dans ce conteneur', - style: TextStyle(color: Colors.grey), + 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, ), - ); - } - - 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(), + ], + ), ); }, ), diff --git a/em2rp/lib/views/widgets/event_preparation/add_equipment_to_event_dialog.dart b/em2rp/lib/views/widgets/event_preparation/add_equipment_to_event_dialog.dart new file mode 100644 index 0000000..963f3e5 --- /dev/null +++ b/em2rp/lib/views/widgets/event_preparation/add_equipment_to_event_dialog.dart @@ -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]), + ), + ], + ], + ); + } + } +} diff --git a/em2rp/lib/views/widgets/event_preparation/code_not_found_dialog.dart b/em2rp/lib/views/widgets/event_preparation/code_not_found_dialog.dart new file mode 100644 index 0000000..332c830 --- /dev/null +++ b/em2rp/lib/views/widgets/event_preparation/code_not_found_dialog.dart @@ -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'), + ), + ], + ); + } +} diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index fd0d782..47fb909 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: flutter_localizations: sdk: flutter timeago: ^3.6.1 + audioplayers: ^6.1.0 path: any dev_dependencies: diff --git a/em2rp/scripts/deploy.js b/em2rp/scripts/deploy.js index 51415d2..c44bdc2 100644 --- a/em2rp/scripts/deploy.js +++ b/em2rp/scripts/deploy.js @@ -5,18 +5,23 @@ * - Bascule en mode PRODUCTION * - Incrémente la version * - Build l'application Flutter pour le web - * - Déploie sur Firebase Hosting + * - Vérifie que version.json est bien présent + * - Déploie sur Firebase Hosting (avec en-têtes CORS pour version.json) + * - Vérifie que version.json est accessible avec CORS * - Rebascule en mode DÉVELOPPEMENT */ const { execSync } = require('child_process'); const { incrementVersion } = require('./increment_version'); const { setProductionMode, setDevelopmentMode } = require('./toggle_env'); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); console.log('🚀 Démarrage du déploiement Firebase Hosting...\n'); // Étape 0: Basculer en mode production -console.log('🔒 Étape 0/4: Basculement en mode PRODUCTION'); +console.log('🔒 Étape 0/5: Basculement en mode PRODUCTION'); if (!setProductionMode()) { console.error('❌ Impossible de basculer en mode production'); process.exit(1); @@ -24,12 +29,12 @@ if (!setProductionMode()) { console.log(''); // Étape 1: Incrémenter la version -console.log('📝 Étape 1/4: Incrémentation de la version'); +console.log('📝 Étape 1/5: Incrémentation de la version'); const newVersion = incrementVersion(); console.log(''); // Étape 2: Build Flutter pour le web -console.log('🔨 Étape 2/4: Build Flutter Web'); +console.log('🔨 Étape 2/5: Build Flutter Web'); try { execSync('flutter build web --release', { stdio: 'inherit', @@ -43,9 +48,42 @@ try { process.exit(1); } -// Étape 3: Déploiement Firebase -console.log('🌐 Étape 3/4: Déploiement sur Firebase Hosting'); +// Étape 2.5: Vérifier que version.json est bien présent dans build/web +console.log('🔍 Étape 2.5/5: Vérification de version.json'); +const versionJsonPath = path.join(process.cwd(), 'build', 'web', 'version.json'); +if (!fs.existsSync(versionJsonPath)) { + console.warn('⚠️ version.json n\'a pas été copié dans build/web/'); + + // Copier manuellement depuis web/version.json + const sourceVersionJsonPath = path.join(process.cwd(), 'web', 'version.json'); + if (fs.existsSync(sourceVersionJsonPath)) { + console.log(' → Copie de web/version.json vers build/web/...'); + fs.copyFileSync(sourceVersionJsonPath, versionJsonPath); + console.log('✅ Fichier version.json copié avec succès'); + } else { + console.error('❌ Impossible de trouver web/version.json'); + setDevelopmentMode(); + process.exit(1); + } +} else { + console.log('✅ version.json est présent dans build/web/'); +} + +// Afficher la version qui va être déployée try { + const versionContent = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8')); + console.log(` 📦 Version: ${versionContent.version}`); + console.log(` 🔒 Force update: ${versionContent.forceUpdate}`); +} catch (error) { + console.warn('⚠️ Impossible de lire version.json'); +} +console.log(''); + +// Étape 3: Déploiement Firebase +console.log('🌐 Étape 3/5: Déploiement sur Firebase Hosting'); +console.log(' ℹ️ Les en-têtes CORS pour version.json seront appliqués automatiquement'); +try { + execSync('firebase deploy --only hosting', { stdio: 'inherit', cwd: process.cwd() @@ -59,8 +97,48 @@ try { process.exit(1); } -// Étape 4: Rebascule en mode développement -console.log('\n🔓 Étape 4/4: Retour en mode DÉVELOPPEMENT'); +// Étape 4: Vérifier que version.json est accessible avec CORS +console.log('\n🔍 Étape 4/5: Vérification de l\'accès à version.json'); +setTimeout(() => { + https.get('https://app.em2events.fr/version.json', { + headers: { + 'Origin': 'http://localhost' + } + }, (res) => { + if (res.statusCode === 200) { + console.log('✅ version.json est accessible (statut 200)'); + + // Vérifier les en-têtes CORS + const corsHeader = res.headers['access-control-allow-origin']; + if (corsHeader) { + console.log(`✅ En-têtes CORS configurés: ${corsHeader}`); + } else { + console.warn('⚠️ En-têtes CORS non détectés (peuvent prendre quelques minutes pour se propager)'); + } + + // Lire et afficher la version déployée + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + try { + const deployed = JSON.parse(body); + console.log(`📦 Version déployée: ${deployed.version}`); + } catch (e) { + // Ignore + } + }); + } else { + console.warn(`⚠️ Statut HTTP: ${res.statusCode}`); + } + }).on('error', (err) => { + console.warn('⚠️ Impossible de vérifier l\'accès à version.json'); + console.warn(` ${err.message}`); + console.warn(' Le fichier peut prendre quelques minutes pour être accessible'); + }); +}, 2000); // Attendre 2 secondes pour que le déploiement se propage + +// Étape 5: Rebascule en mode développement +console.log('\n🔓 Étape 5/5: Retour en mode DÉVELOPPEMENT'); if (!setDevelopmentMode()) { console.warn('⚠️ Impossible de rebascule en mode développement'); console.warn('⚠️ Exécutez manuellement: npm run env:dev'); @@ -69,3 +147,4 @@ if (!setDevelopmentMode()) { } console.log('\n✨ Processus de déploiement terminé!'); +console.log('📝 Les utilisateurs recevront une notification de mise à jour au prochain chargement.'); diff --git a/em2rp/web/version.json b/em2rp/web/version.json index 1b5b1ac..27cefb2 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -1,7 +1,7 @@ { - "version": "1.0.4", + "version": "1.0.6", "updateUrl": "https://app.em2events.fr", "forceUpdate": true, "releaseNotes": "Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :\r\n\r\n* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.\r\n* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.", - "timestamp": "2026-01-16T17:56:48.878Z" + "timestamp": "2026-01-18T12:09:32.899Z" } \ No newline at end of file