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:
ElPoyo
2026-01-20 14:33:37 +01:00
parent a182f1b922
commit a7e5f91a21
26 changed files with 1712 additions and 383 deletions

View File

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