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

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

View File

@@ -4,7 +4,17 @@ import 'package:em2rp/utils/colors.dart';
/// Dialog pour scanner un QR code et récupérer l'ID
class QRCodeScannerDialog extends StatefulWidget {
const QRCodeScannerDialog({super.key});
/// Callback appelé quand un code est scanné (mode multi-scan)
final Function(String code)? onCodeScanned;
/// Active le mode scan continu (ne ferme pas automatiquement)
final bool multiScanMode;
const QRCodeScannerDialog({
super.key,
this.onCodeScanned,
this.multiScanMode = false,
});
@override
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
@@ -45,12 +55,27 @@ class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
_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);
}
});
}
}
}

View File

@@ -6,6 +6,8 @@ import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart';
@@ -37,6 +39,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
final EventAvailabilityService _availabilityService = EventAvailabilityService();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {};
bool _isLoading = true;
@@ -64,52 +67,100 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
// 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<String, dynamic>;
final containersMap = result['containers'] as Map<String, dynamic>;
// 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<String, dynamic>, id);
} catch (e) {
DebugLog.error('[EventAssignedEquipmentSection] Error parsing equipment $id', e);
}
});
// Remplir le cache de containers
containersMap.forEach((id, data) {
try {
_containerCache[id] = ContainerModel.fromMap(data as Map<String, dynamic>, id);
} catch (e) {
DebugLog.error('[EventAssignedEquipmentSection] Error parsing container $id', e);
}
});
DebugLog.info('[EventAssignedEquipmentSection] Caches populated: ${_equipmentCache.length} equipments, ${_containerCache.length} containers');
} else {
// Mode création d'événement : charger via les providers
DebugLog.info('[EventAssignedEquipmentSection] Loading via providers (creation mode)');
// Extraire les IDs des équipements assignés
final equipmentIds = widget.assignedEquipment
.map((eq) => eq.equipmentId)
.toList();
// Charger les conteneurs
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
// Extraire les IDs des équipements enfants des containers
final childEquipmentIds = <String>[];
for (var container in containers) {
childEquipmentIds.addAll(container.equipmentIds);
}
// Combiner les IDs des équipements assignés + enfants des containers
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
// Charger TOUS les équipements nécessaires
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
// Créer le cache des équipements
for (var eq in widget.assignedEquipment) {
final equipmentItem = equipment.firstWhere(
(e) => e.id == eq.equipmentId,
orElse: () => EquipmentModel(
id: eq.equipmentId,
name: 'Équipement inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
_equipmentCache[eq.equipmentId] = equipmentItem;
}
// Créer le cache des conteneurs
for (var containerId in widget.assignedContainers) {
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Conteneur inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
updatedAt: DateTime.now(),
createdAt: DateTime.now(),
),
);
_containerCache[containerId] = container;
}
}
} catch (e) {
// Erreur silencieuse - le cache restera vide
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
} finally {
setState(() => _isLoading = false);
}
@@ -156,6 +207,26 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
if (newContainers.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(newContainers);
for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!existsInNew) {
newEquipment.add(EventEquipment(
equipmentId: childEquipmentId,
quantity: 1,
));
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
}
}
}
}
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
// On enregistre directement la sélection
@@ -217,25 +288,47 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
.where((id) => id != containerId)
.toList();
// Retirer les équipements enfants de la liste des équipements assignés
final updatedEquipment = widget.assignedEquipment.where((eq) {
if (container != null) {
// Garder uniquement les équipements qui ne sont PAS dans ce conteneur
return !container.equipmentIds.contains(eq.equipmentId);
}
return true;
}).toList();
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
final updatedEquipment = <EventEquipment>[];
if (container != null) {
// Collecter les IDs d'équipements dans les autres containers
final Set<String> equipmentIdsInOtherContainers = {};
for (var otherContainerId in updatedContainers) {
final otherContainer = _containerCache[otherContainerId];
if (otherContainer != null) {
equipmentIdsInOtherContainers.addAll(otherContainer.equipmentIds);
}
}
// Garder les équipements qui :
// 1. Ne sont PAS dans le container supprimé OU
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
for (var eq in widget.assignedEquipment) {
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
if (!isInRemovedContainer || isInOtherContainer) {
updatedEquipment.add(eq);
}
}
} else {
// Si le container n'est pas dans le cache, garder tous les équipements
updatedEquipment.addAll(widget.assignedEquipment);
}
// Notifier le changement avec les deux listes mises à jour
widget.onChanged(updatedEquipment, updatedContainers);
setState(() {
_containerCache.remove(containerId);
// Retirer aussi les équipements enfants du cache
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
if (container != null) {
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
for (var equipmentId in container.equipmentIds) {
_equipmentCache.remove(equipmentId);
if (!remainingEquipmentIds.contains(equipmentId)) {
_equipmentCache.remove(equipmentId);
}
}
}
});
@@ -444,79 +537,69 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
onPressed: () => _removeContainer(container.id),
),
children: [
// Afficher les équipements enfants (par composition)
Consumer<EquipmentProvider>(
builder: (context, provider, child) {
return StreamBuilder<List<EquipmentModel>>(
stream: provider.equipmentStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
// 🔧 FIX: Utiliser directement le cache local au lieu du provider stream
Builder(
builder: (context) {
// Récupérer les équipements enfants depuis le cache local
final childEquipments = container.equipmentIds
.map((id) => _equipmentCache[id])
.where((eq) => eq != null)
.cast<EquipmentModel>()
.toList();
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(),
],
),
);
},
),

View File

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

View File

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