feat: Gestion complète des containers et refactorisation du matériel

Ajout de la gestion des containers (création, édition, suppression, affichage des détails).
Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple.

**Features:**
- **Gestion des Containers :**
    - Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres.
    - Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique.
    - Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques.
    - Ajout des routes et du provider (`ContainerProvider`) nécessaires.
- **Modèle de Données :**
    - Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc.
    - Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions).
- **QR Codes :**
    - Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité.
    - Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité.
    - Amélioration de la performance de la génération de QR codes en masse.
- **Interface Utilisateur (UI/UX) :**
    - Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`).
    - Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion.
    - Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`).
    - Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel.

**Refactorisation :**
- L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers.
- Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes.
- Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.).
This commit is contained in:
ElPoyo
2025-10-29 10:57:42 +01:00
parent ae3a1b7227
commit 3fab69cb00
31 changed files with 6540 additions and 656 deletions

View File

@@ -0,0 +1,814 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/utils/colors.dart';
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/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/container_pdf_generator_service.dart';
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:printing/printing.dart';
import 'package:pdf/pdf.dart';
class ContainerManagementPage extends StatefulWidget {
const ContainerManagementPage({super.key});
@override
State<ContainerManagementPage> createState() =>
_ContainerManagementPageState();
}
class _ContainerManagementPageState extends State<ContainerManagementPage>
with SelectionModeMixin<ContainerManagementPage> {
final TextEditingController _searchController = TextEditingController();
ContainerType? _selectedType;
EquipmentStatus? _selectedStatus;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 800;
return PermissionGate(
requiredPermissions: const ['view_equipment'],
fallback: Scaffold(
appBar: const CustomAppBar(title: 'Accès refusé'),
drawer: const MainDrawer(currentPage: '/container_management'),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Text(
'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion des containers.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
),
),
child: Scaffold(
appBar: isSelectionMode
? AppBar(
backgroundColor: AppColors.rouge,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: toggleSelectionMode,
),
title: Text(
'$selectedCount sélectionné(s)',
style: const TextStyle(color: Colors.white),
),
actions: [
if (hasSelection) ...[
IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white),
tooltip: 'Générer QR Codes',
onPressed: _generateQRCodesForSelected,
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
tooltip: 'Supprimer',
onPressed: _deleteSelectedContainers,
),
],
],
)
: const CustomAppBar(title: 'Gestion des Containers'),
drawer: const MainDrawer(currentPage: '/container_management'),
floatingActionButton: !isSelectionMode
? FloatingActionButton.extended(
onPressed: () => _navigateToForm(context),
backgroundColor: AppColors.rouge,
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
'Nouveau Container',
style: TextStyle(color: Colors.white),
),
)
: null,
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
),
);
}
Widget _buildMobileLayout() {
return Column(
children: [
_buildSearchBar(),
_buildMobileFilters(),
Expanded(child: _buildContainerList()),
],
);
}
Widget _buildDesktopLayout() {
return Row(
children: [
SizedBox(
width: 250,
child: _buildSidebar(),
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
child: Column(
children: [
_buildSearchBar(),
Expanded(child: _buildContainerList()),
],
),
),
],
);
}
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<ContainerProvider>().setSearchQuery(value);
},
),
),
const SizedBox(width: 12),
if (!isSelectionMode)
IconButton(
icon: const Icon(Icons.checklist, color: AppColors.rouge),
tooltip: 'Mode sélection',
onPressed: toggleSelectionMode,
),
],
),
);
}
Widget _buildMobileFilters() {
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: [
_buildTypeChip(null, 'Tous'),
const SizedBox(width: 8),
...ContainerType.values.map((type) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildTypeChip(type, containerTypeLabel(type)),
);
}),
],
),
),
);
}
Widget _buildTypeChip(ContainerType? type, String label) {
final isSelected = _selectedType == type;
return ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedType = selected ? type : null;
context.read<ContainerProvider>().setSelectedType(_selectedType);
});
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: isSelected ? Colors.white : AppColors.noir,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
);
}
Widget _buildSidebar() {
return Container(
color: Colors.grey.shade50,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Filtres',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
const SizedBox(height: 16),
// Filtre par type
Text(
'Type de container',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
const SizedBox(height: 8),
_buildFilterOption(null, 'Tous les types'),
...ContainerType.values.map((type) {
return _buildFilterOption(type, containerTypeLabel(type));
}),
const Divider(height: 32),
// Filtre par statut
Text(
'Statut',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
const SizedBox(height: 8),
_buildStatusFilter(null, 'Tous les statuts'),
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
],
),
);
}
Widget _buildFilterOption(ContainerType? type, String label) {
final isSelected = _selectedType == type;
return RadioListTile<ContainerType?>(
title: Text(label),
value: type,
groupValue: _selectedType,
activeColor: AppColors.rouge,
dense: true,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
setState(() {
_selectedType = value;
context.read<ContainerProvider>().setSelectedType(_selectedType);
});
},
);
}
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
final isSelected = _selectedStatus == status;
return RadioListTile<EquipmentStatus?>(
title: Text(label),
value: status,
groupValue: _selectedStatus,
activeColor: AppColors.rouge,
dense: true,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
setState(() {
_selectedStatus = value;
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
});
},
);
}
Widget _buildContainerList() {
return Consumer<ContainerProvider>(
builder: (context, provider, child) {
return StreamBuilder<List<ContainerModel>>(
stream: provider.containersStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Erreur: ${snapshot.error}'),
);
}
final containers = 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);
},
);
},
);
},
);
}
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,
),
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
Icon(
_getTypeIcon(container.type),
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(
containerTypeLabel(container.type),
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<String>(
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)),
],
),
),
],
),
],
],
),
),
),
);
}
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) {
Color color;
String label;
switch (status) {
case EquipmentStatus.available:
color = Colors.green;
label = 'Disponible';
break;
case EquipmentStatus.inUse:
color = Colors.orange;
label = 'En prestation';
break;
case EquipmentStatus.maintenance:
color = Colors.blue;
label = 'Maintenance';
break;
case EquipmentStatus.outOfService:
color = Colors.red;
label = 'Hors service';
break;
default:
color = Colors.grey;
label = 'Autre';
}
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,
),
),
);
}
IconData _getTypeIcon(ContainerType type) {
switch (type) {
case ContainerType.flightCase:
return Icons.work;
case ContainerType.pelicase:
return Icons.work_outline;
case ContainerType.bag:
return Icons.shopping_bag;
case ContainerType.openCrate:
return Icons.inventory_2;
case ContainerType.toolbox:
return Icons.handyman;
}
}
void _handleMenuAction(String action, ContainerModel container) {
switch (action) {
case 'view':
_viewContainerDetails(container);
break;
case 'edit':
_editContainer(container);
break;
case 'qr':
showDialog(
context: context,
builder: (context) => QRCodeDialog.forContainer(container),
);
break;
case 'delete':
_deleteContainer(container);
break;
}
}
void _navigateToForm(BuildContext context) async {
final result = await Navigator.pushNamed(context, '/container_form');
if (result == true) {
// Rafraîchir la liste
}
}
void _viewContainerDetails(ContainerModel container) async {
await Navigator.pushNamed(
context,
'/container_detail',
arguments: container,
);
}
void _editContainer(ContainerModel container) async {
await Navigator.pushNamed(
context,
'/container_form',
arguments: container,
);
}
Future<void> _generateQRCodesForSelected() async {
if (!hasSelection) return;
// Récupérer les containers sélectionnés
final containerProvider = context.read<ContainerProvider>();
final List<ContainerModel> selectedContainers = [];
final Map<String, List<EquipmentModel>> containerEquipmentMap = {};
for (final id in selectedIds) {
final container = await containerProvider.getContainerById(id);
if (container != null) {
selectedContainers.add(container);
// Charger les équipements pour ce container
final equipment = await containerProvider.getContainerEquipment(id);
containerEquipmentMap[id] = equipment;
}
}
if (selectedContainers.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun container trouvé')),
);
}
return;
}
// Afficher le dialogue de sélection de format
final format = await showDialog<ContainerQRLabelFormat>(
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, ContainerQRLabelFormat.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, ContainerQRLabelFormat.medium),
),
ListTile(
leading: const Icon(Icons.label),
title: const Text('Grandes étiquettes'),
subtitle: const Text('QR code + ID + Type + Contenu (6 par page)'),
onTap: () => Navigator.pop(context, ContainerQRLabelFormat.large),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
],
),
);
if (format == null || !mounted) return;
// Générer et afficher le PDF
try {
final pdfBytes = await ContainerPDFGeneratorService.generateQRCodesPDF(
containerList: selectedContainers,
containerEquipmentMap: containerEquipmentMap,
format: format,
);
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')),
);
}
}
}
Future<void> _deleteContainer(ContainerModel container) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer le container "${container.name}" ?\n\n'
'Cette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true && mounted) {
try {
await context.read<ContainerProvider>().deleteContainer(container.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Container supprimé avec succès')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de la suppression: $e')),
);
}
}
}
}
Future<void> _deleteSelectedContainers() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer $selectedCount container(s) ?\n\n'
'Cette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true && mounted) {
try {
final provider = context.read<ContainerProvider>();
for (final id in selectedIds) {
await provider.deleteContainer(id);
}
disableSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Containers supprimés avec succès')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de la suppression: $e')),
);
}
}
}
}
}