From e59e3e6316e65ea178313aeb5b271caa7934367f Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Thu, 30 Oct 2025 20:06:13 +0100 Subject: [PATCH] feat: Enhance container management UI with new management components and improved QR code generation flow --- em2rp/lib/services/pdf_service.dart | 6 +- .../lib/views/container_management_page.dart | 489 +++++------------- em2rp/lib/views/equipment_detail_page.dart | 63 ++- .../lib/views/equipment_management_page.dart | 72 +-- .../views/widgets/common/qr_code_dialog.dart | 51 +- .../qr_code_format_selector_dialog.dart | 126 +++-- .../widgets/management/management_card.dart | 231 +++++++++ .../management/management_empty_state.dart | 38 ++ .../management/management_filter_bar.dart | 89 ++++ .../widgets/management/management_list.dart | 66 +++ .../management/management_search_bar.dart | 69 +++ 11 files changed, 815 insertions(+), 485 deletions(-) create mode 100644 em2rp/lib/views/widgets/management/management_card.dart create mode 100644 em2rp/lib/views/widgets/management/management_empty_state.dart create mode 100644 em2rp/lib/views/widgets/management/management_filter_bar.dart create mode 100644 em2rp/lib/views/widgets/management/management_list.dart create mode 100644 em2rp/lib/views/widgets/management/management_search_bar.dart diff --git a/em2rp/lib/services/pdf_service.dart b/em2rp/lib/services/pdf_service.dart index 465135b..4aacea4 100644 --- a/em2rp/lib/services/pdf_service.dart +++ b/em2rp/lib/services/pdf_service.dart @@ -21,17 +21,17 @@ class PDFGeneratorConfig { static const small = PDFGeneratorConfig( qrCodeSize: 150, - itemsPerPage: 20, + itemsPerPage: 50, ); static const medium = PDFGeneratorConfig( qrCodeSize: 250, - itemsPerPage: 6, + itemsPerPage: 20, ); static const large = PDFGeneratorConfig( qrCodeSize: 300, - itemsPerPage: 10, + itemsPerPage: 12, ); static PDFGeneratorConfig fromFormat(QRLabelFormat format) { diff --git a/em2rp/lib/views/container_management_page.dart b/em2rp/lib/views/container_management_page.dart index 2d831b8..ddca842 100644 --- a/em2rp/lib/views/container_management_page.dart +++ b/em2rp/lib/views/container_management_page.dart @@ -5,13 +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/container_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/services/pdf_service.dart'; -import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart'; -import 'package:printing/printing.dart'; -import 'package:pdf/pdf.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'; class ContainerManagementPage extends StatefulWidget { const ContainerManagementPage({super.key}); @@ -81,7 +82,45 @@ class _ContainerManagementPageState extends State ], ], ) - : const CustomAppBar(title: 'Gestion des Containers'), + : AppBar( + title: const Text('Gestion des Containers'), + backgroundColor: AppColors.rouge, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Retour à la gestion des équipements', + onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'), + ), + actions: [ + IconButton( + icon: const Icon(Icons.logout, color: Colors.white), + onPressed: () async { + final shouldLogout = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Déconnexion'), + content: const Text('Voulez-vous vraiment vous déconnecter ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Déconnexion'), + ), + ], + ), + ); + if (shouldLogout == true && context.mounted) { + await context.read().signOut(); + if (context.mounted) { + Navigator.pushReplacementNamed(context, '/login'); + } + } + }, + ), + ], + ), drawer: const MainDrawer(currentPage: '/container_management'), floatingActionButton: !isSelectionMode ? FloatingActionButton.extended( @@ -130,50 +169,14 @@ class _ContainerManagementPageState extends State } Widget _buildSearchBar() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.1), - spreadRadius: 1, - blurRadius: 3, - offset: const Offset(0, 1), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher un container...', - prefixIcon: const Icon(Icons.search, color: AppColors.rouge), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - context.read().setSearchQuery(value); - }, - ), - ), - const SizedBox(width: 12), - if (!isSelectionMode) - IconButton( - icon: const Icon(Icons.checklist, color: AppColors.rouge), - tooltip: 'Mode sélection', - onPressed: toggleSelectionMode, - ), - ], - ), + return ManagementSearchBar( + controller: _searchController, + hintText: 'Rechercher un container...', + onChanged: (value) { + context.read().setSearchQuery(value); + }, + onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode, + showSelectionModeButton: !isSelectionMode, ); } @@ -319,59 +322,15 @@ class _ContainerManagementPageState extends State Widget _buildContainerList() { return Consumer( builder: (context, provider, child) { - return StreamBuilder>( + return ManagementList( stream: provider.containersStream, - builder: (context, snapshot) { - // Utiliser les données en cache si disponibles pendant le rebuild - if (snapshot.hasData) { - _cachedContainers = snapshot.data; - } - - // Afficher le loader seulement au premier chargement - if (snapshot.connectionState == ConnectionState.waiting && _cachedContainers == null) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text('Erreur: ${snapshot.error}'), - ); - } - - final containers = _cachedContainers ?? snapshot.data ?? []; - - if (containers.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.inventory_2_outlined, - size: 80, - color: Colors.grey.shade400, - ), - const SizedBox(height: 16), - Text( - 'Aucun container trouvé', - style: TextStyle( - fontSize: 18, - color: Colors.grey.shade600, - ), - ), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: containers.length, - itemBuilder: (context, index) { - final container = containers[index]; - return _buildContainerCard(container); - }, - ); + cachedItems: _cachedContainers, + emptyMessage: 'Aucun container trouvé', + emptyIcon: Icons.inventory_2_outlined, + onDataReceived: (items) { + _cachedContainers = items; }, + itemBuilder: (container) => _buildContainerCard(container), ); }, ); @@ -380,192 +339,71 @@ class _ContainerManagementPageState extends State Widget _buildContainerCard(ContainerModel container) { final isSelected = isItemSelected(container.id); - return Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: isSelected - ? const BorderSide(color: AppColors.rouge, width: 2) - : BorderSide.none, + return ManagementCard( + item: container, + getId: (c) => c.id, + getTitle: (c) => c.id, + getSubtitle: (c) => c.name, + getIcon: (c) => c.type.getIcon( + size: 40, + color: AppColors.rouge, ), - child: InkWell( - onTap: () { - if (isSelectionMode) { - toggleItemSelection(container.id); - } else { - _viewContainerDetails(container); - } - }, - onLongPress: () { - if (!isSelectionMode) { - toggleSelectionMode(); - toggleItemSelection(container.id); - } - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - if (isSelectionMode) - Padding( - padding: const EdgeInsets.only(right: 16), - child: Checkbox( - value: isSelected, - onChanged: (value) { - toggleItemSelection(container.id); - }, - activeColor: AppColors.rouge, - ), - ), - - // Icône du type de container - container.type.getIcon( - size: 40, - color: AppColors.rouge, - ), - - const SizedBox(width: 16), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - container.id, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - container.name, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - _buildInfoChip( - container.type.label, - Icons.category, - ), - const SizedBox(width: 8), - _buildInfoChip( - '${container.itemCount} items', - Icons.inventory, - ), - ], - ), - ], - ), - ), - - const SizedBox(width: 16), - - // Badge de statut - _buildStatusBadge(container.status), - - if (!isSelectionMode) ...[ - const SizedBox(width: 8), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) => _handleMenuAction(value, container), - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'view', - child: Row( - children: [ - Icon(Icons.visibility, size: 20), - SizedBox(width: 8), - Text('Voir détails'), - ], - ), - ), - const PopupMenuItem( - value: 'edit', - child: Row( - children: [ - Icon(Icons.edit, size: 20), - SizedBox(width: 8), - Text('Modifier'), - ], - ), - ), - const PopupMenuItem( - value: 'qr', - child: Row( - children: [ - Icon(Icons.qr_code, size: 20), - SizedBox(width: 8), - Text('QR Code'), - ], - ), - ), - const PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Icon(Icons.delete, color: Colors.red, size: 20), - SizedBox(width: 8), - Text('Supprimer', style: TextStyle(color: Colors.red)), - ], - ), - ), - ], - ), - ], - ], - ), + getInfoChips: (c) => [ + InfoChip( + label: c.type.label, + icon: Icons.category, ), - ), - ); - } - - Widget _buildInfoChip(String label, IconData icon) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: Colors.grey.shade700), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade700, - ), - ), - ], - ), - ); - } - - Widget _buildStatusBadge(EquipmentStatus status) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: status.color.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: status.color), - ), - child: Text( - status.label, - style: TextStyle( - color: status.color, - fontWeight: FontWeight.bold, - fontSize: 12, + InfoChip( + label: '${c.itemCount} items', + icon: Icons.inventory, ), + ], + getStatusBadge: (c) => StatusBadge( + label: c.status.label, + color: c.status.color, ), + actions: const [ + CardAction( + id: 'view', + label: 'Voir détails', + icon: Icons.visibility, + ), + CardAction( + id: 'edit', + label: 'Modifier', + icon: Icons.edit, + ), + CardAction( + id: 'qr', + label: 'QR Code', + icon: Icons.qr_code, + ), + CardAction( + id: 'delete', + label: 'Supprimer', + icon: Icons.delete, + color: Colors.red, + ), + ], + onActionSelected: _handleMenuAction, + onTap: () { + if (isSelectionMode) { + toggleItemSelection(container.id); + } else { + _viewContainerDetails(container); + } + }, + onLongPress: () { + if (!isSelectionMode) { + toggleSelectionMode(); + toggleItemSelection(container.id); + } + }, + isSelectionMode: isSelectionMode, + isSelected: isSelected, + onSelectionChanged: (value) { + toggleItemSelection(container.id); + }, ); } @@ -579,10 +417,7 @@ class _ContainerManagementPageState extends State _editContainer(container); break; case 'qr': - showDialog( - context: context, - builder: (context) => QRCodeDialog.forContainer(container), - ); + // Non utilisé - les QR codes multiples sont générés via _generateQRCodesForSelected break; case 'delete': _deleteContainer(container); @@ -641,73 +476,25 @@ class _ContainerManagementPageState extends State return; } - // Afficher le dialogue de sélection de format - final format = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Format des étiquettes'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.qr_code_2), - title: const Text('Petits QR codes'), - subtitle: const Text('2×2 cm - QR code + ID (20 par page)'), - onTap: () => Navigator.pop(context, QRLabelFormat.small), - ), - ListTile( - leading: const Icon(Icons.qr_code), - title: const Text('QR codes moyens'), - subtitle: const Text('4×4 cm - QR code + ID (6 par page)'), - onTap: () => Navigator.pop(context, QRLabelFormat.medium), - ), - ListTile( - leading: const Icon(Icons.label), - title: const Text('Grandes étiquettes'), - subtitle: const Text('QR code + ID + Type + Contenu (10 par page)'), - onTap: () => Navigator.pop(context, QRLabelFormat.large), - ), - ], + // Afficher le dialogue de sélection de format avec le widget générique + if (mounted) { + showDialog( + context: context, + builder: (context) => QRCodeFormatSelectorDialog( + itemList: selectedContainers, + getId: (c) => c.id, + getTitle: (c) => c.name, + getDetails: (ContainerModel c) { + final equipment = containerEquipmentMap[c.id] ?? []; + return [ + 'Contenu (${equipment.length}):', + ...equipment.take(5).map((eq) => '- ${eq.id}'), + if (equipment.length > 5) '... +${equipment.length - 5}', + ]; + }, + dialogTitle: 'Générer ${selectedContainers.length} QR Code(s)', ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ], - ), - ); - - if (format == null || !mounted) return; - - // Générer et afficher le PDF avec la nouvelle API optimisée - try { - final pdfBytes = await PDFService.generatePDF( - items: selectedContainers, - format: format, - getId: (c) => c.id, - getTitle: (c) => c.name, - getDetails: format == QRLabelFormat.large ? (ContainerModel c) { - final equipment = containerEquipmentMap[c.id] ?? []; - return [ - 'Contenu (${equipment.length}):', - ...equipment.take(5).map((eq) => '- ${eq.id}'), - if (equipment.length > 5) '... +${equipment.length - 5}', - ]; - } : null, ); - - if (mounted) { - await Printing.layoutPdf( - onLayout: (PdfPageFormat format) async => pdfBytes, - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de la génération: $e')), - ); - } } } diff --git a/em2rp/lib/views/equipment_detail_page.dart b/em2rp/lib/views/equipment_detail_page.dart index 0a7e435..077db4e 100644 --- a/em2rp/lib/views/equipment_detail_page.dart +++ b/em2rp/lib/views/equipment_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/maintenance_model.dart'; @@ -293,17 +294,49 @@ class _EquipmentDetailPageState extends State { } Future _exportQRCode() async { + // Afficher le dialog de chargement + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + child: Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.rouge), + ), + const SizedBox(height: 16), + const Text( + 'Génération du QR Code...', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ); + try { - final qrImage = await QRCodeService.generateQRCode( + // Exécuter la génération dans un isolate séparé pour éviter le freeze + final qrImage = await compute( + _generateQRCodeIsolate, widget.equipment.id, - size: 1024, - useCache: false, ); - await Printing.sharePdf( - bytes: qrImage, - filename: 'QRCode_${widget.equipment.id}.png', - ); + // Fermer le dialog de chargement + if (mounted) { + Navigator.pop(context); + } + + // Partager le fichier + if (mounted) { + await Printing.sharePdf( + bytes: qrImage, + filename: 'QRCode_${widget.equipment.id}.png', + ); + } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -314,14 +347,28 @@ class _EquipmentDetailPageState extends State { ); } } catch (e) { + // Fermer le dialog de chargement en cas d'erreur if (mounted) { + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de l\'export: $e')), + SnackBar( + content: Text('Erreur lors de l\'export: $e'), + backgroundColor: Colors.red, + ), ); } } } + /// Fonction statique pour exécuter la génération QR code dans un isolate + static Future _generateQRCodeIsolate(String equipmentId) async { + return await QRCodeService.generateQRCode( + equipmentId, + size: 1024, + useCache: false, + ); + } + void _editEquipment() { Navigator.push( context, diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index 91b2c6c..3bfdb80 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -11,6 +11,7 @@ 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_format_selector_dialog.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart'; +import 'package:em2rp/views/widgets/management/management_list.dart'; class EquipmentManagementPage extends StatefulWidget { const EquipmentManagementPage({super.key}); @@ -418,61 +419,19 @@ class _EquipmentManagementPageState extends State Widget _buildEquipmentList() { return Consumer( builder: (context, provider, child) { - return StreamBuilder>( + return ManagementList( stream: provider.equipmentStream, - builder: (context, snapshot) { - // Mettre en cache les données quand elles arrivent - if (snapshot.hasData) { - _cachedEquipment = snapshot.data; - } - - // Afficher le loader seulement si on n'a pas encore de cache - if (snapshot.connectionState == ConnectionState.waiting && _cachedEquipment == null) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text('Erreur: ${snapshot.error}'), - ); - } - - // Utiliser le cache si disponible, sinon les nouvelles données - final equipment = _cachedEquipment ?? snapshot.data ?? []; - - if (equipment.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.inventory_2_outlined, - size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - 'Aucun équipement trouvé', - style: TextStyle(fontSize: 18, color: Colors.grey[600]), - ), - const SizedBox(height: 8), - Text( - 'Ajoutez votre premier équipement', - style: TextStyle(fontSize: 14, color: Colors.grey[500]), - ), - ], - ), - ); - } - - // Créer une copie pour le tri - final sortedEquipment = List.from(equipment); + cachedItems: _cachedEquipment, + emptyMessage: 'Aucun équipement trouvé', + emptyIcon: Icons.inventory_2_outlined, + onDataReceived: (items) { + _cachedEquipment = items; + }, + itemBuilder: (equipment) { + // Trier les équipements par nom + final sortedEquipment = List.from(_cachedEquipment ?? [equipment]); sortedEquipment.sort((a, b) => a.name.compareTo(b.name)); - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: sortedEquipment.length, - itemBuilder: (context, index) { - return _buildEquipmentCard(sortedEquipment[index]); - }, - ); + return _buildEquipmentCard(equipment); }, ); }, @@ -804,8 +763,11 @@ class _EquipmentManagementPageState extends State // Plusieurs équipements : afficher le sélecteur de format showDialog( context: context, - builder: (context) => QRCodeFormatSelectorDialog( - equipmentList: selectedEquipment, + builder: (context) => QRCodeFormatSelectorDialog( + itemList: selectedEquipment, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)', ), ); } diff --git a/em2rp/lib/views/widgets/common/qr_code_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_dialog.dart index 252a8a6..9991e14 100644 --- a/em2rp/lib/views/widgets/common/qr_code_dialog.dart +++ b/em2rp/lib/views/widgets/common/qr_code_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/services/qr_code_service.dart'; @@ -122,14 +123,42 @@ class QRCodeDialog extends StatelessWidget { } Future _downloadQRCode(BuildContext context, String id) async { + // Afficher le dialog de chargement + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + child: Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.rouge), + ), + const SizedBox(height: 16), + const Text( + 'Génération du QR Code...', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ); + try { - // Générer l'image QR code en haute résolution - final qrImage = await QRCodeService.generateQRCode( + // Exécuter la génération dans un isolate séparé + final qrImage = await compute( + _generateQRCodeIsolate, id, - size: 1024, - useCache: false, ); + // Fermer le dialog de chargement + if (context.mounted) { + Navigator.pop(context); + } + // Utiliser la bibliothèque printing pour sauvegarder l'image await Printing.sharePdf( bytes: qrImage, @@ -145,6 +174,11 @@ class QRCodeDialog extends StatelessWidget { ); } } catch (e) { + // Fermer le dialog de chargement en cas d'erreur + if (context.mounted) { + Navigator.pop(context); + } + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -156,6 +190,15 @@ class QRCodeDialog extends StatelessWidget { } } + /// Fonction statique pour exécuter la génération QR code dans un isolate + static Future _generateQRCodeIsolate(String id) async { + return await QRCodeService.generateQRCode( + id, + size: 1024, + useCache: false, + ); + } + /// Factory pour équipement static QRCodeDialog forEquipment(dynamic equipment) { return QRCodeDialog( diff --git a/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart index 31c6077..6b2c239 100644 --- a/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart +++ b/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart @@ -1,16 +1,24 @@ import 'package:flutter/material.dart'; import 'package:em2rp/utils/colors.dart'; -import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/services/pdf_service.dart'; import 'package:printing/printing.dart'; /// Widget réutilisable pour sélectionner le format de génération de QR codes multiples -class QRCodeFormatSelectorDialog extends StatelessWidget { - final List equipmentList; +/// Compatible avec n'importe quel type d'objet (équipements, conteneurs, etc.) +class QRCodeFormatSelectorDialog extends StatelessWidget { + final List itemList; + final String Function(T) getId; + final String Function(T) getTitle; + final List Function(T)? getDetails; + final String dialogTitle; const QRCodeFormatSelectorDialog({ super.key, - required this.equipmentList, + required this.itemList, + required this.getId, + required this.getTitle, + this.getDetails, + required this.dialogTitle, }); @override @@ -29,7 +37,7 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - 'Générer ${equipmentList.length} QR Codes', + dialogTitle, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -49,7 +57,7 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { ), const SizedBox(height: 16), - // Liste des équipements + // Liste des items Expanded( child: Container( decoration: BoxDecoration( @@ -58,19 +66,19 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { ), child: ListView.separated( shrinkWrap: true, - itemCount: equipmentList.length, + itemCount: itemList.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { - final equipment = equipmentList[index]; + final item = itemList[index]; return ListTile( dense: true, leading: const Icon(Icons.qr_code, size: 20), title: Text( - equipment.id, + getId(item), style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), + getTitle(item), style: const TextStyle(fontSize: 12), ), ); @@ -85,30 +93,21 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { icon: Icons.qr_code, title: 'Petits QR Codes', subtitle: 'QR codes compacts (2x2 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(context, equipmentList, QRLabelFormat.small); - }, + onPressed: () => _generatePDF(context, itemList, QRLabelFormat.small), ), const SizedBox(height: 12), _FormatButton( icon: Icons.qr_code_2, title: 'QR Moyens', subtitle: 'QR codes taille moyenne (4x4 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(context, equipmentList, QRLabelFormat.medium); - }, + onPressed: () => _generatePDF(context, itemList, QRLabelFormat.medium), ), const SizedBox(height: 12), _FormatButton( icon: Icons.label, title: 'Grandes étiquettes', - subtitle: 'QR + ID + Marque/Modèle (10x5 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(context, equipmentList, QRLabelFormat.large); - }, + subtitle: 'QR + ID + Détails (10x5 cm)', + onPressed: () => _generatePDF(context, itemList, QRLabelFormat.large), ), ], ), @@ -118,14 +117,24 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { Future _generatePDF( BuildContext context, - List equipmentList, + List items, QRLabelFormat format, ) async { + // Capturer le navigator AVANT de fermer le premier dialog + final navigator = Navigator.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + // Fermer le dialog de sélection + navigator.pop(); + + // Attendre un court instant pour que le dialog se ferme complètement + await Future.delayed(const Duration(milliseconds: 100)); + // Afficher le dialogue de chargement showDialog( context: context, barrierDismissible: false, - builder: (context) => Dialog( + builder: (dialogContext) => Dialog( child: Padding( padding: const EdgeInsets.all(24), child: Column( @@ -144,7 +153,7 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Génération de ${equipmentList.length} QR code(s)', + 'Génération de ${items.length} QR code(s)', style: TextStyle( fontSize: 14, color: Colors.grey[600], @@ -157,31 +166,23 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { ); try { - // Génération du PDF avec progression - final pdfBytes = await PDFService.generatePDF( - items: equipmentList, + // Attendre que le dialog de chargement s'affiche complètement + await Future.delayed(const Duration(milliseconds: 300)); + + // Génération du PDF + final pdfBytes = await PDFService.generatePDF( + items: items, format: format, - getId: (eq) => eq.id, - getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), - getDetails: format == QRLabelFormat.large ? (EquipmentModel eq) { - final details = []; - final brand = eq.brand; - if (brand != null && brand.isNotEmpty) { - details.add('Marque: $brand'); - } - final model = eq.model; - if (model != null && model.isNotEmpty) { - details.add('Modèle: $model'); - } - details.add('Catégorie: ${eq.category.label}'); - return details; - } : null, + getId: getId, + getTitle: getTitle, + getDetails: getDetails, ); - // Fermer le dialogue de chargement - if (context.mounted) { - Navigator.pop(context); - } + // Fermer le dialogue de chargement avec le navigator capturé + navigator.pop(); + + // Petite pause pour s'assurer que le dialog est bien fermé + await Future.delayed(const Duration(milliseconds: 100)); // Afficher le PDF await Printing.layoutPdf( @@ -189,25 +190,22 @@ class QRCodeFormatSelectorDialog extends StatelessWidget { name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf', ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('PDF généré avec succès'), - backgroundColor: Colors.green, - ), - ); - } + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('PDF généré avec succès'), + backgroundColor: Colors.green, + ), + ); } catch (e) { // Fermer le dialogue de chargement en cas d'erreur - if (context.mounted) { - Navigator.pop(context); - } + navigator.pop(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de la génération du PDF: $e')), - ); - } + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text('Erreur lors de la génération du PDF: $e'), + backgroundColor: Colors.red, + ), + ); } } } diff --git a/em2rp/lib/views/widgets/management/management_card.dart b/em2rp/lib/views/widgets/management/management_card.dart new file mode 100644 index 0000000..54b05d0 --- /dev/null +++ b/em2rp/lib/views/widgets/management/management_card.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Action du menu contextuel +class CardAction { + final String id; + final String label; + final IconData icon; + final Color? color; + + const CardAction({ + required this.id, + required this.label, + required this.icon, + this.color, + }); +} + +/// Widget de card générique pour les pages de gestion +class ManagementCard extends StatelessWidget { + final T item; + final String Function(T) getId; + final String Function(T) getTitle; + final String Function(T) getSubtitle; + final Widget Function(T) getIcon; + final List Function(T) getInfoChips; + final Widget Function(T) getStatusBadge; + final List actions; + final void Function(String actionId, T item) onActionSelected; + final VoidCallback onTap; + final VoidCallback? onLongPress; + final bool isSelectionMode; + final bool isSelected; + final ValueChanged? onSelectionChanged; + + const ManagementCard({ + super.key, + required this.item, + required this.getId, + required this.getTitle, + required this.getSubtitle, + required this.getIcon, + required this.getInfoChips, + required this.getStatusBadge, + required this.actions, + required this.onActionSelected, + required this.onTap, + this.onLongPress, + this.isSelectionMode = false, + this.isSelected = false, + this.onSelectionChanged, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isSelected + ? const BorderSide(color: AppColors.rouge, width: 2) + : BorderSide.none, + ), + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Checkbox en mode sélection + if (isSelectionMode) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Checkbox( + value: isSelected, + onChanged: onSelectionChanged, + activeColor: AppColors.rouge, + ), + ), + + // Icône principale + getIcon(item), + const SizedBox(width: 16), + + // Contenu principal + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre (ID) + Text( + getTitle(item), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + + // Sous-titre + Text( + getSubtitle(item), + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + + // Chips d'information + Wrap( + spacing: 8, + runSpacing: 4, + children: getInfoChips(item), + ), + ], + ), + ), + + const SizedBox(width: 16), + + // Badge de statut + getStatusBadge(item), + + // Menu contextuel (si pas en mode sélection) + if (!isSelectionMode) ...[ + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (actionId) => onActionSelected(actionId, item), + itemBuilder: (context) => actions.map((action) { + return PopupMenuItem( + value: action.id, + child: Row( + children: [ + Icon( + action.icon, + size: 20, + color: action.color, + ), + const SizedBox(width: 8), + Text( + action.label, + style: TextStyle(color: action.color), + ), + ], + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Widget helper pour créer un chip d'information +class InfoChip extends StatelessWidget { + final String label; + final IconData icon; + + const InfoChip({ + super.key, + required this.label, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey.shade700), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ); + } +} + +/// Widget helper pour créer un badge de statut +class StatusBadge extends StatelessWidget { + final String label; + final Color color; + + const StatusBadge({ + super.key, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/management/management_empty_state.dart b/em2rp/lib/views/widgets/management/management_empty_state.dart new file mode 100644 index 0000000..c2f4a61 --- /dev/null +++ b/em2rp/lib/views/widgets/management/management_empty_state.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// Widget réutilisable pour afficher un état vide dans les listes de gestion +class ManagementEmptyState extends StatelessWidget { + final IconData icon; + final String message; + + const ManagementEmptyState({ + super.key, + required this.icon, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + message, + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/management/management_filter_bar.dart b/em2rp/lib/views/widgets/management/management_filter_bar.dart new file mode 100644 index 0000000..229494e --- /dev/null +++ b/em2rp/lib/views/widgets/management/management_filter_bar.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Widget réutilisable pour afficher un chip de filtre +class FilterChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const FilterChip({ + super.key, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? AppColors.rouge : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? AppColors.rouge : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : AppColors.noir, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } +} + +/// Widget réutilisable pour une barre de filtres horizontale +class ManagementFilterBar extends StatelessWidget { + final List filters; + + const ManagementFilterBar({ + super.key, + required this.filters, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + color: Colors.grey.shade50, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: filters.map((filter) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: filter.label, + isSelected: filter.isSelected, + onTap: filter.onTap, + ), + ); + }).toList(), + ), + ), + ); + } +} + +/// Données pour un chip de filtre +class FilterChipData { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const FilterChipData({ + required this.label, + required this.isSelected, + required this.onTap, + }); +} + diff --git a/em2rp/lib/views/widgets/management/management_list.dart b/em2rp/lib/views/widgets/management/management_list.dart new file mode 100644 index 0000000..5f2bc3f --- /dev/null +++ b/em2rp/lib/views/widgets/management/management_list.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/views/widgets/management/management_empty_state.dart'; + +/// Widget de liste générique pour les pages de gestion +class ManagementList extends StatelessWidget { + final Stream> stream; + final List? cachedItems; + final Widget Function(T item) itemBuilder; + final String emptyMessage; + final IconData emptyIcon; + final void Function(List? items)? onDataReceived; + + const ManagementList({ + super.key, + required this.stream, + this.cachedItems, + required this.itemBuilder, + required this.emptyMessage, + this.emptyIcon = Icons.inventory_2_outlined, + this.onDataReceived, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: stream, + builder: (context, snapshot) { + // Notifier si des données sont reçues + if (snapshot.hasData && onDataReceived != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onDataReceived!(snapshot.data); + }); + } + + // Afficher le loader seulement au premier chargement + if (snapshot.connectionState == ConnectionState.waiting && cachedItems == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Erreur: ${snapshot.error}'), + ); + } + + final items = cachedItems ?? snapshot.data ?? []; + + if (items.isEmpty) { + return ManagementEmptyState( + icon: emptyIcon, + message: emptyMessage, + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: items.length, + itemBuilder: (context, index) { + return itemBuilder(items[index]); + }, + ); + }, + ); + } +} + diff --git a/em2rp/lib/views/widgets/management/management_search_bar.dart b/em2rp/lib/views/widgets/management/management_search_bar.dart new file mode 100644 index 0000000..05266d6 --- /dev/null +++ b/em2rp/lib/views/widgets/management/management_search_bar.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Widget réutilisable pour la barre de recherche dans les pages de gestion +class ManagementSearchBar extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final ValueChanged onChanged; + final VoidCallback? onSelectionModeToggle; + final bool showSelectionModeButton; + + const ManagementSearchBar({ + super.key, + required this.controller, + required this.hintText, + required this.onChanged, + this.onSelectionModeToggle, + this.showSelectionModeButton = true, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText, + prefixIcon: const Icon(Icons.search, color: AppColors.rouge), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: onChanged, + ), + ), + if (showSelectionModeButton && onSelectionModeToggle != null) ...[ + const SizedBox(width: 12), + IconButton( + icon: const Icon(Icons.checklist, color: AppColors.rouge), + tooltip: 'Mode sélection', + onPressed: onSelectionModeToggle, + ), + ], + ], + ), + ); + } +} +