feat: Scan et traitement intelligent des QR Codes en préparation d'événement
Cette mise à jour majeure introduit une fonctionnalité de scan et de saisie manuelle de codes QR directement depuis la page de préparation d'un événement. Ce système accélère et fiabilise le processus de validation des équipements et des containers pour chaque étape (préparation, chargement, etc.), tout en ajoutant des retours sonores, haptiques et visuels pour une expérience utilisateur améliorée.
**Fonctionnalités et améliorations principales :**
- **Scan et saisie manuelle en préparation d'événement :**
- Ajout d'un champ de "Saisie manuelle" et d'un bouton "Scanner QR Code" sur la page de préparation (`EventPreparationPage`).
- Le scanner peut fonctionner en mode "multi-scan", permettant de valider plusieurs éléments à la suite sans fermer la caméra.
- Le système gère à la fois les équipements individuels et les containers (qui valident automatiquement tout leur contenu).
- **Logique de traitement intelligente (`QRCodeProcessingService`) :**
- Un nouveau service centralise la logique de traitement des codes.
- Pour les équipements quantitatifs, chaque scan incrémente la quantité jusqu'à atteindre la cible requise pour l'étape en cours.
- Pour les équipements non quantitatifs, le premier scan valide l'élément.
- Les scans multiples d'un élément déjà validé ou dont la quantité est atteinte génèrent une erreur.
- **Ajout dynamique d'équipements :**
- Si un code scanné n'est pas assigné à l'événement, une boîte de dialogue propose de rechercher l'équipement ou le container dans la base de données et de l'ajouter à l'événement en cours.
- **Feedbacks utilisateur :**
- Création d'un `AudioFeedbackService` pour fournir des retours sonores (succès/erreur) et haptiques (vibration) lors de chaque scan.
- Des `Snackbars` claires (vertes pour succès, orange pour erreur) informent l'utilisateur du résultat de chaque action.
- **Optimisation du chargement des données :**
- Nouvel endpoint backend `getEventWithDetails` qui charge un événement et toutes ses dépendances (équipements, containers et leurs enfants) en un seul appel, optimisant drastiquement les temps de chargement des pages de préparation et de modification d'événement.
- Le frontend (`EventPreparationPage`, `EventAssignedEquipmentSection`) utilise ce nouvel endpoint, éliminant les chargements multiples et fiabilisant l'affichage des données.
**Refactorisation et corrections :**
- **Structure du code :**
- La logique de traitement des codes est extraite dans le `QRCodeProcessingService`.
- Création de widgets dédiés (`CodeNotFoundDialog`, `AddEquipmentToEventDialog`) pour gérer les nouveaux flux utilisateurs.
- **Fiabilisation de l'état :**
- Mise à jour optimiste de l'UI lors du changement de statut d'un événement (`EventStatusButton`) pour une meilleure réactivité.
- Correction d'un bug dans la suppression d'un container d'un événement, qui pouvait retirer des équipements partagés avec d'autres containers.
- Correction d'un bug lors de l'ajout d'un container à un événement, qui n'ajoutait pas automatiquement ses équipements enfants.
- **Optimisations des performances UI :**
- Amélioration de la fluidité du défilement infini sur la page de gestion des équipements grâce à `RepaintBoundary` et à une gestion optimisée du chargement.
**Déploiement et version :**
- **Scripts de déploiement :** Ajout d'un script PowerShell (`deploy_hosting.ps1`) et amélioration du script Node.js pour automatiser et fiabiliser les déploiements sur Firebase Hosting.
- **Configuration CORS :** Les en-têtes CORS sont désormais configurés pour `version.json`, assurant le bon fonctionnement du mécanisme de mise à jour de l'application.
- **Version de l'application :** Incrémentée à `1.0.6`.
This commit is contained in:
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
|
||||
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final requestData = {'eventId': eventId, 'data': data};
|
||||
// Correction : fusionner eventId et les champs de data à la racine
|
||||
final requestData = {'eventId': eventId, ...data};
|
||||
await _apiService.call('updateEvent', requestData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||
@@ -248,6 +249,35 @@ class DataService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||
try {
|
||||
print('[DataService] Getting event with details: $eventId');
|
||||
final result = await _apiService.call('getEventWithDetails', {
|
||||
'eventId': eventId,
|
||||
});
|
||||
|
||||
final event = result['event'] as Map<String, dynamic>?;
|
||||
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
|
||||
final containers = result['containers'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
if (event == null) {
|
||||
throw Exception('Event not found');
|
||||
}
|
||||
|
||||
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'equipments': equipments,
|
||||
'containers': containers,
|
||||
};
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting event with details: $e');
|
||||
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||
try {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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/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 {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user