import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.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/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 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 alreadyAssigned; final List alreadyAssignedContainers; final String? excludeEventId; const EquipmentSelectionDialog({ super.key, required this.startDate, required this.endDate, this.alreadyAssigned = const [], this.alreadyAssignedContainers = const [], this.excludeEventId, }); @override State createState() => _EquipmentSelectionDialogState(); } class _EquipmentSelectionDialogState extends State { 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 _selectedItems = {}; final ValueNotifier _selectionChangeNotifier = ValueNotifier(0); // Pour notifier les changements de sélection sans setState Map _availableQuantities = {}; // Pour consommables Map> _recommendedContainers = {}; // Recommandations Map> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés) Map _containerConflicts = {}; // Conflits des conteneurs Set _expandedContainers = {}; // Conteneurs dépliés dans la liste // NOUVEAU : IDs en conflit récupérés en batch Set _conflictingEquipmentIds = {}; Set _conflictingContainerIds = {}; Map _conflictDetails = {}; // Détails des conflits par ID Map _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables bool _isLoadingConflicts = false; String _searchQuery = ''; // Nouvelles options d'affichage bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit // NOUVEAU : Lazy loading et pagination SelectionType _displayType = SelectionType.equipment; // Type affiché (équipements OU containers) bool _isLoadingMore = false; bool _hasMoreEquipments = true; bool _hasMoreContainers = true; String? _lastEquipmentId; String? _lastContainerId; List _paginatedEquipments = []; List _paginatedContainers = []; // Cache pour éviter les rebuilds inutiles List _cachedContainers = []; List _cachedEquipment = []; @override void initState() { super.initState(); // Ajouter le listener de scroll pour lazy loading _scrollController.addListener(_onScroll); // Charger immédiatement les données de manière asynchrone _initializeData(); } /// Gestion du scroll pour lazy loading void _onScroll() { if (_isLoadingMore) return; if (_scrollController.hasClients && _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) { // Charger la page suivante selon le type affiché if (_displayType == SelectionType.equipment && _hasMoreEquipments) { _loadNextEquipmentPage(); } else if (_displayType == SelectionType.container && _hasMoreContainers) { _loadNextContainerPage(); } } } /// Initialise toutes les données nécessaires Future _initializeData() async { try { // 1. Charger les conflits (batch optimisé) await _loadEquipmentConflicts(); // 2. Initialiser la sélection avec le matériel déjà assigné await _initializeAlreadyAssigned(); // 3. Charger la première page selon le type sélectionné if (_displayType == SelectionType.equipment) { await _loadNextEquipmentPage(); } else { await _loadNextContainerPage(); } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error initializing data', e); } } /// Initialise la sélection avec le matériel déjà assigné Future _initializeAlreadyAssigned() async { final Map 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 { // Pour les conteneurs déjà assignés, on va les chercher via l'API si nécessaire // ou créer des conteneurs temporaires for (var containerId in widget.alreadyAssignedContainers) { // Chercher dans le cache ou créer un conteneur temporaire final container = _cachedContainers.firstWhere( (c) => c.id == containerId, orElse: () => ContainerModel( id: containerId, name: 'Conteneur $containerId', 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'); } } /// Charge la page suivante d'équipements (lazy loading) Future _loadNextEquipmentPage() async { if (_isLoadingMore || !_hasMoreEquipments) return; setState(() => _isLoadingMore = true); try { final result = await _dataService.getEquipmentsPaginated( limit: 25, startAfter: _lastEquipmentId, searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null, sortBy: 'id', sortOrder: 'asc', ); final newEquipments = (result['equipments'] as List) .map((data) { final map = data as Map; final id = map['id'] as String; return EquipmentModel.fromMap(map, id); }) .toList(); if (mounted) { setState(() { _paginatedEquipments.addAll(newEquipments); _hasMoreEquipments = result['hasMore'] as bool? ?? false; _lastEquipmentId = result['lastVisible'] as String?; _isLoadingMore = false; }); DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments'); // Charger les quantités pour les consommables/câbles de cette page await _loadAvailableQuantities(newEquipments); } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e); if (mounted) { setState(() => _isLoadingMore = false); } } } /// Charge la page suivante de containers (lazy loading) Future _loadNextContainerPage() async { if (_isLoadingMore || !_hasMoreContainers) return; setState(() => _isLoadingMore = true); try { final result = await _dataService.getContainersPaginated( limit: 25, startAfter: _lastContainerId, searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, category: _selectedCategory?.name, // Filtre par catégorie d'équipements sortBy: 'id', sortOrder: 'asc', ); final containersData = result['containers'] as List; DebugLog.info('[EquipmentSelectionDialog] Raw containers data received: ${containersData.length} containers'); // D'abord, extraire TOUS les équipements final List allEquipmentsToCache = []; for (var data in containersData) { final map = data as Map; final containerId = map['id'] as String; // Debug: vérifier si le champ 'equipment' existe final hasEquipmentField = map.containsKey('equipment'); final equipmentData = map['equipment']; DebugLog.info('[EquipmentSelectionDialog] Container $containerId: hasEquipmentField=$hasEquipmentField, equipmentData type=${equipmentData?.runtimeType}, count=${equipmentData is List ? equipmentData.length : 0}'); final equipmentList = (map['equipment'] as List?) ?.map((eqData) { final eqMap = eqData as Map; final eqId = eqMap['id'] as String; DebugLog.info('[EquipmentSelectionDialog] - Equipment found: $eqId'); return EquipmentModel.fromMap(eqMap, eqId); }) .toList() ?? []; allEquipmentsToCache.addAll(equipmentList); } DebugLog.info('[EquipmentSelectionDialog] Total equipments extracted from containers: ${allEquipmentsToCache.length}'); // Créer les containers final newContainers = containersData .map((data) { final map = data as Map; final id = map['id'] as String; return ContainerModel.fromMap(map, id); }) .toList(); if (mounted) { setState(() { // Ajouter tous les équipements au cache DANS le setState for (var eq in allEquipmentsToCache) { if (!_cachedEquipment.any((e) => e.id == eq.id)) { _cachedEquipment.add(eq); } } _paginatedContainers.addAll(newContainers); _hasMoreContainers = result['hasMore'] as bool? ?? false; _lastContainerId = result['lastVisible'] as String?; _isLoadingMore = false; }); DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers'); DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}'); // Mettre à jour les statuts de conflit pour les nouveaux containers await _updateContainerConflictStatus(); } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e); if (mounted) { setState(() => _isLoadingMore = false); } } } /// Recharge depuis le début (appelé lors d'un changement de filtre/recherche) Future _reloadData() async { setState(() { _paginatedEquipments.clear(); _paginatedContainers.clear(); _lastEquipmentId = null; _lastContainerId = null; _hasMoreEquipments = true; _hasMoreContainers = true; }); if (_displayType == SelectionType.equipment) { await _loadNextEquipmentPage(); } else { await _loadNextContainerPage(); } } @override void dispose() { _searchController.dispose(); _scrollController.dispose(); // Nettoyer le ScrollController _selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier super.dispose(); } /// Charge les quantités disponibles pour les consommables/câbles d'une liste d'équipements Future _loadAvailableQuantities(List equipments) async { if (!mounted) return; try { final consumables = equipments.where((eq) => eq.category == EquipmentCategory.consumable || eq.category == EquipmentCategory.cable); for (var eq in consumables) { // Ne recharger que si on n'a pas déjà la quantité if (!_availableQuantities.containsKey(eq.id)) { 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); } } /// 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 _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?) ?.map((e) => e.toString()) .toSet() ?? {}; final conflictingContainerIds = (result['conflictingContainerIds'] as List?) ?.map((e) => e.toString()) .toSet() ?? {}; final conflictDetails = result['conflictDetails'] as Map? ?? {}; final equipmentQuantities = result['equipmentQuantities'] as Map? ?? {}; 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; // Convertir conflictDetails en equipmentConflicts pour l'affichage détaillé _equipmentConflicts.clear(); conflictDetails.forEach((itemId, conflicts) { final conflictList = (conflicts as List).map((conflict) { final conflictMap = conflict as Map; // Créer un EventModel minimal pour le conflit final conflictEvent = EventModel( id: conflictMap['eventId'] as String, name: conflictMap['eventName'] as String, description: '', startDateTime: DateTime.parse(conflictMap['startDate'] as String), endDateTime: DateTime.parse(conflictMap['endDate'] as String), basePrice: 0.0, installationTime: 0, disassemblyTime: 0, eventTypeId: '', customerId: '', address: '', latitude: 0.0, longitude: 0.0, workforce: const [], documents: const [], options: const [], status: EventStatus.confirmed, assignedEquipment: const [], assignedContainers: const [], ); // Calculer les jours de chevauchement final conflictStart = DateTime.parse(conflictMap['startDate'] as String); final conflictEnd = DateTime.parse(conflictMap['endDate'] as String); final overlapStart = widget.startDate.isAfter(conflictStart) ? widget.startDate : conflictStart; final overlapEnd = widget.endDate.isBefore(conflictEnd) ? widget.endDate : conflictEnd; final overlapDays = overlapEnd.difference(overlapStart).inDays + 1; return AvailabilityConflict( equipmentId: itemId, equipmentName: '', // Sera résolu lors de l'affichage conflictingEvent: conflictEvent, overlapDays: overlapDays.clamp(1, 999), ); }).toList(); _equipmentConflicts[itemId] = conflictList; }); }); } // 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 _updateContainerConflictStatus() async { if (!mounted) return; try { // Utiliser les containers paginés chargés for (var container in _paginatedContainers) { // 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}'); // Déclencher un rebuild pour afficher les changements visuels if (mounted) { setState(() {}); } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e); } } /// Récupère les détails des conflits pour un équipement/conteneur donné List> _getConflictDetailsFor(String id) { final details = _conflictDetails[id]; if (details == null) return []; if (details is List) { return details.cast>(); } return []; } /// Construit l'affichage des quantités pour les câbles/consommables Widget _buildQuantityInfo(EquipmentModel equipment) { final quantityInfo = _equipmentQuantities[equipment.id] as Map?; 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? ?? []; 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 _showQuantityDetailsDialog(EquipmentModel equipment, Map quantityInfo) async { final reservations = quantityInfo['reservations'] as List? ?? []; 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; 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 /// NOTE: Désactivé avec le lazy loading - on ne charge pas tous les containers d'un coup Future _findRecommendedContainers(String equipmentId) async { // Désactivé pour le moment avec le lazy loading // On pourrait implémenter une API dédiée si nécessaire return; } /// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache List _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 _selectContainerChildren(String containerId) async { try { // Chercher le container dans les données paginées ou le cache final container = [..._paginatedContainers, ..._cachedContainers].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)) { // Chercher l'équipement dans les données paginées ou le cache final eq = [..._paginatedEquipments, ..._cachedEquipment].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 _deselectContainerChildren(String containerId) async { try { // Chercher le container dans les données paginées ou le cache final container = [..._paginatedContainers, ..._cachedContainers].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 _showForceConfirmationDialog(String equipmentId) async { final conflicts = _equipmentConflicts[equipmentId] ?? []; return showDialog( 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( 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()); // Recharger depuis le début avec le nouveau filtre _reloadData(); }, ), 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, ), ), ], ), const SizedBox(height: 12), // Chip pour switcher entre Équipements et Containers Row( children: [ const Text( 'Afficher :', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), const SizedBox(width: 8), ChoiceChip( label: const Text('Équipements'), selected: _displayType == SelectionType.equipment, onSelected: (selected) { if (selected && _displayType != SelectionType.equipment) { setState(() { _displayType = SelectionType.equipment; }); _reloadData(); } }, selectedColor: AppColors.rouge, labelStyle: TextStyle( color: _displayType == SelectionType.equipment ? Colors.white : Colors.black87, ), ), const SizedBox(width: 8), ChoiceChip( label: const Text('Containers'), selected: _displayType == SelectionType.container, onSelected: (selected) { if (selected && _displayType != SelectionType.container) { setState(() { _displayType = SelectionType.container; }); _reloadData(); } }, selectedColor: AppColors.rouge, labelStyle: TextStyle( color: _displayType == SelectionType.container ? Colors.white : Colors.black87, ), ), ], ), ], ), ); } Widget _buildFilterChip(String label, EquipmentCategory? category) { final isSelected = _selectedCategory == category; return FilterChip( label: Text(label), selected: isSelected, onSelected: (selected) { setState(() { _selectedCategory = selected ? category : null; }); // Recharger depuis le début avec le nouveau filtre _reloadData(); }, 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 (_isLoadingConflicts) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(color: AppColors.rouge), const SizedBox(height: 16), Text( 'Vérification de la disponibilité...', 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 ValueListenableBuilder( valueListenable: _selectionChangeNotifier, builder: (context, _, __) { // Filtrer les données paginées selon le type affiché List itemWidgets = []; if (_displayType == SelectionType.equipment) { // Filtrer côté client pour "Afficher équipements déjà utilisés" final filteredEquipments = _paginatedEquipments.where((eq) { if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) { return false; } return true; }).toList(); itemWidgets = filteredEquipments.map((equipment) { return _buildEquipmentCard(equipment, key: ValueKey('equipment_${equipment.id}')); }).toList(); } else { // Containers final filteredContainers = _paginatedContainers.where((container) { if (!_showConflictingItems) { // Vérifier si le container lui-même est en conflit if (_conflictingContainerIds.contains(container.id)) { return false; } // Vérifier si le container a des équipements enfants en conflit final hasConflictingChildren = container.equipmentIds.any( (eqId) => _conflictingEquipmentIds.contains(eqId), ); if (hasConflictingChildren) { return false; } } return true; }).toList(); itemWidgets = filteredContainers.map((container) { return _buildContainerCard(container, key: ValueKey('container_${container.id}')); }).toList(); } return ListView( controller: _scrollController, padding: const EdgeInsets.all(16), children: [ // Header _buildSectionHeader( _displayType == SelectionType.equipment ? 'Équipements' : 'Containers', _displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory, itemWidgets.length, ), const SizedBox(height: 12), // Items ...itemWidgets, // Indicateur de chargement en bas if (_isLoadingMore) const Padding( padding: EdgeInsets.all(16), child: Center( child: CircularProgressIndicator(color: AppColors.rouge), ), ), // Message si fin de liste if (!_isLoadingMore && !(_displayType == SelectionType.equipment ? _hasMoreEquipments : _hasMoreContainers)) Padding( padding: const EdgeInsets.all(16), child: Center( child: Text( 'Fin de la liste', style: TextStyle(color: Colors.grey.shade600, fontSize: 14), ), ), ), // Message si rien trouvé if (itemWidgets.isEmpty && !_isLoadingMore) 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), ), ], ), ), ), ], ); }, ); } /// 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: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Colors.blue, ), ), 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) { // Utiliser les équipements paginés et le cache final allEquipment = [..._paginatedEquipments, ..._cachedEquipment]; final childEquipments = allEquipment .where((eq) => container.equipmentIds.contains(eq.id)) .toList(); DebugLog.info('[EquipmentSelectionDialog] Building container children for ${container.id}: ${container.equipmentIds.length} IDs, found ${childEquipments.length} equipment(s) in cache (total cache: ${_cachedEquipment.length})'); if (container.equipmentIds.isNotEmpty && childEquipments.isEmpty) { DebugLog.error('[EquipmentSelectionDialog] Container ${container.id} has ${container.equipmentIds.length} equipment IDs but found 0 equipment in cache!'); DebugLog.info('[EquipmentSelectionDialog] Looking for IDs: ${container.equipmentIds.take(5).join(", ")}...'); DebugLog.info('[EquipmentSelectionDialog] Cache contains IDs: ${_cachedEquipment.take(5).map((e) => e.id).join(", ")}...'); } 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 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 _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> _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( 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'), ), ], ), ); }, ); } }