Files
EM2_ERP/em2rp/lib/views/widgets/event/equipment_selection_dialog.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

2461 lines
93 KiB
Dart

import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/event_availability_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart';
/// Type de sélection dans le dialog
enum SelectionType { equipment, container }
/// Statut de conflit pour un conteneur
enum ContainerConflictStatus {
none, // Aucun conflit
partial, // Au moins un enfant en conflit
complete, // Tous les enfants en conflit
}
/// Informations sur les conflits d'un conteneur
class ContainerConflictInfo {
final ContainerConflictStatus status;
final List<String> conflictingEquipmentIds;
final int totalChildren;
ContainerConflictInfo({
required this.status,
required this.conflictingEquipmentIds,
required this.totalChildren,
});
String get description {
if (status == ContainerConflictStatus.none) return '';
if (status == ContainerConflictStatus.complete) {
return 'Tous les équipements sont déjà utilisés';
}
return '${conflictingEquipmentIds.length}/${totalChildren} équipement(s) déjà utilisé(s)';
}
}
/// Item sélectionné (équipement ou conteneur)
class SelectedItem {
final String id;
final String name;
final SelectionType type;
final int quantity; // Pour consommables/câbles
SelectedItem({
required this.id,
required this.name,
required this.type,
this.quantity = 1,
});
SelectedItem copyWith({int? quantity}) {
return SelectedItem(
id: id,
name: name,
type: type,
quantity: quantity ?? this.quantity,
);
}
}
/// Dialog complet de sélection de matériel pour un événement
class EquipmentSelectionDialog extends StatefulWidget {
final DateTime startDate;
final DateTime endDate;
final List<EventEquipment> alreadyAssigned;
final List<String> alreadyAssignedContainers;
final String? excludeEventId;
const EquipmentSelectionDialog({
super.key,
required this.startDate,
required this.endDate,
this.alreadyAssigned = const [],
this.alreadyAssignedContainers = const [],
this.excludeEventId,
});
@override
State<EquipmentSelectionDialog> createState() => _EquipmentSelectionDialogState();
}
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController(); // Préserve la position de scroll
final EventAvailabilityService _availabilityService = EventAvailabilityService();
final DataService _dataService = DataService(apiService);
EquipmentCategory? _selectedCategory;
Map<String, SelectedItem> _selectedItems = {};
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
Map<String, int> _availableQuantities = {}; // Pour consommables
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
// NOUVEAU : IDs en conflit récupérés en batch
Set<String> _conflictingEquipmentIds = {};
Set<String> _conflictingContainerIds = {};
Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID
Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables
bool _isLoadingQuantities = false;
bool _isLoadingConflicts = false;
String _searchQuery = '';
// Nouvelles options d'affichage
bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit
bool _containersExpanded = true; // Section "Boîtes" dépliée
bool _equipmentExpanded = true; // Section "Tous les équipements" dépliée
// Cache pour éviter les rebuilds inutiles
List<ContainerModel> _cachedContainers = [];
List<EquipmentModel> _cachedEquipment = [];
bool _initialDataLoaded = false;
@override
void initState() {
super.initState();
// Charger immédiatement les données de manière asynchrone
_initializeData();
}
/// Initialise toutes les données nécessaires
Future<void> _initializeData() async {
try {
// 1. S'assurer que les équipements et conteneurs sont chargés
await _ensureEquipmentsLoaded();
// 2. Mettre à jour le cache immédiatement après le chargement
if (mounted) {
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
setState(() {
// Utiliser allEquipment pour avoir TOUS les équipements sans filtres
_cachedEquipment = equipmentProvider.allEquipment;
_cachedContainers = containerProvider.containers;
_initialDataLoaded = true;
});
DebugLog.info('[EquipmentSelectionDialog] Cache updated: ${_cachedEquipment.length} equipment(s), ${_cachedContainers.length} container(s)');
}
// 3. Initialiser la sélection avec le matériel déjà assigné
await _initializeAlreadyAssigned();
// 4. Charger les quantités et conflits en parallèle
await Future.wait([
_loadAvailableQuantities(),
_loadEquipmentConflicts(),
]);
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error during initialization', e);
}
}
/// S'assure que les équipements sont chargés avant d'utiliser le dialog
Future<void> _ensureEquipmentsLoaded() async {
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
DebugLog.info('[EquipmentSelectionDialog] Starting equipment loading...');
// Forcer le chargement et attendre qu'il soit terminé
await equipmentProvider.ensureLoaded();
// Attendre que le chargement soit vraiment terminé
while (equipmentProvider.isLoading) {
await Future.delayed(const Duration(milliseconds: 100));
}
// Vérifier qu'on a bien des équipements chargés
if (equipmentProvider.allEquipment.isEmpty) {
DebugLog.warning('[EquipmentSelectionDialog] No equipment loaded after ensureLoaded!');
}
// Charger aussi les conteneurs si nécessaire
if (containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
// Attendre que le chargement des conteneurs soit terminé
while (containerProvider.isLoading) {
await Future.delayed(const Duration(milliseconds: 100));
}
}
DebugLog.info('[EquipmentSelectionDialog] Data loaded: ${equipmentProvider.allEquipment.length} equipment(s), ${containerProvider.containers.length} container(s)');
}
/// Initialise la sélection avec le matériel déjà assigné
Future<void> _initializeAlreadyAssigned() async {
final Map<String, SelectedItem> initialSelection = {};
// Ajouter les équipements déjà assignés
for (var eq in widget.alreadyAssigned) {
initialSelection[eq.equipmentId] = SelectedItem(
id: eq.equipmentId,
name: eq.equipmentId,
type: SelectionType.equipment,
quantity: eq.quantity,
);
}
// Ajouter les conteneurs déjà assignés
if (widget.alreadyAssignedContainers.isNotEmpty) {
try {
final containerProvider = context.read<ContainerProvider>();
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
for (var containerId in widget.alreadyAssignedContainers) {
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
initialSelection[containerId] = SelectedItem(
id: containerId,
name: container.name,
type: SelectionType.container,
);
// Charger le cache des enfants
_containerEquipmentCache[containerId] = List.from(container.equipmentIds);
// Ajouter les enfants comme sélectionnés aussi
for (var equipmentId in container.equipmentIds) {
if (!initialSelection.containsKey(equipmentId)) {
initialSelection[equipmentId] = SelectedItem(
id: equipmentId,
name: equipmentId,
type: SelectionType.equipment,
quantity: 1,
);
}
}
}
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error loading already assigned containers', e);
}
}
// Mettre à jour la sélection et notifier
if (mounted && initialSelection.isNotEmpty) {
setState(() {
_selectedItems = initialSelection;
});
_selectionChangeNotifier.value++;
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
}
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose(); // Nettoyer le ScrollController
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
super.dispose();
}
/// Charge les quantités disponibles pour tous les consommables/câbles
Future<void> _loadAvailableQuantities() async {
if (!mounted) return;
setState(() => _isLoadingQuantities = true);
try {
final equipmentProvider = context.read<EquipmentProvider>();
// Utiliser directement allEquipment du provider (déjà chargé)
final equipment = equipmentProvider.allEquipment;
final consumables = equipment.where((eq) =>
eq.category == EquipmentCategory.consumable ||
eq.category == EquipmentCategory.cable);
for (var eq in consumables) {
final available = await _availabilityService.getAvailableQuantity(
equipment: eq,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
_availableQuantities[eq.id] = available;
}
} catch (e) {
DebugLog.error('Error loading quantities', e);
} finally {
if (mounted) setState(() => _isLoadingQuantities = false);
}
}
/// Charge les conflits de disponibilité pour tous les équipements et conteneurs
/// Version optimisée : un seul appel API au lieu d'un par équipement
Future<void> _loadEquipmentConflicts() async {
setState(() => _isLoadingConflicts = true);
try {
DebugLog.info('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
final startTime = DateTime.now();
// UN SEUL appel API pour récupérer TOUS les équipements en conflit
final result = await _dataService.getConflictingEquipmentIds(
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
installationTime: 0, // TODO: Récupérer depuis l'événement si nécessaire
disassemblyTime: 0,
);
final endTime = DateTime.now();
final duration = endTime.difference(startTime);
DebugLog.info('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
// Extraire les IDs en conflit
final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toSet() ?? {};
final conflictingContainerIds = (result['conflictingContainerIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toSet() ?? {};
final conflictDetails = result['conflictDetails'] as Map<String, dynamic>? ?? {};
final equipmentQuantities = result['equipmentQuantities'] as Map<String, dynamic>? ?? {};
DebugLog.info('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
DebugLog.info('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
if (mounted) {
setState(() {
_conflictingEquipmentIds = conflictingEquipmentIds;
_conflictingContainerIds = conflictingContainerIds;
_conflictDetails = conflictDetails;
_equipmentQuantities = equipmentQuantities;
});
}
// Mettre à jour les statuts de conteneurs
await _updateContainerConflictStatus();
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error loading conflicts', e);
} finally {
if (mounted) setState(() => _isLoadingConflicts = false);
}
}
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
Future<void> _updateContainerConflictStatus() async {
if (!mounted) return; // Vérifier si le widget est toujours monté
try {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.containersStream.first;
if (!mounted) return; // Vérifier à nouveau après l'async
for (var container in containers) {
// Vérifier si le conteneur lui-même est en conflit
if (_conflictingContainerIds.contains(container.id)) {
_containerConflicts[container.id] = ContainerConflictInfo(
status: ContainerConflictStatus.complete,
conflictingEquipmentIds: [],
totalChildren: container.equipmentIds.length,
);
continue;
}
// Vérifier si des équipements enfants sont en conflit
final conflictingChildren = container.equipmentIds
.where((eqId) => _conflictingEquipmentIds.contains(eqId))
.toList();
if (conflictingChildren.isNotEmpty) {
final status = conflictingChildren.length == container.equipmentIds.length
? ContainerConflictStatus.complete
: ContainerConflictStatus.partial;
_containerConflicts[container.id] = ContainerConflictInfo(
status: status,
conflictingEquipmentIds: conflictingChildren,
totalChildren: container.equipmentIds.length,
);
DebugLog.info('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
}
}
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
}
}
/// Récupère les détails des conflits pour un équipement/conteneur donné
List<Map<String, dynamic>> _getConflictDetailsFor(String id) {
final details = _conflictDetails[id];
if (details == null) return [];
if (details is List) {
return details.cast<Map<String, dynamic>>();
}
return [];
}
/// Construit l'affichage des quantités pour les câbles/consommables
Widget _buildQuantityInfo(EquipmentModel equipment) {
final quantityInfo = _equipmentQuantities[equipment.id] as Map<String, dynamic>?;
if (quantityInfo == null) {
// Pas d'info de quantité, utiliser l'ancien système (availableQuantities)
final availableQty = _availableQuantities[equipment.id];
if (availableQty == null) return const SizedBox.shrink();
return Text(
'Disponible : $availableQty',
style: TextStyle(
color: availableQty > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
);
}
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
return Row(
children: [
Text(
'Disponible : $availableQuantity/$totalQuantity $unit',
style: TextStyle(
color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
if (reservations.isNotEmpty) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: () => _showQuantityDetailsDialog(equipment, quantityInfo),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.blue.shade700,
),
),
),
],
],
);
}
/// Affiche un dialog avec les détails des réservations de quantité
Future<void> _showQuantityDetailsDialog(EquipmentModel equipment, Map<String, dynamic> quantityInfo) async {
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.inventory_2, color: Colors.blue.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'Quantités - ${equipment.name}',
style: const TextStyle(fontSize: 18),
),
),
],
),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Résumé
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Quantité totale :',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
Text(
'$totalQuantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Disponible :',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
Text(
'$availableQuantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red,
fontSize: 16,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Liste des réservations
if (reservations.isNotEmpty) ...[
Text(
'Utilisé sur ${reservations.length} événement(s) :',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Column(
children: reservations.map((reservation) {
final res = reservation as Map<String, dynamic>;
final eventName = res['eventName'] as String? ?? 'Événement inconnu';
final quantity = res['quantity'] as int? ?? 0;
final viaContainer = res['viaContainer'] as String?;
final viaContainerName = res['viaContainerName'] as String?;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
dense: true,
leading: CircleAvatar(
backgroundColor: Colors.orange.shade100,
child: Text(
'$quantity',
style: TextStyle(
color: Colors.orange.shade900,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
eventName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: viaContainer != null
? Text(
'Via ${viaContainerName ?? viaContainer}',
style: const TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
),
)
: null,
trailing: Text(
'$quantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
),
);
}).toList(),
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
/// Recherche les conteneurs recommandés pour un équipement
Future<void> _findRecommendedContainers(String equipmentId) async {
try {
final containerProvider = context.read<ContainerProvider>();
// Récupérer les conteneurs depuis le stream
final containerStream = containerProvider.containersStream;
final containers = await containerStream.first;
final recommended = containers
.where((container) => container.equipmentIds.contains(equipmentId))
.toList();
if (recommended.isNotEmpty) {
setState(() {
_recommendedContainers[equipmentId] = recommended;
});
}
} catch (e) {
DebugLog.error('Error finding recommended containers', e);
}
}
/// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache
List<ContainerModel> _getParentContainers(String equipmentId) {
return _recommendedContainers[equipmentId] ?? [];
}
void _toggleSelection(String id, String name, SelectionType type, {int? maxQuantity, bool force = false}) async {
// Vérifier si l'équipement est en conflit
if (!force && type == SelectionType.equipment && _conflictingEquipmentIds.contains(id)) {
// Demander confirmation pour forcer
final shouldForce = await _showForceConfirmationDialog(id);
if (shouldForce == true) {
_toggleSelection(id, name, type, maxQuantity: maxQuantity, force: true);
}
return;
}
if (_selectedItems.containsKey(id)) {
// Désélectionner
DebugLog.info('[EquipmentSelectionDialog] Deselecting $type: $id');
DebugLog.info('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
if (type == SelectionType.container) {
// Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone
await _deselectContainerChildren(id);
}
// Mise à jour avec setState pour garantir le rebuild
if (mounted) {
setState(() {
_selectedItems.remove(id);
});
}
DebugLog.info('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
DebugLog.info('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
// Notifier le changement
_selectionChangeNotifier.value++;
} else {
// Sélectionner
DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id');
// Mise à jour avec setState pour garantir le rebuild
if (mounted) {
setState(() {
_selectedItems[id] = SelectedItem(
id: id,
name: name,
type: type,
quantity: 1,
);
});
}
// Si c'est un équipement, chercher les conteneurs recommandés
if (type == SelectionType.equipment) {
_findRecommendedContainers(id);
}
// Si c'est un conteneur, sélectionner ses enfants en cascade
if (type == SelectionType.container) {
await _selectContainerChildren(id);
}
// Notifier le changement
_selectionChangeNotifier.value++;
}
}
/// Sélectionner tous les enfants d'un conteneur
Future<void> _selectContainerChildren(String containerId) async {
try {
final containerProvider = context.read<ContainerProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
// Utiliser le cache si disponible
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final equipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.allEquipment;
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
// Mettre à jour le cache
_containerEquipmentCache[containerId] = List.from(container.equipmentIds);
// Sélectionner chaque enfant (sans bloquer, car ils sont "composés")
for (var equipmentId in container.equipmentIds) {
if (!_selectedItems.containsKey(equipmentId)) {
final eq = equipment.firstWhere(
(e) => e.id == equipmentId,
orElse: () => EquipmentModel(
id: equipmentId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
setState(() {
_selectedItems[equipmentId] = SelectedItem(
id: equipmentId,
name: eq.id,
type: SelectionType.equipment,
quantity: 1,
);
});
}
}
}
DebugLog.info('[EquipmentSelectionDialog] Selected container $containerId with ${container.equipmentIds.length} children');
} catch (e) {
DebugLog.error('Error selecting container children', e);
}
}
/// Désélectionner tous les enfants d'un conteneur
Future<void> _deselectContainerChildren(String containerId) async {
try {
final containerProvider = context.read<ContainerProvider>();
// Utiliser le cache si disponible
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
setState(() {
// Retirer les enfants de _selectedItems
for (var equipmentId in container.equipmentIds) {
_selectedItems.remove(equipmentId);
}
// Nettoyer le cache
_containerEquipmentCache.remove(containerId);
// Retirer de la liste des conteneurs expandés
_expandedContainers.remove(containerId);
});
}
DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
} catch (e) {
DebugLog.error('Error deselecting container children', e);
}
}
/// Affiche un dialog pour confirmer le forçage d'un équipement en conflit
Future<bool?> _showForceConfirmationDialog(String equipmentId) async {
final conflicts = _equipmentConflicts[equipmentId] ?? [];
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text('Équipement déjà utilisé'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cet équipement est déjà utilisé sur ${conflicts.length} événement(s) :'),
const SizedBox(height: 12),
...conflicts.map((conflict) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.event, size: 16, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conflict.conflictingEvent.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'Chevauchement : ${conflict.overlapDays} jour(s)',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
],
),
)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: const Text('Forcer quand même'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final dialogWidth = screenSize.width * 0.9;
final dialogHeight = screenSize.height * 0.85;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: dialogWidth.clamp(600.0, 1200.0),
height: dialogHeight.clamp(500.0, 900.0),
child: Column(
children: [
_buildHeader(),
_buildSearchAndFilters(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste principale
Expanded(
flex: 2,
child: _buildMainList(),
),
// Panneau latéral : sélection + recommandations
Container(
width: 320,
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
left: BorderSide(color: Colors.grey.shade300),
),
),
child: Column(
children: [
Expanded(
child: ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) => _buildSelectionPanel(),
),
),
if (_hasRecommendations)
Container(
height: 200,
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade300),
),
),
child: _buildRecommendationsPanel(),
),
],
),
),
],
),
),
_buildFooter(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
const Icon(Icons.add_circle, color: Colors.white, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Ajouter du matériel',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
Widget _buildSearchAndFilters() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Column(
children: [
// Barre de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher du matériel ou des boîtes...',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase());
},
),
const SizedBox(height: 12),
// Filtres par catégorie (pour les équipements)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('Tout', null),
const SizedBox(width: 8),
...EquipmentCategory.values.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(category.label, category),
);
}),
],
),
),
const SizedBox(height: 12),
// Checkbox pour afficher les équipements en conflit
Row(
children: [
Checkbox(
value: _showConflictingItems,
onChanged: (value) {
setState(() {
_showConflictingItems = value ?? false;
});
},
activeColor: AppColors.rouge,
),
const Text(
'Afficher les équipements déjà utilisés',
style: TextStyle(fontSize: 14),
),
const SizedBox(width: 8),
Tooltip(
message: 'Afficher les équipements et boîtes qui sont déjà utilisés durant ces dates',
child: Icon(
Icons.info_outline,
size: 18,
color: Colors.grey.shade600,
),
),
],
),
],
),
);
}
Widget _buildFilterChip(String label, EquipmentCategory? category) {
final isSelected = _selectedCategory == category;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedCategory = selected ? category : null;
});
},
selectedColor: AppColors.rouge,
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
),
);
}
Widget _buildMainList() {
// Afficher un indicateur de chargement si les données sont en cours de chargement
if (_isLoadingQuantities || _isLoadingConflicts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: AppColors.rouge),
const SizedBox(height: 16),
Text(
_isLoadingConflicts
? 'Vérification de la disponibilité...'
: 'Chargement des quantités disponibles...',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
);
}
// Vue hiérarchique unique : Boîtes en haut, TOUS les équipements en bas
return _buildHierarchicalList();
}
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
Widget _buildHierarchicalList() {
return Consumer2<ContainerProvider, EquipmentProvider>(
builder: (context, containerProvider, equipmentProvider, child) {
// Utiliser les données du cache si disponibles, sinon utiliser allEquipment des providers
final allContainers = _initialDataLoaded ? _cachedContainers : containerProvider.containers;
final allEquipment = _initialDataLoaded ? _cachedEquipment : equipmentProvider.allEquipment;
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection
return ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) {
// Filtrage des boîtes
final filteredContainers = allContainers.where((container) {
// Filtre par conflit (masquer si non cochée et en conflit)
if (!_showConflictingItems && _conflictingContainerIds.contains(container.id)) {
return false;
}
// Filtre par catégorie : afficher uniquement les boîtes contenant au moins 1 équipement de la catégorie
if (_selectedCategory != null) {
final hasEquipmentOfCategory = container.equipmentIds.any((eqId) {
final equipment = allEquipment.firstWhere(
(e) => e.id == eqId,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
return equipment.id.isNotEmpty && equipment.category == _selectedCategory;
});
if (!hasEquipmentOfCategory) {
return false;
}
}
// Filtre par recherche
if (_searchQuery.isNotEmpty) {
final searchLower = _searchQuery.toLowerCase();
return container.id.toLowerCase().contains(searchLower) ||
container.name.toLowerCase().contains(searchLower);
}
return true;
}).toList();
// Filtrage des équipements (TOUS, pas seulement les orphelins)
final filteredEquipment = allEquipment.where((eq) {
// Filtre par conflit (masquer si non cochée et en conflit)
if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) {
return false;
}
// Filtre par catégorie
if (_selectedCategory != null && eq.category != _selectedCategory) {
return false;
}
// Filtre par recherche
if (_searchQuery.isNotEmpty) {
final searchLower = _searchQuery.toLowerCase();
return eq.id.toLowerCase().contains(searchLower) ||
(eq.brand?.toLowerCase().contains(searchLower) ?? false) ||
(eq.model?.toLowerCase().contains(searchLower) ?? false);
}
return true;
}).toList();
return ListView(
controller: _scrollController, // Préserve la position de scroll
padding: const EdgeInsets.all(16),
cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll
children: [
// SECTION 1 : BOÎTES
if (filteredContainers.isNotEmpty) ...[
_buildCollapsibleSectionHeader(
'Boîtes',
Icons.inventory,
filteredContainers.length,
_containersExpanded,
(expanded) {
setState(() {
_containersExpanded = expanded;
});
},
),
const SizedBox(height: 12),
if (_containersExpanded) ...[
...filteredContainers.map((container) => _buildContainerCard(
container,
key: ValueKey('container_${container.id}'),
)),
const SizedBox(height: 24),
],
],
// SECTION 2 : TOUS LES ÉQUIPEMENTS
if (filteredEquipment.isNotEmpty) ...[
_buildCollapsibleSectionHeader(
'Tous les équipements',
Icons.inventory_2,
filteredEquipment.length,
_equipmentExpanded,
(expanded) {
setState(() {
_equipmentExpanded = expanded;
});
},
),
const SizedBox(height: 12),
if (_equipmentExpanded) ...[
...filteredEquipment.map((equipment) => _buildEquipmentCard(
equipment,
key: ValueKey('equipment_${equipment.id}'),
)),
],
],
// Message si rien n'est trouvé
if (filteredContainers.isEmpty && filteredEquipment.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucun résultat trouvé',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
],
),
),
),
],
);
},
); // Fin du ValueListenableBuilder
},
);
}
/// Header de section (version simple, gardée pour compatibilité)
Widget _buildSectionHeader(String title, IconData icon, int count) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.rouge,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
/// Header de section repliable
Widget _buildCollapsibleSectionHeader(
String title,
IconData icon,
int count,
bool isExpanded,
Function(bool) onToggle,
) {
return InkWell(
onTap: () => onToggle(!isExpanded),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.rouge.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
color: AppColors.rouge,
size: 24,
),
const SizedBox(width: 8),
Icon(icon, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.rouge,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
final isSelected = _selectedItems.containsKey(equipment.id);
final isConsumable = equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable;
final availableQty = _availableQuantities[equipment.id];
final selectedItem = _selectedItems[equipment.id];
final hasConflict = _conflictingEquipmentIds.contains(equipment.id); // CORRECTION ICI !
final conflictDetails = _getConflictDetailsFor(equipment.id);
// Bloquer la sélection si en conflit et non forcé
final canSelect = !hasConflict || isSelected;
return RepaintBoundary(
key: key,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isSelected
? const BorderSide(color: AppColors.rouge, width: 2)
: hasConflict
? BorderSide(color: Colors.orange.shade300, width: 1)
: BorderSide.none,
),
child: InkWell(
onTap: canSelect
? () => _toggleSelection(
equipment.id,
equipment.id,
SelectionType.equipment,
maxQuantity: availableQty,
)
: null,
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: hasConflict && !isSelected
? BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
)
: null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: canSelect
? (value) => _toggleSelection(
equipment.id,
equipment.id,
SelectionType.equipment,
maxQuantity: availableQty,
)
: null,
activeColor: AppColors.rouge,
),
const SizedBox(width: 12),
// Icône
equipment.category.getIcon(size: 32, color: equipment.category.color),
const SizedBox(width: 16),
// Infos
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
if (hasConflict)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade700,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning, size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(
'Déjà utilisé',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
if (equipment.brand != null || equipment.model != null)
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(),
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 14,
),
),
// Affichage des boîtes parentes
if (_getParentContainers(equipment.id).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _getParentContainers(equipment.id).map((container) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
border: Border.all(color: Colors.blue.shade300),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory, size: 12, color: Colors.blue.shade700),
const SizedBox(width: 4),
Text(
container.name,
style: TextStyle(
fontSize: 10,
color: Colors.blue.shade700,
fontWeight: FontWeight.w500,
),
),
],
),
);
}).toList(),
),
),
if (isConsumable)
Padding(
padding: const EdgeInsets.only(top: 4),
child: _buildQuantityInfo(equipment),
),
],
),
),
// Sélecteur de quantité pour consommables (toujours affiché)
if (isConsumable && availableQty != null)
_buildQuantitySelector(
equipment.id,
selectedItem ?? SelectedItem(
id: equipment.id,
name: equipment.id,
type: SelectionType.equipment,
quantity: 0, // Quantité 0 si non sélectionné
),
availableQty,
isSelected: isSelected, // Passer l'état de sélection
),
],
),
// Affichage des conflits
if (hasConflict)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.orange.shade900),
const SizedBox(width: 6),
Text(
'Utilisé sur ${conflictDetails.length} événement(s) :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
],
),
const SizedBox(height: 6),
...conflictDetails.take(2).map((detail) {
final eventName = detail['eventName'] as String? ?? 'Événement inconnu';
final viaContainer = detail['viaContainer'] as String?;
final viaContainerName = detail['viaContainerName'] as String?;
return Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$eventName',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
),
),
if (viaContainer != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'via ${viaContainerName ?? viaContainer}',
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
}),
if (conflictDetails.length > 2)
Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Text(
'... et ${conflictDetails.length - 2} autre(s)',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
fontStyle: FontStyle.italic,
),
),
),
if (!isSelected)
Padding(
padding: const EdgeInsets.only(top: 8),
child: TextButton.icon(
onPressed: () => _toggleSelection(
equipment.id,
equipment.id,
SelectionType.equipment,
maxQuantity: availableQty,
force: true,
),
icon: const Icon(Icons.warning, size: 16),
label: const Text('Forcer quand même', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(
foregroundColor: Colors.orange.shade900,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
),
),
],
),
),
],
),
),
),
),
),
);
}
/// Widget pour le sélecteur de quantité
/// Si isSelected = false, le premier clic sur + sélectionne l'item avec quantité 1
Widget _buildQuantitySelector(
String equipmentId,
SelectedItem selectedItem,
int maxQuantity, {
required bool isSelected,
}) {
final displayQuantity = isSelected ? selectedItem.quantity : 0;
return Container(
width: 120,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: isSelected && selectedItem.quantity > 1
? () {
if (mounted) {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
});
_selectionChangeNotifier.value++; // Notifier le changement
}
}
: null,
iconSize: 20,
color: isSelected && selectedItem.quantity > 1 ? AppColors.rouge : Colors.grey,
),
Expanded(
child: Text(
'$displayQuantity',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected ? Colors.black : Colors.grey,
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: (isSelected && selectedItem.quantity < maxQuantity) || !isSelected
? () {
if (!isSelected) {
// Premier clic : sélectionner avec quantité 1
_toggleSelection(
equipmentId,
selectedItem.name,
SelectionType.equipment,
maxQuantity: maxQuantity,
);
} else {
// Item déjà sélectionné : incrémenter
if (mounted) {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
});
_selectionChangeNotifier.value++; // Notifier le changement
}
}
}
: null,
iconSize: 20,
color: AppColors.rouge,
),
],
),
);
}
Widget _buildContainerCard(ContainerModel container, {Key? key}) {
final isSelected = _selectedItems.containsKey(container.id);
final isExpanded = _expandedContainers.contains(container.id);
final conflictInfo = _containerConflicts[container.id];
final hasConflict = conflictInfo != null;
final isCompleteConflict = conflictInfo?.status == ContainerConflictStatus.complete;
// Bloquer la sélection si tous les enfants sont en conflit (sauf si déjà sélectionné)
final canSelect = !isCompleteConflict || isSelected;
return RepaintBoundary(
key: key,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isSelected
? const BorderSide(color: AppColors.rouge, width: 2)
: hasConflict
? BorderSide(
color: isCompleteConflict ? Colors.red.shade300 : Colors.orange.shade300,
width: 1,
)
: BorderSide.none,
),
child: Container(
decoration: hasConflict && !isSelected
? BoxDecoration(
color: isCompleteConflict ? Colors.red.shade50 : Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
)
: null,
child: Column(
children: [
InkWell(
onTap: canSelect
? () => _toggleSelection(
container.id,
container.name,
SelectionType.container,
)
: null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: canSelect
? (value) => _toggleSelection(
container.id,
container.name,
SelectionType.container,
)
: null,
activeColor: AppColors.rouge,
),
const SizedBox(width: 12),
// Icône du conteneur
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: container.type.getIcon(size: 28, color: AppColors.rouge),
),
const SizedBox(width: 16),
// Infos principales
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
container.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
// Badge de statut de conflit
if (hasConflict)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCompleteConflict
? Colors.red.shade700
: Colors.orange.shade700,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCompleteConflict ? Icons.block : Icons.warning,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
isCompleteConflict ? 'Indisponible' : 'Partiellement utilisée',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 4),
Text(
container.name,
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 14,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.inventory_2,
size: 14,
color: Colors.blue.shade700,
),
const SizedBox(width: 4),
Text(
'${container.itemCount} équipement(s)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.blue.shade700,
),
),
if (hasConflict) ...[
const SizedBox(width: 8),
Icon(
Icons.warning,
size: 14,
color: isCompleteConflict ? Colors.red.shade700 : Colors.orange.shade700,
),
const SizedBox(width: 4),
Text(
conflictInfo.description,
style: TextStyle(
fontSize: 11,
color: isCompleteConflict ? Colors.red.shade700 : Colors.orange.shade700,
fontWeight: FontWeight.w500,
),
),
],
],
),
],
),
),
// Bouton pour déplier/replier
IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: AppColors.rouge,
),
onPressed: () {
if (isExpanded) {
_expandedContainers.remove(container.id);
} else {
_expandedContainers.add(container.id);
}
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
},
tooltip: isExpanded ? 'Replier' : 'Voir le contenu',
),
],
),
// Avertissement pour conteneur complètement indisponible
if (isCompleteConflict && !isSelected)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.block, size: 20, color: Colors.red.shade900),
const SizedBox(width: 8),
Expanded(
child: Text(
'Cette boîte ne peut pas être sélectionnée car tous ses équipements sont déjà utilisés.',
style: TextStyle(
fontSize: 12,
color: Colors.red.shade900,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
),
// Liste des enfants (si déplié)
if (isExpanded)
_buildContainerChildren(container, conflictInfo),
],
),
),
),
);
}
/// Widget pour afficher les équipements enfants d'un conteneur
Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
return StreamBuilder<List<EquipmentModel>>(
stream: provider.equipmentStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final allEquipment = snapshot.data ?? [];
final childEquipments = allEquipment
.where((eq) => container.equipmentIds.contains(eq.id))
.toList();
if (childEquipments.isEmpty) {
return Container(
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.grey.shade600),
const SizedBox(width: 8),
Text(
'Aucun équipement dans ce conteneur',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
],
),
);
}
return Container(
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.list, size: 16, color: Colors.grey.shade700),
const SizedBox(width: 6),
Text(
'Contenu de la boîte :',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
...childEquipments.map((eq) {
final hasConflict = _equipmentConflicts.containsKey(eq.id);
final conflicts = _equipmentConflicts[eq.id] ?? [];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: hasConflict ? Colors.orange.shade50 : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: hasConflict ? Colors.orange.shade300 : Colors.grey.shade300,
),
),
child: Row(
children: [
// Flèche de hiérarchie
Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey.shade600,
),
const SizedBox(width: 8),
// Icône de l'équipement
eq.category.getIcon(size: 20, color: eq.category.color),
const SizedBox(width: 12),
// Nom de l'équipement
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
eq.id,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade800,
),
),
if (eq.brand != null || eq.model != null)
Text(
'${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
),
// Indicateur de conflit
if (hasConflict) ...[
const SizedBox(width: 8),
Tooltip(
message: 'Utilisé sur ${conflicts.length} événement(s)',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.orange.shade700,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning, size: 12, color: Colors.white),
const SizedBox(width: 4),
Text(
'${conflicts.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
],
),
);
}),
],
),
);
},
);
},
);
}
Widget _buildSelectionPanel() {
// Compter uniquement les conteneurs et équipements "racine" (pas enfants de conteneurs)
final selectedContainers = _selectedItems.entries
.where((e) => e.value.type == SelectionType.container)
.toList();
// Collecter tous les IDs d'équipements qui sont enfants de conteneurs sélectionnés
final Set<String> equipmentIdsInContainers = {};
for (var containerEntry in selectedContainers) {
final childrenIds = _getContainerEquipmentIds(containerEntry.key);
equipmentIdsInContainers.addAll(childrenIds);
}
// Équipements qui ne sont PAS enfants d'un conteneur sélectionné
final selectedStandaloneEquipment = _selectedItems.entries
.where((e) => e.value.type == SelectionType.equipment)
.where((e) => !equipmentIdsInContainers.contains(e.key))
.toList();
final containerCount = selectedContainers.length;
final standaloneEquipmentCount = selectedStandaloneEquipment.length;
final totalDisplayed = containerCount + standaloneEquipmentCount;
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rouge,
),
child: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 8),
const Text(
'Sélection',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$totalDisplayed',
style: const TextStyle(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
Expanded(
child: totalDisplayed == 0
? const Center(
child: Text(
'Aucune sélection',
style: TextStyle(color: Colors.grey),
),
)
: ListView(
padding: const EdgeInsets.all(8),
children: [
if (containerCount > 0) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Boîtes ($containerCount)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
...selectedContainers.map((e) => _buildSelectedContainerTile(e.key, e.value)),
],
if (standaloneEquipmentCount > 0) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Équipements ($standaloneEquipmentCount)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
...selectedStandaloneEquipment.map((e) => _buildSelectedItemTile(e.key, e.value)),
],
],
),
),
],
);
}
/// Récupère les IDs des équipements d'un conteneur (depuis le cache)
List<String> _getContainerEquipmentIds(String containerId) {
// On doit récupérer le conteneur depuis le provider de manière synchrone
// Pour cela, on va maintenir un cache local
return _containerEquipmentCache[containerId] ?? [];
}
// Cache local pour les équipements des conteneurs
Map<String, List<String>> _containerEquipmentCache = {};
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
final isExpanded = _expandedContainers.contains(id);
final childrenIds = _getContainerEquipmentIds(id);
final childrenCount = childrenIds.length;
return Column(
children: [
ListTile(
dense: true,
leading: Icon(
Icons.inventory,
size: 20,
color: AppColors.rouge,
),
title: Text(
item.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'$childrenCount équipement(s)',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (childrenCount > 0)
IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
),
onPressed: () {
if (isExpanded) {
_expandedContainers.remove(id);
} else {
_expandedContainers.add(id);
}
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => _toggleSelection(id, item.name, item.type),
),
],
),
),
if (isExpanded && childrenCount > 0)
...childrenIds.map((equipmentId) {
final childItem = _selectedItems[equipmentId];
if (childItem != null) {
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
}
return const SizedBox.shrink();
}).toList(),
],
);
}
Widget _buildSelectedChildEquipmentTile(String id, SelectedItem item) {
return Padding(
padding: const EdgeInsets.only(left: 40),
child: ListTile(
dense: true,
leading: Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey.shade600,
),
title: Text(
item.name,
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
subtitle: item.quantity > 1
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 10))
: null,
// PAS de bouton de suppression pour les enfants
),
);
}
Widget _buildSelectedItemTile(String id, SelectedItem item) {
return ListTile(
dense: true,
leading: Icon(
Icons.inventory_2,
size: 20,
color: AppColors.rouge,
),
title: Text(
item.name,
style: const TextStyle(fontSize: 13),
),
subtitle: item.quantity > 1
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 11))
: null,
trailing: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => _toggleSelection(id, item.name, item.type),
),
);
}
bool get _hasRecommendations => _recommendedContainers.isNotEmpty;
Widget _buildRecommendationsPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade700,
),
child: const Row(
children: [
Icon(Icons.lightbulb, color: Colors.white, size: 20),
SizedBox(width: 8),
Text(
'Boîtes recommandées',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(8),
children: _recommendedContainers.values
.expand((list) => list)
.toSet() // Enlever les doublons
.map((container) => _buildRecommendedContainerTile(container))
.toList(),
),
),
],
);
}
Widget _buildRecommendedContainerTile(ContainerModel container) {
final isAlreadySelected = _selectedItems.containsKey(container.id);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
dense: true,
leading: container.type.getIcon(size: 24, color: Colors.blue.shade700),
title: Text(
container.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'${container.itemCount} équipement(s)',
style: const TextStyle(fontSize: 11),
),
trailing: isAlreadySelected
? const Icon(Icons.check_circle, color: Colors.green)
: IconButton(
icon: const Icon(Icons.add_circle_outline, color: Colors.blue),
onPressed: () => _toggleSelection(
container.id,
container.name,
SelectionType.container,
),
),
),
);
}
Widget _buildFooter() {
return ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Text(
'${_selectedItems.length} élément(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _selectedItems.isEmpty
? null
: () => Navigator.of(context).pop(_selectedItems),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
),
child: const Text('Valider la sélection'),
),
],
),
);
},
);
}
}