feat: Ajout du scan de QR Code pour retrouver équipements et conteneurs

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.
This commit is contained in:
ElPoyo
2026-01-16 00:35:10 +01:00
parent 67b85d323c
commit 06f394b728
5 changed files with 581 additions and 1 deletions

View File

@@ -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<ContainerManagementPage>
},
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<ContainerManagementPage>
_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<ContainerManagementPage>
}
}
/// 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<ContainerManagementPage>
}
}
}
/// Scanner un QR Code et ouvrir la vue de détail correspondante
Future<void> _scanQRCode() async {
try {
// Ouvrir le scanner
final scannedCode = await showDialog<String>(
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<ContainerProvider>();
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<EquipmentProvider>();
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,
),
);
}
}
}
}

View File

@@ -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<EquipmentManagementPage>
),
),
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<EquipmentManagementPage>
),
),
),
// 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<EquipmentManagementPage>
),
);
}
/// Scanner un QR Code et ouvrir la vue de détail correspondante
Future<void> _scanQRCode() async {
try {
// Ouvrir le scanner
final scannedCode = await showDialog<String>(
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<EquipmentProvider>();
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<ContainerProvider>();
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,
),
);
}
}
}
}

View File

@@ -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<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
}
class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
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<Barcode> 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;
}

View File

@@ -8,6 +8,7 @@ class ManagementSearchBar extends StatelessWidget {
final ValueChanged<String> onChanged;
final VoidCallback? onSelectionModeToggle;
final bool showSelectionModeButton;
final List<Widget>? 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(