Files
EM2_ERP/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart

2535 lines
95 KiB
Dart

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<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
final Map<String, int> _availableQuantities = {}; // Pour consommables
final Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
final Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
final Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
final 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 _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;
final List<EquipmentModel> _paginatedEquipments = [];
final List<ContainerModel> _paginatedContainers = [];
// Cache pour éviter les rebuilds inutiles
final List<ContainerModel> _cachedContainers = [];
final List<EquipmentModel> _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<void> _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<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 {
// 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<void> _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<dynamic>)
.map((data) {
final map = data as Map<String, dynamic>;
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<void> _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<dynamic>;
DebugLog.info('[EquipmentSelectionDialog] Raw containers data received: ${containersData.length} containers');
// D'abord, extraire TOUS les équipements
final List<EquipmentModel> allEquipmentsToCache = [];
for (var data in containersData) {
final map = data as Map<String, dynamic>;
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<dynamic>?)
?.map((eqData) {
final eqMap = eqData as Map<String, dynamic>;
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<String, dynamic>;
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<void> _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<void> _loadAvailableQuantities(List<EquipmentModel> 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<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;
// Convertir conflictDetails en equipmentConflicts pour l'affichage détaillé
_equipmentConflicts.clear();
conflictDetails.forEach((itemId, conflicts) {
final conflictList = (conflicts as List<dynamic>).map((conflict) {
final conflictMap = conflict as Map<String, dynamic>;
// 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<void> _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<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
/// NOTE: Désactivé avec le lazy loading - on ne charge pas tous les containers d'un coup
Future<void> _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<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 {
// 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<void> _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<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: SizedBox(
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());
// 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<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) {
// Filtrer les données paginées selon le type affiché
List<Widget> 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,
),
),
),
],
),
);
}
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 SizedBox(
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<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
final 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();
}),
],
);
}
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'),
),
],
),
);
},
);
}
}