Files
EM2_ERP/em2rp/lib/views/container_management_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

755 lines
23 KiB
Dart

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/providers/equipment_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/views/equipment_detail_page.dart';
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_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_search_bar.dart';
import 'package:em2rp/views/widgets/management/management_card.dart';
import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/utils/debug_log.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;
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
@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,
),
],
],
)
: 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<bool>(
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<LocalUserProvider>().signOut();
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/login');
}
}
},
),
],
),
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 ManagementSearchBar(
controller: _searchController,
hintText: 'Rechercher un container...',
onChanged: (value) {
context.read<ContainerProvider>().setSearchQuery(value);
},
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode,
showSelectionModeButton: !isSelectionMode,
additionalActions: [
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.qr_code_scanner, color: AppColors.rouge),
tooltip: 'Scanner un QR Code',
onPressed: _scanQRCode,
),
],
);
}
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, type.label),
);
}),
],
),
),
);
}
Widget _buildTypeChip(ContainerType? type, String label) {
final isSelected = _selectedType == type;
final color = isSelected ? Colors.white : AppColors.noir;
return ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (type != null) ...[
type.getIcon(size: 16, color: color),
const SizedBox(width: 8),
],
Text(label),
],
),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedType = selected ? type : null;
context.read<ContainerProvider>().setSelectedType(_selectedType);
});
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: color,
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, type.label);
}),
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 ManagementList<ContainerModel>(
stream: provider.containersStream,
cachedItems: _cachedContainers,
emptyMessage: 'Aucun container trouvé',
emptyIcon: Icons.inventory_2_outlined,
onDataReceived: (items) {
_cachedContainers = items;
},
itemBuilder: (container) => _buildContainerCard(container),
);
},
);
}
Widget _buildContainerCard(ContainerModel container) {
final isSelected = isItemSelected(container.id);
return ManagementCard<ContainerModel>(
item: container,
getId: (c) => c.id,
getTitle: (c) => c.id,
getSubtitle: (c) => c.name,
getIcon: (c) => c.type.getIcon(
size: 40,
color: AppColors.rouge,
),
getInfoChips: (c) => [
InfoChip(
label: c.type.label,
icon: Icons.category,
),
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);
},
);
}
void _handleMenuAction(String action, ContainerModel container) {
switch (action) {
case 'view':
_viewContainerDetails(container);
break;
case 'edit':
_editContainer(container);
break;
case 'qr':
_showQRCode(container);
break;
case 'delete':
_deleteContainer(container);
break;
}
}
/// Afficher le QR code d'un conteneur
void _showQRCode(ContainerModel container) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forContainer(container),
);
}
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;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
try {
// 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;
}
}
// Fermer l'indicateur de chargement
if (mounted) {
Navigator.of(context).pop();
}
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 avec le widget générique
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeFormatSelectorDialog<ContainerModel>(
itemList: selectedContainers,
getId: (c) => c.id,
getTitle: (c) => c.name,
getDetails: (ContainerModel c) {
final equipment = containerEquipmentMap[c.id] ?? <EquipmentModel>[];
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)',
),
);
}
} catch (e) {
// Fermer l'indicateur si une erreur survient
if (mounted) {
Navigator.of(context).pop();
}
DebugLog.error('[ContainerManagementPage] Error generating QR codes', e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la génération : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
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')),
);
}
}
}
}
/// Scanner un QR Code et ouvrir la vue de détail correspondante
Future<void> _scanQRCode() async {
try {
// Ouvrir le scanner
final scannedCode = await showDialog<String>(
context: context,
builder: (context) => const QRCodeScannerDialog(),
);
if (scannedCode == null || scannedCode.isEmpty) {
return; // L'utilisateur a annulé
}
if (!mounted) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
// Rechercher d'abord dans les conteneurs
final containerProvider = context.read<ContainerProvider>();
if (containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
}
final container = containerProvider.containers.firstWhere(
(c) => c.id == scannedCode,
orElse: () => ContainerModel(
id: '',
name: '',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
Navigator.of(context).pop(); // Fermer l'indicateur
}
if (container.id.isNotEmpty) {
// Conteneur trouvé
if (mounted) {
Navigator.pushNamed(
context,
'/container_detail',
arguments: container,
);
}
return;
}
// Si pas trouvé dans les conteneurs, chercher dans les équipements
final equipmentProvider = context.read<EquipmentProvider>();
await equipmentProvider.ensureLoaded();
final equipment = equipmentProvider.allEquipment.firstWhere(
(eq) => eq.id == scannedCode,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (equipment.id.isNotEmpty) {
// Équipement trouvé
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentDetailPage(equipment: equipment),
),
);
}
return;
}
// Rien trouvé
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun conteneur ou équipement trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
DebugLog.error('[ContainerManagementPage] Error scanning QR code', e);
if (mounted) {
// Fermer l'indicateur si ouvert
Navigator.of(context).popUntil((route) => route.isFirst);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du scan : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}