Files
EM2_ERP/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart
ElPoyo a182f1b922 refactor: Passage à la pagination côté serveur pour les équipements et containers
Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.

**Changements Backend (Cloud Functions) :**

-   **Nouveaux Endpoints Paginés :**
    -   `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
    -   Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
    -   La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
-   **Optimisation de `getContainersPaginated` :**
    -   Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
-   **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
-   **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.

**Changements Frontend (Flutter) :**

-   **`EquipmentProvider` et `ContainerProvider` :**
    -   La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
    -   Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
    -   Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
    -   Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
-   **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
    -   Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
    -   Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
    -   Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
-   **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
    -   Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
    -   Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
    -   La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
-   **Optimisations diverses :**
    -   Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
    -   Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.

**Correction mineure :**

-   **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
2026-01-18 12:40:23 +01:00

2595 lines
97 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
Map<String, int> _availableQuantities = {}; // Pour consommables
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
// NOUVEAU : IDs en conflit récupérés en batch
Set<String> _conflictingEquipmentIds = {};
Set<String> _conflictingContainerIds = {};
Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID
Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables
bool _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<EquipmentModel> _paginatedEquipments = [];
List<ContainerModel> _paginatedContainers = [];
// Cache pour éviter les rebuilds inutiles
List<ContainerModel> _cachedContainers = [];
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: Container(
width: dialogWidth.clamp(600.0, 1200.0),
height: dialogHeight.clamp(500.0, 900.0),
child: Column(
children: [
_buildHeader(),
_buildSearchAndFilters(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste principale
Expanded(
flex: 2,
child: _buildMainList(),
),
// Panneau latéral : sélection + recommandations
Container(
width: 320,
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
left: BorderSide(color: Colors.grey.shade300),
),
),
child: Column(
children: [
Expanded(
child: ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) => _buildSelectionPanel(),
),
),
if (_hasRecommendations)
Container(
height: 200,
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade300),
),
),
child: _buildRecommendationsPanel(),
),
],
),
),
],
),
),
_buildFooter(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
const Icon(Icons.add_circle, color: Colors.white, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Ajouter du matériel',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
Widget _buildSearchAndFilters() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Column(
children: [
// Barre de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher du matériel ou des boîtes...',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase());
// 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,
),
),
),
],
),
);
}
/// 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<String> equipmentIdsInContainers = {};
for (var containerEntry in selectedContainers) {
final childrenIds = _getContainerEquipmentIds(containerEntry.key);
equipmentIdsInContainers.addAll(childrenIds);
}
// Équipements qui ne sont PAS enfants d'un conteneur sélectionné
final selectedStandaloneEquipment = _selectedItems.entries
.where((e) => e.value.type == SelectionType.equipment)
.where((e) => !equipmentIdsInContainers.contains(e.key))
.toList();
final containerCount = selectedContainers.length;
final standaloneEquipmentCount = selectedStandaloneEquipment.length;
final totalDisplayed = containerCount + standaloneEquipmentCount;
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rouge,
),
child: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 8),
const Text(
'Sélection',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$totalDisplayed',
style: const TextStyle(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
Expanded(
child: totalDisplayed == 0
? const Center(
child: Text(
'Aucune sélection',
style: TextStyle(color: Colors.grey),
),
)
: ListView(
padding: const EdgeInsets.all(8),
children: [
if (containerCount > 0) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Boîtes ($containerCount)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
...selectedContainers.map((e) => _buildSelectedContainerTile(e.key, e.value)),
],
if (standaloneEquipmentCount > 0) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Équipements ($standaloneEquipmentCount)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
...selectedStandaloneEquipment.map((e) => _buildSelectedItemTile(e.key, e.value)),
],
],
),
),
],
);
}
/// Récupère les IDs des équipements d'un conteneur (depuis le cache)
List<String> _getContainerEquipmentIds(String containerId) {
// On doit récupérer le conteneur depuis le provider de manière synchrone
// Pour cela, on va maintenir un cache local
return _containerEquipmentCache[containerId] ?? [];
}
// Cache local pour les équipements des conteneurs
Map<String, List<String>> _containerEquipmentCache = {};
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
final isExpanded = _expandedContainers.contains(id);
final childrenIds = _getContainerEquipmentIds(id);
final childrenCount = childrenIds.length;
return Column(
children: [
ListTile(
dense: true,
leading: Icon(
Icons.inventory,
size: 20,
color: AppColors.rouge,
),
title: Text(
item.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'$childrenCount équipement(s)',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (childrenCount > 0)
IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
),
onPressed: () {
if (isExpanded) {
_expandedContainers.remove(id);
} else {
_expandedContainers.add(id);
}
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => _toggleSelection(id, item.name, item.type),
),
],
),
),
if (isExpanded && childrenCount > 0)
...childrenIds.map((equipmentId) {
final childItem = _selectedItems[equipmentId];
if (childItem != null) {
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
}
return const SizedBox.shrink();
}).toList(),
],
);
}
Widget _buildSelectedChildEquipmentTile(String id, SelectedItem item) {
return Padding(
padding: const EdgeInsets.only(left: 40),
child: ListTile(
dense: true,
leading: Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey.shade600,
),
title: Text(
item.name,
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
subtitle: item.quantity > 1
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 10))
: null,
// PAS de bouton de suppression pour les enfants
),
);
}
Widget _buildSelectedItemTile(String id, SelectedItem item) {
return ListTile(
dense: true,
leading: Icon(
Icons.inventory_2,
size: 20,
color: AppColors.rouge,
),
title: Text(
item.name,
style: const TextStyle(fontSize: 13),
),
subtitle: item.quantity > 1
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 11))
: null,
trailing: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => _toggleSelection(id, item.name, item.type),
),
);
}
bool get _hasRecommendations => _recommendedContainers.isNotEmpty;
Widget _buildRecommendationsPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade700,
),
child: const Row(
children: [
Icon(Icons.lightbulb, color: Colors.white, size: 20),
SizedBox(width: 8),
Text(
'Boîtes recommandées',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(8),
children: _recommendedContainers.values
.expand((list) => list)
.toSet() // Enlever les doublons
.map((container) => _buildRecommendedContainerTile(container))
.toList(),
),
),
],
);
}
Widget _buildRecommendedContainerTile(ContainerModel container) {
final isAlreadySelected = _selectedItems.containsKey(container.id);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
dense: true,
leading: container.type.getIcon(size: 24, color: Colors.blue.shade700),
title: Text(
container.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'${container.itemCount} équipement(s)',
style: const TextStyle(fontSize: 11),
),
trailing: isAlreadySelected
? const Icon(Icons.check_circle, color: Colors.green)
: IconButton(
icon: const Icon(Icons.add_circle_outline, color: Colors.blue),
onPressed: () => _toggleSelection(
container.id,
container.name,
SelectionType.container,
),
),
),
);
}
Widget _buildFooter() {
return ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Text(
'${_selectedItems.length} élément(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _selectedItems.isEmpty
? null
: () => Navigator.of(context).pop(_selectedItems),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
),
child: const Text('Valider la sélection'),
),
],
),
);
},
);
}
}