Files
EM2_ERP/em2rp/lib/views/container_detail_page.dart
ElPoyo b79791ff7a refactor: Ajout des sous-catégories et refonte de la gestion de l'appartenance
Cette mise à jour structurelle améliore la classification des équipements en introduisant la notion de sous-catégories et supprime la gestion directe de l'appartenance d'un équipement à une boîte (`parentBoxIds`). L'appartenance est désormais uniquement définie côté conteneur. Une nouvelle catégorie "Régie / Backline" est également ajoutée.

**Changements majeurs :**

-   **Suppression de `parentBoxIds` sur `EquipmentModel` :**
    -   Le champ `parentBoxIds` a été retiré du modèle de données `EquipmentModel` et de toutes les logiques associées (création, mise à jour, copie).
    -   La responsabilité de lier un équipement à un conteneur est désormais exclusivement gérée par le `ContainerModel` via sa liste `equipmentIds`.
    -   La logique de synchronisation complexe dans `EquipmentFormPage` qui mettait à jour les conteneurs lors de la modification d'un équipement a été entièrement supprimée, simplifiant considérablement le code.
    -   Le sélecteur de boîtes parentes (`ParentBoxesSelector`) a été retiré du formulaire d'équipement.

-   **Ajout des sous-catégories :**
    -   Un champ optionnel `subCategory` (String) a été ajouté au `EquipmentModel`.
    -   Le formulaire de création/modification d'équipement inclut désormais un nouveau champ "Sous-catégorie" avec autocomplétion.
    -   Ce champ est contextuel : il propose des suggestions basées sur les sous-catégories existantes pour la catégorie principale sélectionnée (ex: "Console", "Micro" pour la catégorie "Son").
    -   La sous-catégorie est maintenant affichée sur les fiches de détail des équipements et dans les listes de la page de gestion, améliorant la visibilité du classement.

**Nouvelle catégorie d'équipement :**

-   Une nouvelle catégorie `backline` ("Régie / Backline") a été ajoutée à `EquipmentCategory` avec une icône (`Icons.piano`) et une couleur associée.

**Refactorisation et nettoyage :**

-   Le `EquipmentProvider` et `EquipmentService` ont été mis à jour pour charger et filtrer les sous-catégories.
-   De nombreuses instanciations d'un `EquipmentModel` vide (`dummy`) à travers l'application ont été nettoyées pour retirer la référence à `parentBoxIds`.

-   **Version de l'application :**
    -   La version a été incrémentée à `1.0.4`.
2026-01-17 12:07:20 +01:00

778 lines
22 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/views/equipment_detail_page.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:intl/intl.dart';
class ContainerDetailPage extends StatefulWidget {
final ContainerModel container;
const ContainerDetailPage({super.key, required this.container});
@override
State<ContainerDetailPage> createState() => _ContainerDetailPageState();
}
class _ContainerDetailPageState extends State<ContainerDetailPage> {
late ContainerModel _container;
List<EquipmentModel> _equipmentList = [];
bool _isLoadingEquipment = true;
@override
void initState() {
super.initState();
_container = widget.container;
_loadEquipment();
}
Future<void> _loadEquipment() async {
setState(() {
_isLoadingEquipment = true;
});
try {
final containerProvider = context.read<ContainerProvider>();
final equipment = await containerProvider.getContainerEquipment(_container.id);
setState(() {
_equipmentList = equipment;
_isLoadingEquipment = false;
});
} catch (e) {
setState(() {
_isLoadingEquipment = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du chargement: $e')),
);
}
}
}
Future<void> _refreshContainer() async {
final containerProvider = context.read<ContainerProvider>();
final updated = await containerProvider.getContainerById(_container.id);
if (updated != null) {
setState(() {
_container = updated;
});
await _loadEquipment();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détails de la boite'),
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: _editContainer,
),
IconButton(
icon: const Icon(Icons.qr_code),
tooltip: 'QR Code',
onPressed: _showQRCode,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: _handleMenuAction,
itemBuilder: (context) => [
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)),
],
),
),
],
),
],
),
body: RefreshIndicator(
onRefresh: _refreshContainer,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildHeaderCard(),
const SizedBox(height: 16),
_buildPhysicalCharacteristics(),
const SizedBox(height: 16),
_buildEquipmentSection(),
const SizedBox(height: 16),
if (_container.notes != null && _container.notes!.isNotEmpty)
_buildNotesSection(),
const SizedBox(height: 16),
_buildHistorySection(),
],
),
),
);
}
Widget _buildHeaderCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_container.type.getIcon(size:60, color:AppColors.rouge),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_container.id,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_container.name,
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade700,
),
),
],
),
),
],
),
const Divider(height: 32),
Row(
children: [
Expanded(
child: _buildInfoItem(
'Type',
_container.type.label,
Icons.category,
),
),
Expanded(
child: _buildInfoItem(
'Statut',
_getStatusLabel(_container.status),
Icons.info,
statusColor: _getStatusColor(_container.status),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildInfoItem(
'Équipements',
'${_container.itemCount}',
Icons.inventory,
),
),
Expanded(
child: _buildInfoItem(
'Poids total',
_calculateTotalWeight(),
Icons.scale,
),
),
],
),
],
),
),
);
}
Widget _buildPhysicalCharacteristics() {
final hasDimensions = _container.length != null ||
_container.width != null ||
_container.height != null;
final hasWeight = _container.weight != null;
final hasVolume = _container.volume != null;
if (!hasDimensions && !hasWeight) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Caractéristiques physiques',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(height: 24),
if (hasWeight)
_buildCharacteristicRow(
'Poids à vide',
'${_container.weight} kg',
Icons.scale,
),
if (hasDimensions) ...[
if (hasWeight) const SizedBox(height: 12),
_buildCharacteristicRow(
'Dimensions (L×l×H)',
'${_container.length ?? '?'} × ${_container.width ?? '?'} × ${_container.height ?? '?'} cm',
Icons.straighten,
),
],
if (hasVolume) ...[
const SizedBox(height: 12),
_buildCharacteristicRow(
'Volume',
'${_container.volume!.toStringAsFixed(3)}',
Icons.view_in_ar,
),
],
],
),
),
);
}
Widget _buildEquipmentSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Contenu du container',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (_isLoadingEquipment)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
)
else if (_equipmentList.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
children: [
Icon(
Icons.inventory_2_outlined,
size: 60,
color: Colors.grey.shade400,
),
const SizedBox(height: 12),
Text(
'Aucun équipement dans ce container',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _equipmentList.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final equipment = _equipmentList[index];
return _buildEquipmentTile(equipment);
},
),
],
),
),
);
}
Widget _buildEquipmentTile(EquipmentModel equipment) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8),
leading: CircleAvatar(
backgroundColor: AppColors.rouge.withOpacity(0.1),
child: const Icon(Icons.inventory_2, color: AppColors.rouge),
),
title: Text(
equipment.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (equipment.brand != null || equipment.model != null)
Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'),
const SizedBox(height: 4),
Row(
children: [
_buildSmallBadge(
_getCategoryLabel(equipment.category),
Colors.blue,
),
const SizedBox(width: 8),
if (equipment.weight != null)
_buildSmallBadge(
'${equipment.weight} kg',
Colors.grey,
),
],
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility, size: 20),
tooltip: 'Voir détails',
onPressed: () => _viewEquipment(equipment),
),
IconButton(
icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20),
tooltip: 'Retirer',
onPressed: () => _removeEquipment(equipment),
),
],
),
);
}
Widget _buildNotesSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Notes',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(height: 24),
Text(
_container.notes!,
style: const TextStyle(fontSize: 14),
),
],
),
),
);
}
Widget _buildHistorySection() {
if (_container.history.isEmpty) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Historique',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(height: 24),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _container.history.length > 10
? 10
: _container.history.length,
separatorBuilder: (context, index) => const Divider(height: 16),
itemBuilder: (context, index) {
final entry = _container.history[
_container.history.length - 1 - index]; // Plus récent en premier
return _buildHistoryEntry(entry);
},
),
],
),
),
);
}
Widget _buildHistoryEntry(ContainerHistoryEntry entry) {
IconData icon;
Color color;
String description;
switch (entry.action) {
case 'equipment_added':
icon = Icons.add_circle;
color = Colors.green;
description = 'Équipement ajouté: ${entry.equipmentId ?? "?"}';
break;
case 'equipment_removed':
icon = Icons.remove_circle;
color = Colors.red;
description = 'Équipement retiré: ${entry.equipmentId ?? "?"}';
break;
case 'status_change':
icon = Icons.sync;
color = Colors.blue;
description =
'Statut changé: ${entry.previousValue}${entry.newValue}';
break;
default:
icon = Icons.info;
color = Colors.grey;
description = entry.action;
}
return Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
description,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 2),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(entry.timestamp),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
],
);
}
Widget _buildInfoItem(String label, String value, IconData icon,
{Color? statusColor}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: Colors.grey.shade600),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
],
);
}
Widget _buildCharacteristicRow(String label, String value, IconData icon) {
return Row(
children: [
Icon(icon, size: 20, color: AppColors.rouge),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: const TextStyle(fontSize: 14),
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildSmallBadge(String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.bold,
),
),
);
}
String _calculateTotalWeight() {
if (_equipmentList.isEmpty && _container.weight == null) {
return '-';
}
final totalWeight = _container.calculateTotalWeight(_equipmentList);
return '${totalWeight.toStringAsFixed(1)} kg';
}
String _getStatusLabel(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return 'Disponible';
case EquipmentStatus.inUse:
return 'En prestation';
case EquipmentStatus.maintenance:
return 'Maintenance';
case EquipmentStatus.outOfService:
return 'Hors service';
default:
return 'Autre';
}
}
Color _getStatusColor(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return Colors.green;
case EquipmentStatus.inUse:
return Colors.orange;
case EquipmentStatus.maintenance:
return Colors.blue;
case EquipmentStatus.outOfService:
return Colors.red;
default:
return Colors.grey;
}
}
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.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other:
return 'Autre';
}
}
void _handleMenuAction(String action) {
if (action == 'delete') {
_deleteContainer();
}
}
void _editContainer() {
Navigator.pushNamed(
context,
'/container_form',
arguments: _container,
).then((_) => _refreshContainer());
}
void _showQRCode() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('QR Code - ${_container.name}'),
content: SizedBox(
width: 250,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
QrImageView(
data: _container.id,
version: QrVersions.auto,
size: 200,
),
const SizedBox(height: 16),
Text(
_container.id,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
void _viewEquipment(EquipmentModel equipment) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentDetailPage(equipment: equipment),
),
);
}
Future<void> _removeEquipment(EquipmentModel equipment) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Retirer l\'équipement'),
content: Text(
'Êtes-vous sûr de vouloir retirer "${equipment.id}" de ce container ?',
),
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('Retirer'),
),
],
),
);
if (confirm == true && mounted) {
try {
await context.read<ContainerProvider>().removeEquipmentFromContainer(
containerId: _container.id,
equipmentId: equipment.id,
);
await _refreshContainer();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Équipement retiré avec succès')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
}
}
Future<void> _deleteContainer() 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) {
Navigator.pop(context);
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: $e')),
);
}
}
}
}
}