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,881 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_parent_containers.dart';
import 'package:intl/intl.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:printing/printing.dart';
class EquipmentDetailPage extends StatefulWidget {
final EquipmentModel equipment;
const EquipmentDetailPage({super.key, required this.equipment});
@override
State<EquipmentDetailPage> createState() => _EquipmentDetailPageState();
}
class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
final EquipmentService _equipmentService = EquipmentService();
List<MaintenanceModel> _maintenances = [];
bool _isLoadingMaintenances = true;
@override
void initState() {
super.initState();
_loadMaintenances();
}
Future<void> _loadMaintenances() async {
try {
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
setState(() {
_maintenances = maintenances;
_isLoadingMaintenances = false;
});
} catch (e) {
setState(() {
_isLoadingMaintenances = false;
});
}
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 800;
final userProvider = Provider.of<LocalUserProvider>(context);
final hasManagePermission = userProvider.hasPermission('manage_equipment');
return Scaffold(
appBar: CustomAppBar(
title: widget.equipment.id,
actions: [
IconButton(
icon: const Icon(Icons.qr_code),
tooltip: 'Générer QR Code',
onPressed: _showQRCode,
),
if (hasManagePermission)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: _editEquipment,
),
if (hasManagePermission)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: _deleteEquipment,
),
],
),
body: SingleChildScrollView(
padding: EdgeInsets.all(isMobile ? 16 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 24),
_buildMainInfoSection(),
const SizedBox(height: 24),
if (hasManagePermission) ...[
_buildPriceSection(),
const SizedBox(height: 24),
],
if (widget.equipment.category == EquipmentCategory.consumable ||
widget.equipment.category == EquipmentCategory.cable) ...[
_buildQuantitySection(),
const SizedBox(height: 24),
],
if (widget.equipment.parentBoxIds.isNotEmpty) ...[
EquipmentParentContainers(
parentBoxIds: widget.equipment.parentBoxIds,
),
const SizedBox(height: 24),
],
_buildDatesSection(),
const SizedBox(height: 24),
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
_buildNotesSection(),
const SizedBox(height: 24),
],
_buildMaintenanceHistorySection(hasManagePermission),
const SizedBox(height: 24),
_buildAssociatedEventsSection(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.rouge, AppColors.rouge.withValues(alpha: 0.8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.rouge.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.white,
radius: 30,
child: Icon(
_getCategoryIcon(widget.equipment.category),
color: AppColors.rouge,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.equipment.id,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim().isNotEmpty
? '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: const TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
],
),
),
if (widget.equipment.category != EquipmentCategory.consumable &&
widget.equipment.category != EquipmentCategory.cable)
_buildStatusBadge(),
],
),
],
),
);
}
Widget _buildStatusBadge() {
final statusInfo = _getStatusInfo(widget.equipment.status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: statusInfo.$2,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
statusInfo.$1,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: statusInfo.$2,
),
),
],
),
);
}
Widget _buildMainInfoSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Informations principales',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
_buildInfoRow('Catégorie', _getCategoryName(widget.equipment.category)),
if (widget.equipment.brand != null && widget.equipment.brand!.isNotEmpty)
_buildInfoRow('Marque', widget.equipment.brand!),
if (widget.equipment.model != null && widget.equipment.model!.isNotEmpty)
_buildInfoRow('Modèle', widget.equipment.model!),
if (widget.equipment.category != EquipmentCategory.consumable &&
widget.equipment.category != EquipmentCategory.cable)
_buildInfoRow('Statut', _getStatusInfo(widget.equipment.status).$1),
],
),
),
);
}
Widget _buildPriceSection() {
final hasPrices = widget.equipment.purchasePrice != null || widget.equipment.rentalPrice != null;
if (!hasPrices) return const SizedBox.shrink();
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.euro, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Prix',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (widget.equipment.purchasePrice != null)
_buildInfoRow(
'Prix d\'achat',
'${widget.equipment.purchasePrice!.toStringAsFixed(2)}',
),
if (widget.equipment.rentalPrice != null)
_buildInfoRow(
'Prix de location',
'${widget.equipment.rentalPrice!.toStringAsFixed(2)} €/jour',
),
],
),
),
);
}
Widget _buildQuantitySection() {
final availableQty = widget.equipment.availableQuantity ?? 0;
final totalQty = widget.equipment.totalQuantity ?? 0;
final criticalThreshold = widget.equipment.criticalThreshold ?? 0;
final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold;
return Card(
elevation: 2,
color: isCritical ? Colors.red.shade50 : null,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isCritical ? Icons.warning : Icons.inventory,
color: isCritical ? Colors.red : AppColors.rouge,
),
const SizedBox(width: 8),
Text(
'Quantités',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: isCritical ? Colors.red : null,
),
),
if (isCritical) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'STOCK CRITIQUE',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
],
),
const Divider(height: 24),
_buildInfoRow(
'Quantité disponible',
availableQty.toString(),
valueColor: isCritical ? Colors.red : null,
valueWeight: isCritical ? FontWeight.bold : null,
),
_buildInfoRow('Quantité totale', totalQty.toString()),
if (criticalThreshold > 0)
_buildInfoRow('Seuil critique', criticalThreshold.toString()),
],
),
),
);
}
Widget _buildDatesSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.calendar_today, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Dates',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (widget.equipment.purchaseDate != null)
_buildInfoRow(
'Date d\'achat',
DateFormat('dd/MM/yyyy').format(widget.equipment.purchaseDate!),
),
if (widget.equipment.lastMaintenanceDate != null)
_buildInfoRow(
'Dernière maintenance',
DateFormat('dd/MM/yyyy').format(widget.equipment.lastMaintenanceDate!),
),
if (widget.equipment.nextMaintenanceDate != null)
_buildInfoRow(
'Prochaine maintenance',
DateFormat('dd/MM/yyyy').format(widget.equipment.nextMaintenanceDate!),
valueColor: widget.equipment.nextMaintenanceDate!.isBefore(DateTime.now())
? Colors.red
: null,
),
_buildInfoRow(
'Créé le',
DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.createdAt),
),
_buildInfoRow(
'Modifié le',
DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.updatedAt),
),
],
),
),
);
}
Widget _buildNotesSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notes, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
Text(
widget.equipment.notes!,
style: const TextStyle(fontSize: 14),
),
],
),
),
);
}
Widget _buildMaintenanceHistorySection(bool hasManagePermission) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.build, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Historique des maintenances',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
if (_isLoadingMaintenances)
const Center(child: CircularProgressIndicator())
else if (_maintenances.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Aucune maintenance enregistrée',
style: TextStyle(color: Colors.grey),
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _maintenances.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final maintenance = _maintenances[index];
return _buildMaintenanceItem(maintenance, hasManagePermission);
},
),
],
),
),
);
}
Widget _buildMaintenanceItem(MaintenanceModel maintenance, bool showCost) {
final isCompleted = maintenance.completedDate != null;
final typeInfo = _getMaintenanceTypeInfo(maintenance.type);
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8),
leading: CircleAvatar(
backgroundColor: isCompleted ? Colors.green.withValues(alpha: 0.2) : Colors.orange.withValues(alpha: 0.2),
child: Icon(
isCompleted ? Icons.check_circle : Icons.schedule,
color: isCompleted ? Colors.green : Colors.orange,
),
),
title: Text(
maintenance.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
Icon(typeInfo.$2, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(typeInfo.$1, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
],
),
const SizedBox(height: 4),
Text(
isCompleted
? 'Effectuée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}'
: 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
if (showCost && maintenance.cost != null) ...[
const SizedBox(height: 4),
Text(
'Coût: ${maintenance.cost!.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
],
],
),
);
}
Widget _buildAssociatedEventsSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.event, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Événements associés',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Fonctionnalité à implémenter',
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
),
),
),
],
),
),
);
}
Widget _buildInfoRow(
String label,
String value, {
Color? valueColor,
FontWeight? valueWeight,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: valueColor,
fontWeight: valueWeight ?? FontWeight.w600,
),
),
),
],
),
);
}
void _showQRCode() {
showDialog(
context: context,
builder: (context) => Dialog(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.qr_code, color: AppColors.rouge, size: 32),
const SizedBox(width: 12),
Expanded(
child: Text(
'QR Code - ${widget.equipment.id}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: QrImageView(
data: widget.equipment.id,
version: QrVersions.auto,
size: 300,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
style: TextStyle(color: Colors.grey[700]),
),
],
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _exportQRCode(),
icon: const Icon(Icons.download),
label: const Text('Télécharger PNG'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
minimumSize: const Size(0, 48),
),
icon: const Icon(Icons.close, color: Colors.white),
label: const Text(
'Fermer',
style: TextStyle(color: Colors.white),
),
),
),
],
),
],
),
),
),
);
}
Future<void> _exportQRCode() async {
try {
final qrImage = await QRCodeService.generateQRCode(
widget.equipment.id,
size: 1024,
useCache: false,
);
await Printing.sharePdf(
bytes: qrImage,
filename: 'QRCode_${widget.equipment.id}.png',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('QR Code exporté avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de l\'export: $e')),
);
}
}
}
void _editEquipment() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentFormPage(equipment: widget.equipment),
),
).then((_) {
Navigator.pop(context);
});
}
void _deleteEquipment() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(widget.equipment.id);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
}
IconData _getCategoryIcon(EquipmentCategory category) {
switch (category) {
case EquipmentCategory.lighting:
return Icons.light_mode;
case EquipmentCategory.sound:
return Icons.volume_up;
case EquipmentCategory.video:
return Icons.videocam;
case EquipmentCategory.effect:
return Icons.auto_awesome;
case EquipmentCategory.structure:
return Icons.construction;
case EquipmentCategory.consumable:
return Icons.inventory_2;
case EquipmentCategory.cable:
return Icons.cable;
case EquipmentCategory.other:
return Icons.more_horiz;
}
}
String _getCategoryName(EquipmentCategory category) {
switch (category) {
case EquipmentCategory.lighting:
return 'Lumière';
case EquipmentCategory.sound:
return 'Son';
case EquipmentCategory.video:
return 'Vidéo';
case EquipmentCategory.effect:
return 'Effets';
case EquipmentCategory.structure:
return 'Structure';
case EquipmentCategory.consumable:
return 'Consommable';
case EquipmentCategory.cable:
return 'Câble';
case EquipmentCategory.other:
return 'Autre';
}
}
(String, Color) _getStatusInfo(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return ('Disponible', Colors.green);
case EquipmentStatus.inUse:
return ('En prestation', Colors.blue);
case EquipmentStatus.rented:
return ('Loué', Colors.orange);
case EquipmentStatus.lost:
return ('Perdu', Colors.red);
case EquipmentStatus.outOfService:
return ('HS', Colors.red[900]!);
case EquipmentStatus.maintenance:
return ('Maintenance', Colors.amber);
}
}
(String, IconData) _getMaintenanceTypeInfo(MaintenanceType type) {
switch (type) {
case MaintenanceType.preventive:
return ('Préventive', Icons.schedule);
case MaintenanceType.corrective:
return ('Corrective', Icons.build);
case MaintenanceType.inspection:
return ('Inspection', Icons.search);
}
}
}