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:
@@ -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<EquipmentManagementPage>
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Éviter les appels multiples
|
||||
// Éviter les appels multiples avec un flag simple (sans setState)
|
||||
if (_isLoadingMore) return;
|
||||
|
||||
final provider = context.read<EquipmentProvider>();
|
||||
@@ -70,16 +69,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
|
||||
// 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<EquipmentManagementPage>
|
||||
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<EquipmentManagementPage>
|
||||
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<EquipmentManagementPage>
|
||||
? () => toggleItemSelection(equipment.id)
|
||||
: () => _viewEquipmentDetails(equipment),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user