Files
EM2_ERP/em2rp/lib/views/container_form_page.dart
ElPoyo 3fab69cb00 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.).
2025-10-29 10:57:42 +01:00

925 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/utils/id_generator.dart';
class ContainerFormPage extends StatefulWidget {
final ContainerModel? container;
const ContainerFormPage({super.key, this.container});
@override
State<ContainerFormPage> createState() => _ContainerFormPageState();
}
class _ContainerFormPageState extends State<ContainerFormPage> {
final _formKey = GlobalKey<FormState>();
// Controllers
final _nameController = TextEditingController();
final _idController = TextEditingController();
final _weightController = TextEditingController();
final _lengthController = TextEditingController();
final _widthController = TextEditingController();
final _heightController = TextEditingController();
final _notesController = TextEditingController();
// Form fields
ContainerType _selectedType = ContainerType.flightCase;
EquipmentStatus _selectedStatus = EquipmentStatus.available;
bool _autoGenerateId = true;
final Set<String> _selectedEquipmentIds = {};
bool _isEditing = false;
@override
void initState() {
super.initState();
if (widget.container != null) {
_isEditing = true;
_loadContainerData();
}
}
void _loadContainerData() {
final container = widget.container!;
_nameController.text = container.name;
_idController.text = container.id;
_selectedType = container.type;
_selectedStatus = container.status;
_weightController.text = container.weight?.toString() ?? '';
_lengthController.text = container.length?.toString() ?? '';
_widthController.text = container.width?.toString() ?? '';
_heightController.text = container.height?.toString() ?? '';
_notesController.text = container.notes ?? '';
_selectedEquipmentIds.addAll(container.equipmentIds);
_autoGenerateId = false;
}
void _updateIdFromName() {
if (_autoGenerateId && !_isEditing) {
final name = _nameController.text;
if (name.isNotEmpty) {
final baseId = IdGenerator.generateContainerId(
type: _selectedType,
name: name,
);
_idController.text = baseId;
}
}
}
void _updateIdFromType() {
if (_autoGenerateId && !_isEditing) {
final name = _nameController.text;
if (name.isNotEmpty) {
final baseId = IdGenerator.generateContainerId(
type: _selectedType,
name: name,
);
_idController.text = baseId;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier Container' : 'Nouveau Container'),
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
// Nom
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom du container *',
hintText: 'ex: Flight Case Beam 7R',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.label),
),
onChanged: (_) => _updateIdFromName(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un nom';
}
return null;
},
),
const SizedBox(height: 16),
// ID
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _idController,
decoration: const InputDecoration(
labelText: 'Identifiant *',
hintText: 'ex: FLIGHTCASE_BEAM',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.qr_code),
),
enabled: !_autoGenerateId || _isEditing,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un identifiant';
}
final validation = IdGenerator.validateContainerId(value);
return validation;
},
),
),
if (!_isEditing) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
_autoGenerateId ? Icons.lock : Icons.lock_open,
color: _autoGenerateId ? AppColors.rouge : Colors.grey,
),
tooltip: _autoGenerateId
? 'Génération automatique'
: 'Saisie manuelle',
onPressed: () {
setState(() {
_autoGenerateId = !_autoGenerateId;
if (_autoGenerateId) {
_updateIdFromName();
}
});
},
),
],
],
),
const SizedBox(height: 16),
// Type
DropdownButtonFormField<ContainerType>(
value: _selectedType,
decoration: const InputDecoration(
labelText: 'Type de container *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: ContainerType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(containerTypeLabel(type)),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedType = value;
_updateIdFromType();
});
}
},
),
const SizedBox(height: 16),
// Statut
DropdownButtonFormField<EquipmentStatus>(
value: _selectedStatus,
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
),
items: [
EquipmentStatus.available,
EquipmentStatus.inUse,
EquipmentStatus.maintenance,
EquipmentStatus.outOfService,
].map((status) {
String label;
switch (status) {
case EquipmentStatus.available:
label = 'Disponible';
break;
case EquipmentStatus.inUse:
label = 'En prestation';
break;
case EquipmentStatus.maintenance:
label = 'En maintenance';
break;
case EquipmentStatus.outOfService:
label = 'Hors service';
break;
default:
label = 'Autre';
}
return DropdownMenuItem(
value: status,
child: Text(label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStatus = value;
});
}
},
),
const SizedBox(height: 24),
// Section Caractéristiques physiques
Text(
'Caractéristiques physiques',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 16),
// Poids
TextFormField(
controller: _weightController,
decoration: const InputDecoration(
labelText: 'Poids à vide (kg)',
hintText: 'ex: 15.5',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
return 'Veuillez entrer un nombre valide';
}
}
return null;
},
),
const SizedBox(height: 16),
// Dimensions
Row(
children: [
Expanded(
child: TextFormField(
controller: _lengthController,
decoration: const InputDecoration(
labelText: 'Longueur (cm)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
return 'Nombre invalide';
}
}
return null;
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _widthController,
decoration: const InputDecoration(
labelText: 'Largeur (cm)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
return 'Nombre invalide';
}
}
return null;
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _heightController,
decoration: const InputDecoration(
labelText: 'Hauteur (cm)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
return 'Nombre invalide';
}
}
return null;
},
),
),
],
),
const SizedBox(height: 24),
// Section Équipements
Text(
'Équipements dans ce container',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 16),
// Liste des équipements sélectionnés
if (_selectedEquipmentIds.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedEquipmentIds.map((id) {
return Chip(
label: Text(id),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() {
_selectedEquipmentIds.remove(id);
});
},
);
}).toList(),
),
],
),
)
else
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
child: const Center(
child: Text(
'Aucun équipement sélectionné',
style: TextStyle(color: Colors.grey),
),
),
),
const SizedBox(height: 12),
// Bouton pour ajouter des équipements
OutlinedButton.icon(
onPressed: _selectEquipment,
icon: const Icon(Icons.add),
label: const Text('Ajouter des équipements'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
const SizedBox(height: 24),
// Notes
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
hintText: 'Informations additionnelles...',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.notes),
),
maxLines: 3,
),
const SizedBox(height: 32),
// Boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: _saveContainer,
icon: const Icon(Icons.save, color: Colors.white),
label: Text(
_isEditing ? 'Mettre à jour' : 'Créer',
style: const TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
],
),
),
);
}
Future<void> _selectEquipment() async {
final equipmentProvider = context.read<EquipmentProvider>();
await showDialog(
context: context,
builder: (context) => _EquipmentSelectorDialog(
selectedIds: _selectedEquipmentIds,
equipmentProvider: equipmentProvider,
),
);
setState(() {});
}
Future<bool> _isIdUnique(String id) async {
final provider = context.read<ContainerProvider>();
final container = await provider.getContainerById(id);
return container == null;
}
Future<void> _saveContainer() async {
if (!_formKey.currentState!.validate()) {
return;
}
try {
final containerProvider = context.read<ContainerProvider>();
if (_isEditing) {
await _updateContainer(containerProvider);
} else {
await _createSingleContainer(containerProvider);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
}
Future<void> _createSingleContainer(ContainerProvider provider) async {
final baseId = _idController.text.trim();
// Vérifier l'unicité de l'ID directement
String uniqueId = baseId;
if (!await _isIdUnique(baseId)) {
uniqueId = '${baseId}_${DateTime.now().millisecondsSinceEpoch}';
}
final container = ContainerModel(
id: uniqueId,
name: _nameController.text.trim(),
type: _selectedType,
status: _selectedStatus,
equipmentIds: _selectedEquipmentIds.toList(),
weight: _weightController.text.isNotEmpty
? double.tryParse(_weightController.text)
: null,
length: _lengthController.text.isNotEmpty
? double.tryParse(_lengthController.text)
: null,
width: _widthController.text.isNotEmpty
? double.tryParse(_widthController.text)
: null,
height: _heightController.text.isNotEmpty
? double.tryParse(_heightController.text)
: null,
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await provider.createContainer(container);
// Mettre à jour les parentBoxIds des équipements
for (final equipmentId in _selectedEquipmentIds) {
try {
await provider.addEquipmentToContainer(
containerId: uniqueId,
equipmentId: equipmentId,
);
} catch (e) {
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
}
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Container créé avec succès')),
);
}
}
Future<void> _updateContainer(ContainerProvider provider) async {
final container = widget.container!;
await provider.updateContainer(container.id, {
'name': _nameController.text.trim(),
'type': containerTypeToString(_selectedType),
'status': equipmentStatusToString(_selectedStatus),
'equipmentIds': _selectedEquipmentIds.toList(),
'weight': _weightController.text.isNotEmpty
? double.tryParse(_weightController.text)
: null,
'length': _lengthController.text.isNotEmpty
? double.tryParse(_lengthController.text)
: null,
'width': _widthController.text.isNotEmpty
? double.tryParse(_widthController.text)
: null,
'height': _heightController.text.isNotEmpty
? double.tryParse(_heightController.text)
: null,
'notes': _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
});
// Gérer les équipements ajoutés
final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet());
for (final equipmentId in addedEquipment) {
try {
await provider.addEquipmentToContainer(
containerId: container.id,
equipmentId: equipmentId,
);
} catch (e) {
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
}
}
// Gérer les équipements retirés
final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds);
for (final equipmentId in removedEquipment) {
try {
await provider.removeEquipmentFromContainer(
containerId: container.id,
equipmentId: equipmentId,
);
} catch (e) {
print('Erreur lors du retrait de l\'équipement $equipmentId: $e');
}
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Container mis à jour avec succès')),
);
}
}
@override
void dispose() {
_nameController.dispose();
_idController.dispose();
_weightController.dispose();
_lengthController.dispose();
_widthController.dispose();
_heightController.dispose();
_notesController.dispose();
super.dispose();
}
}
/// Widget de dialogue pour sélectionner les équipements
class _EquipmentSelectorDialog extends StatefulWidget {
final Set<String> selectedIds;
final EquipmentProvider equipmentProvider;
const _EquipmentSelectorDialog({
required this.selectedIds,
required this.equipmentProvider,
});
@override
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState();
}
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
final TextEditingController _searchController = TextEditingController();
EquipmentCategory? _filterCategory;
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.height * 0.8,
padding: const EdgeInsets.all(24),
child: Column(
children: [
// En-tête
Row(
children: [
const Icon(Icons.inventory, color: AppColors.rouge),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Sélectionner des équipements',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const Divider(),
const SizedBox(height: 16),
// Barre de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher un équipement...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
const SizedBox(height: 16),
// Filtres par catégorie
SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
ChoiceChip(
label: const Text('Tout'),
selected: _filterCategory == null,
onSelected: (selected) {
setState(() {
_filterCategory = null;
});
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: _filterCategory == null ? Colors.white : Colors.black,
),
),
const SizedBox(width: 8),
...EquipmentCategory.values.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(_getCategoryLabel(category)),
selected: _filterCategory == category,
onSelected: (selected) {
setState(() {
_filterCategory = selected ? category : null;
});
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: _filterCategory == category ? Colors.white : Colors.black,
),
),
);
}),
],
),
),
const SizedBox(height: 16),
// Compteur de sélection
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.check_circle, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'${widget.selectedIds.length} équipement(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 16),
// Liste des équipements
Expanded(
child: StreamBuilder<List<EquipmentModel>>(
stream: widget.equipmentProvider.equipmentStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Erreur: ${snapshot.error}'));
}
var equipment = snapshot.data ?? [];
// Filtrer par catégorie
if (_filterCategory != null) {
equipment = equipment.where((e) => e.category == _filterCategory).toList();
}
// Filtrer par recherche
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
equipment = equipment.where((e) {
return e.id.toLowerCase().contains(query) ||
(e.brand?.toLowerCase().contains(query) ?? false) ||
(e.model?.toLowerCase().contains(query) ?? false);
}).toList();
}
if (equipment.isEmpty) {
return const Center(
child: Text('Aucun équipement trouvé'),
);
}
return ListView.builder(
itemCount: equipment.length,
itemBuilder: (context, index) {
final item = equipment[index];
final isSelected = widget.selectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
widget.selectedIds.add(item.id);
} else {
widget.selectedIds.remove(item.id);
}
});
},
title: Text(
item.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.brand != null || item.model != null)
Text('${item.brand ?? ''} ${item.model ?? ''}'),
const SizedBox(height: 4),
Text(
_getCategoryLabel(item.category),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
secondary: Icon(
_getCategoryIcon(item.category),
color: AppColors.rouge,
),
activeColor: AppColors.rouge,
);
},
);
},
),
),
// Boutons d'action
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
child: const Text(
'Valider',
style: TextStyle(color: Colors.white),
),
),
],
),
],
),
),
);
}
String _getCategoryLabel(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';
}
}
IconData _getCategoryIcon(EquipmentCategory category) {
switch (category) {
case EquipmentCategory.lighting:
return Icons.lightbulb;
case EquipmentCategory.sound:
return Icons.speaker;
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;
case EquipmentCategory.cable:
return Icons.cable;
case EquipmentCategory.other:
return Icons.category;
}
}
}