From 06f394b728ea5742b506b5e1f89c5957c55b24c7 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Fri, 16 Jan 2026 00:35:10 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Ajout=20du=20scan=20de=20QR=20Code=20po?= =?UTF-8?q?ur=20retrouver=20=C3=A9quipements=20et=20conteneurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette mise à jour introduit une fonctionnalité de scan de QR codes directement depuis l'application, permettant aux utilisateurs de retrouver et d'accéder rapidement à la page de détail d'un équipement ou d'un conteneur. **Features :** - **Scan de QR Code :** - Un nouveau bouton "Scanner QR Code" est ajouté sur les pages de gestion des équipements et des conteneurs. - L'appui sur ce bouton ouvre une nouvelle boîte de dialogue (`QRCodeScannerDialog`) utilisant la caméra de l'appareil pour scanner un QR code. - Le scanner affiche un overlay visuel clair avec un cadre de détection et fournit un retour visuel (icône de validation) lorsqu'un code est détecté avec succès. - **Recherche et Redirection Intelligente :** - Une fois un QR code scanné, l'application recherche l'ID correspondant d'abord dans les équipements, puis dans les conteneurs. - Si une correspondance est trouvée, l'utilisateur est automatiquement redirigé vers la page de détail de l'élément correspondant (`EquipmentDetailPage` ou `ContainerDetailPage`). - Un message informe l'utilisateur si aucun élément ne correspond à l'ID scanné. **Changements Techniques :** - **Dépendance :** Ajout de la bibliothèque `mobile_scanner` pour gérer la fonctionnalité de scan. - **Nouveau Widget :** Création du widget `QRCodeScannerDialog`, un dialogue réutilisable et stylisé pour le scan, incluant un overlay personnalisé (`_ScannerOverlayPainter`). - **Intégration UI :** - Le `ManagementSearchBar` accepte désormais une liste de `additionalActions` pour permettre l'ajout de boutons personnalisés comme celui du scanner. - Ajout du bouton de scan sur les écrans `EquipmentManagementPage` et `ContainerManagementPage`, à la fois en version bureau (icône) et mobile (bouton plein). - **Logique de Recherche :** Implémentation de la fonction `_scanQRCode` dans les deux pages de gestion pour orchestrer l'ouverture du scanner, la recherche dans les `EquipmentProvider` et `ContainerProvider`, et la navigation. --- .../lib/views/container_management_page.dart | 138 ++++++++- .../lib/views/equipment_management_page.dart | 151 +++++++++ .../common/qr_code_scanner_dialog.dart | 289 ++++++++++++++++++ .../management/management_search_bar.dart | 3 + em2rp/pubspec.yaml | 1 + 5 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart diff --git a/em2rp/lib/views/container_management_page.dart b/em2rp/lib/views/container_management_page.dart index ddca842..61b6825 100644 --- a/em2rp/lib/views/container_management_page.dart +++ b/em2rp/lib/views/container_management_page.dart @@ -5,14 +5,19 @@ import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/views/equipment_detail_page.dart'; +import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +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_search_bar.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'; class ContainerManagementPage extends StatefulWidget { const ContainerManagementPage({super.key}); @@ -177,6 +182,14 @@ class _ContainerManagementPageState extends State }, onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode, showSelectionModeButton: !isSelectionMode, + additionalActions: [ + const SizedBox(width: 12), + IconButton( + icon: const Icon(Icons.qr_code_scanner, color: AppColors.rouge), + tooltip: 'Scanner un QR Code', + onPressed: _scanQRCode, + ), + ], ); } @@ -417,7 +430,7 @@ class _ContainerManagementPageState extends State _editContainer(container); break; case 'qr': - // Non utilisé - les QR codes multiples sont générés via _generateQRCodesForSelected + _showQRCode(container); break; case 'delete': _deleteContainer(container); @@ -425,6 +438,14 @@ class _ContainerManagementPageState extends State } } + /// Afficher le QR code d'un conteneur + void _showQRCode(ContainerModel container) { + showDialog( + context: context, + builder: (context) => QRCodeDialog.forContainer(container), + ); + } + void _navigateToForm(BuildContext context) async { final result = await Navigator.pushNamed(context, '/container_form'); if (result == true) { @@ -583,5 +604,120 @@ class _ContainerManagementPageState extends State } } } + + /// Scanner un QR Code et ouvrir la vue de détail correspondante + Future _scanQRCode() async { + try { + // Ouvrir le scanner + final scannedCode = await showDialog( + context: context, + builder: (context) => const QRCodeScannerDialog(), + ); + + if (scannedCode == null || scannedCode.isEmpty) { + return; // L'utilisateur a annulé + } + + if (!mounted) return; + + // Afficher un indicateur de chargement + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(color: AppColors.rouge), + ), + ); + + // Rechercher d'abord dans les conteneurs + final containerProvider = context.read(); + if (containerProvider.containers.isEmpty) { + await containerProvider.loadContainers(); + } + + final container = containerProvider.containers.firstWhere( + (c) => c.id == scannedCode, + orElse: () => ContainerModel( + id: '', + name: '', + type: ContainerType.flightCase, + status: EquipmentStatus.available, + equipmentIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + if (mounted) { + Navigator.of(context).pop(); // Fermer l'indicateur + } + + if (container.id.isNotEmpty) { + // Conteneur trouvé + if (mounted) { + Navigator.pushNamed( + context, + '/container_detail', + arguments: container, + ); + } + return; + } + + // Si pas trouvé dans les conteneurs, chercher dans les équipements + final equipmentProvider = context.read(); + await equipmentProvider.ensureLoaded(); + + final equipment = equipmentProvider.allEquipment.firstWhere( + (eq) => eq.id == scannedCode, + orElse: () => EquipmentModel( + id: '', + name: '', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + if (equipment.id.isNotEmpty) { + // Équipement trouvé + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentDetailPage(equipment: equipment), + ), + ); + } + return; + } + + // Rien trouvé + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Aucun conteneur ou équipement trouvé avec l\'ID : $scannedCode'), + backgroundColor: Colors.orange, + ), + ); + } + } catch (e) { + DebugLog.error('[ContainerManagementPage] Error scanning QR code', e); + if (mounted) { + // Fermer l'indicateur si ouvert + Navigator.of(context).popUntil((route) => route.isFirst); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors du scan : ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } } diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index 6b73d6f..dbb83ae 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -5,10 +5,14 @@ import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/views/equipment_form_page.dart'; import 'package:em2rp/views/equipment_detail_page.dart'; +import 'package:em2rp/views/container_detail_page.dart'; import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +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/views/widgets/equipment/equipment_status_badge.dart'; import 'package:em2rp/utils/debug_log.dart'; @@ -156,6 +160,17 @@ class _EquipmentManagementPageState extends State ), ), const SizedBox(width: 8), + // Bouton Scanner QR + IconButton.filled( + onPressed: _scanQRCode, + icon: const Icon(Icons.qr_code_scanner), + tooltip: 'Scanner un QR Code', + style: IconButton.styleFrom( + backgroundColor: Colors.grey[700], + foregroundColor: Colors.white, + ), + ), + const SizedBox(width: 8), // Bouton Gérer les boîtes IconButton.filled( onPressed: () { @@ -256,6 +271,26 @@ class _EquipmentManagementPageState extends State ), ), ), + // Bouton Scanner QR + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0), + child: ElevatedButton.icon( + onPressed: _scanQRCode, + icon: const Icon(Icons.qr_code_scanner, color: Colors.white), + label: const Text( + 'Scanner QR Code', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[700], + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + minimumSize: const Size(double.infinity, 50), + ), + ), + ), const Divider(), // En-tête filtres Padding( @@ -992,4 +1027,120 @@ class _EquipmentManagementPageState extends State ), ); } + + /// Scanner un QR Code et ouvrir la vue de détail correspondante + Future _scanQRCode() async { + try { + // Ouvrir le scanner + final scannedCode = await showDialog( + context: context, + builder: (context) => const QRCodeScannerDialog(), + ); + + if (scannedCode == null || scannedCode.isEmpty) { + return; // L'utilisateur a annulé + } + + if (!mounted) return; + + // Afficher un indicateur de chargement + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(color: AppColors.rouge), + ), + ); + + // Rechercher d'abord dans les équipements + final equipmentProvider = context.read(); + await equipmentProvider.ensureLoaded(); + + final equipment = equipmentProvider.allEquipment.firstWhere( + (eq) => eq.id == scannedCode, + orElse: () => EquipmentModel( + id: '', + name: '', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + if (mounted) { + Navigator.of(context).pop(); // Fermer l'indicateur + } + + if (equipment.id.isNotEmpty) { + // Équipement trouvé + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentDetailPage(equipment: equipment), + ), + ); + } + return; + } + + // Si pas trouvé dans les équipements, chercher dans les conteneurs + final containerProvider = context.read(); + if (containerProvider.containers.isEmpty) { + await containerProvider.loadContainers(); + } + + final container = containerProvider.containers.firstWhere( + (c) => c.id == scannedCode, + orElse: () => ContainerModel( + id: '', + name: '', + type: ContainerType.flightCase, + status: EquipmentStatus.available, + equipmentIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + if (container.id.isNotEmpty) { + // Conteneur trouvé + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ContainerDetailPage(container: container), + ), + ); + } + return; + } + + // Rien trouvé + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'), + backgroundColor: Colors.orange, + ), + ); + } + } catch (e) { + DebugLog.error('[EquipmentManagementPage] Error scanning QR code', e); + if (mounted) { + // Fermer l'indicateur si ouvert + Navigator.of(context).popUntil((route) => route.isFirst); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors du scan : ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } } diff --git a/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart new file mode 100644 index 0000000..6dba6e0 --- /dev/null +++ b/em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +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}); + + @override + State createState() => _QRCodeScannerDialogState(); +} + +class _QRCodeScannerDialogState extends State { + MobileScannerController? _controller; + bool _isProcessing = false; + String? _scannedCode; + + @override + void initState() { + super.initState(); + _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_isProcessing) return; + + final List barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + + final barcode = barcodes.first; + final code = barcode.rawValue; + + if (code != null && code.isNotEmpty) { + setState(() { + _isProcessing = true; + _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); + } + }); + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.black, + insetPadding: const EdgeInsets.all(20), + child: Container( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.rouge, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Row( + children: [ + const Icon(Icons.qr_code_scanner, color: Colors.white, size: 28), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Scanner un QR Code', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + + // Zone de scan + Expanded( + child: Stack( + children: [ + // Scanner + if (_controller != null) + ClipRRect( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)), + child: MobileScanner( + controller: _controller, + onDetect: _onDetect, + ), + ), + + // Overlay avec cadre de scan + Positioned.fill( + child: CustomPaint( + painter: _ScannerOverlayPainter(), + ), + ), + + // Feedback visuel quand un code est détecté + if (_isProcessing && _scannedCode != null) + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.7), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'QR Code détecté !', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _scannedCode!, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + + // Instructions + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.grey[400], size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Positionnez le QR code dans le cadre', + style: TextStyle( + color: Colors.grey[300], + fontSize: 14, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Painter pour dessiner l'overlay du scanner avec un cadre +class _ScannerOverlayPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + const double scanAreaSize = 250.0; + final double left = (size.width - scanAreaSize) / 2; + final double top = (size.height - scanAreaSize) / 2; + + // Fond semi-transparent + final backgroundPath = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final holePath = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromLTWH(left, top, scanAreaSize, scanAreaSize), + const Radius.circular(16), + )); + + final backgroundPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.5) + ..style = PaintingStyle.fill; + + canvas.drawPath( + Path.combine(PathOperation.difference, backgroundPath, holePath), + backgroundPaint, + ); + + // Cadre de scan (coins) + final cornerPaint = Paint() + ..color = AppColors.rouge + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round; + + const double cornerLength = 30.0; + + // Coin haut-gauche + canvas.drawLine( + Offset(left, top + cornerLength), + Offset(left, top), + cornerPaint, + ); + canvas.drawLine( + Offset(left, top), + Offset(left + cornerLength, top), + cornerPaint, + ); + + // Coin haut-droit + canvas.drawLine( + Offset(left + scanAreaSize - cornerLength, top), + Offset(left + scanAreaSize, top), + cornerPaint, + ); + canvas.drawLine( + Offset(left + scanAreaSize, top), + Offset(left + scanAreaSize, top + cornerLength), + cornerPaint, + ); + + // Coin bas-gauche + canvas.drawLine( + Offset(left, top + scanAreaSize - cornerLength), + Offset(left, top + scanAreaSize), + cornerPaint, + ); + canvas.drawLine( + Offset(left, top + scanAreaSize), + Offset(left + cornerLength, top + scanAreaSize), + cornerPaint, + ); + + // Coin bas-droit + canvas.drawLine( + Offset(left + scanAreaSize - cornerLength, top + scanAreaSize), + Offset(left + scanAreaSize, top + scanAreaSize), + cornerPaint, + ); + canvas.drawLine( + Offset(left + scanAreaSize, top + scanAreaSize - cornerLength), + Offset(left + scanAreaSize, top + scanAreaSize), + cornerPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + diff --git a/em2rp/lib/views/widgets/management/management_search_bar.dart b/em2rp/lib/views/widgets/management/management_search_bar.dart index 05266d6..4bcf6db 100644 --- a/em2rp/lib/views/widgets/management/management_search_bar.dart +++ b/em2rp/lib/views/widgets/management/management_search_bar.dart @@ -8,6 +8,7 @@ class ManagementSearchBar extends StatelessWidget { final ValueChanged onChanged; final VoidCallback? onSelectionModeToggle; final bool showSelectionModeButton; + final List? additionalActions; const ManagementSearchBar({ super.key, @@ -16,6 +17,7 @@ class ManagementSearchBar extends StatelessWidget { required this.onChanged, this.onSelectionModeToggle, this.showSelectionModeButton = true, + this.additionalActions, }); @override @@ -53,6 +55,7 @@ class ManagementSearchBar extends StatelessWidget { onChanged: onChanged, ), ), + if (additionalActions != null) ...additionalActions!, if (showSelectionModeButton && onSelectionModeToggle != null) ...[ const SizedBox(width: 12), IconButton( diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index 8224c1a..fd0d782 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: pdf: ^3.10.7 printing: ^5.11.1 qr_flutter: ^4.1.0 + mobile_scanner: ^5.2.3 flutter_local_notifications: ^19.2.1 timezone: ^0.10.1 flutter_secure_storage: ^9.0.0