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.
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/services/event_availability_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
@@ -109,93 +106,70 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID
|
||||
Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables
|
||||
|
||||
bool _isLoadingQuantities = false;
|
||||
bool _isLoadingConflicts = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
// Nouvelles options d'affichage
|
||||
bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit
|
||||
bool _containersExpanded = true; // Section "Boîtes" dépliée
|
||||
bool _equipmentExpanded = true; // Section "Tous les équipements" dépliée
|
||||
|
||||
// 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 = [];
|
||||
bool _initialDataLoaded = false;
|
||||
|
||||
@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. S'assurer que les équipements et conteneurs sont chargés
|
||||
await _ensureEquipmentsLoaded();
|
||||
// 1. Charger les conflits (batch optimisé)
|
||||
await _loadEquipmentConflicts();
|
||||
|
||||
// 2. Mettre à jour le cache immédiatement après le chargement
|
||||
if (mounted) {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
setState(() {
|
||||
// Utiliser allEquipment pour avoir TOUS les équipements sans filtres
|
||||
_cachedEquipment = equipmentProvider.allEquipment;
|
||||
_cachedContainers = containerProvider.containers;
|
||||
_initialDataLoaded = true;
|
||||
});
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Cache updated: ${_cachedEquipment.length} equipment(s), ${_cachedContainers.length} container(s)');
|
||||
}
|
||||
|
||||
// 3. Initialiser la sélection avec le matériel déjà assigné
|
||||
// 2. Initialiser la sélection avec le matériel déjà assigné
|
||||
await _initializeAlreadyAssigned();
|
||||
|
||||
// 4. Charger les quantités et conflits en parallèle
|
||||
await Future.wait([
|
||||
_loadAvailableQuantities(),
|
||||
_loadEquipmentConflicts(),
|
||||
]);
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error during initialization', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// S'assure que les équipements sont chargés avant d'utiliser le dialog
|
||||
Future<void> _ensureEquipmentsLoaded() async {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Starting equipment loading...');
|
||||
|
||||
// Forcer le chargement et attendre qu'il soit terminé
|
||||
await equipmentProvider.ensureLoaded();
|
||||
|
||||
// Attendre que le chargement soit vraiment terminé
|
||||
while (equipmentProvider.isLoading) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// Vérifier qu'on a bien des équipements chargés
|
||||
if (equipmentProvider.allEquipment.isEmpty) {
|
||||
DebugLog.warning('[EquipmentSelectionDialog] No equipment loaded after ensureLoaded!');
|
||||
}
|
||||
|
||||
// Charger aussi les conteneurs si nécessaire
|
||||
if (containerProvider.containers.isEmpty) {
|
||||
await containerProvider.loadContainers();
|
||||
|
||||
// Attendre que le chargement des conteneurs soit terminé
|
||||
while (containerProvider.isLoading) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
// 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);
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Data loaded: ${equipmentProvider.allEquipment.length} equipment(s), ${containerProvider.containers.length} container(s)');
|
||||
}
|
||||
|
||||
/// Initialise la sélection avec le matériel déjà assigné
|
||||
@@ -215,15 +189,15 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
// Ajouter les conteneurs déjà assignés
|
||||
if (widget.alreadyAssignedContainers.isNotEmpty) {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
|
||||
// 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) {
|
||||
final container = containers.firstWhere(
|
||||
// Chercher dans le cache ou créer un conteneur temporaire
|
||||
final container = _cachedContainers.firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
orElse: () => ContainerModel(
|
||||
id: containerId,
|
||||
name: 'Inconnu',
|
||||
name: 'Conteneur $containerId',
|
||||
type: ContainerType.flightCase,
|
||||
status: EquipmentStatus.available,
|
||||
equipmentIds: [],
|
||||
@@ -267,6 +241,152 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
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();
|
||||
@@ -275,34 +395,29 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Charge les quantités disponibles pour tous les consommables/câbles
|
||||
Future<void> _loadAvailableQuantities() async {
|
||||
/// 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;
|
||||
setState(() => _isLoadingQuantities = true);
|
||||
|
||||
try {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
|
||||
// Utiliser directement allEquipment du provider (déjà chargé)
|
||||
final equipment = equipmentProvider.allEquipment;
|
||||
|
||||
final consumables = equipment.where((eq) =>
|
||||
final consumables = equipments.where((eq) =>
|
||||
eq.category == EquipmentCategory.consumable ||
|
||||
eq.category == EquipmentCategory.cable);
|
||||
|
||||
for (var eq in consumables) {
|
||||
final available = await _availabilityService.getAvailableQuantity(
|
||||
equipment: eq,
|
||||
startDate: widget.startDate,
|
||||
endDate: widget.endDate,
|
||||
excludeEventId: widget.excludeEventId,
|
||||
);
|
||||
_availableQuantities[eq.id] = available;
|
||||
// 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);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingQuantities = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +466,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
_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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,15 +527,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
|
||||
Future<void> _updateContainerConflictStatus() async {
|
||||
if (!mounted) return; // Vérifier si le widget est toujours monté
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = await containerProvider.containersStream.first;
|
||||
|
||||
if (!mounted) return; // Vérifier à nouveau après l'async
|
||||
|
||||
for (var container in containers) {
|
||||
// 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(
|
||||
@@ -406,6 +563,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -639,26 +801,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
// Récupérer les conteneurs depuis le stream
|
||||
final containerStream = containerProvider.containersStream;
|
||||
final containers = await containerStream.first;
|
||||
|
||||
final recommended = containers
|
||||
.where((container) => container.equipmentIds.contains(equipmentId))
|
||||
.toList();
|
||||
|
||||
if (recommended.isNotEmpty) {
|
||||
setState(() {
|
||||
_recommendedContainers[equipmentId] = recommended;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('Error finding recommended containers', e);
|
||||
}
|
||||
// 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
|
||||
@@ -733,14 +880,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
/// Sélectionner tous les enfants d'un conteneur
|
||||
Future<void> _selectContainerChildren(String containerId) async {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
|
||||
// Utiliser le cache si disponible
|
||||
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
final equipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.allEquipment;
|
||||
|
||||
final container = containers.firstWhere(
|
||||
// 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,
|
||||
@@ -759,7 +900,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
// Sélectionner chaque enfant (sans bloquer, car ils sont "composés")
|
||||
for (var equipmentId in container.equipmentIds) {
|
||||
if (!_selectedItems.containsKey(equipmentId)) {
|
||||
final eq = equipment.firstWhere(
|
||||
// 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,
|
||||
@@ -794,12 +936,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
/// Désélectionner tous les enfants d'un conteneur
|
||||
Future<void> _deselectContainerChildren(String containerId) async {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
// Utiliser le cache si disponible
|
||||
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
|
||||
final container = containers.firstWhere(
|
||||
// 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,
|
||||
@@ -1027,6 +1165,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => _searchQuery = value.toLowerCase());
|
||||
// Recharger depuis le début avec le nouveau filtre
|
||||
_reloadData();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1078,6 +1218,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1093,6 +1279,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
setState(() {
|
||||
_selectedCategory = selected ? category : null;
|
||||
});
|
||||
// Recharger depuis le début avec le nouveau filtre
|
||||
_reloadData();
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
checkmarkColor: Colors.white,
|
||||
@@ -1104,7 +1292,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
Widget _buildMainList() {
|
||||
// Afficher un indicateur de chargement si les données sont en cours de chargement
|
||||
if (_isLoadingQuantities || _isLoadingConflicts) {
|
||||
if (_isLoadingConflicts) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -1112,9 +1300,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
const CircularProgressIndicator(color: AppColors.rouge),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_isLoadingConflicts
|
||||
? 'Vérification de la disponibilité...'
|
||||
: 'Chargement des quantités disponibles...',
|
||||
'Vérification de la disponibilité...',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
@@ -1128,150 +1314,105 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
|
||||
Widget _buildHierarchicalList() {
|
||||
return Consumer2<ContainerProvider, EquipmentProvider>(
|
||||
builder: (context, containerProvider, equipmentProvider, child) {
|
||||
// Utiliser les données du cache si disponibles, sinon utiliser allEquipment des providers
|
||||
final allContainers = _initialDataLoaded ? _cachedContainers : containerProvider.containers;
|
||||
final allEquipment = _initialDataLoaded ? _cachedEquipment : equipmentProvider.allEquipment;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _selectionChangeNotifier,
|
||||
builder: (context, _, __) {
|
||||
// Filtrer les données paginées selon le type affiché
|
||||
List<Widget> itemWidgets = [];
|
||||
|
||||
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _selectionChangeNotifier,
|
||||
builder: (context, _, __) {
|
||||
// Filtrage des boîtes
|
||||
final filteredContainers = allContainers.where((container) {
|
||||
// Filtre par conflit (masquer si non cochée et en conflit)
|
||||
if (!_showConflictingItems && _conflictingContainerIds.contains(container.id)) {
|
||||
return false;
|
||||
}
|
||||
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();
|
||||
|
||||
// Filtre par catégorie : afficher uniquement les boîtes contenant au moins 1 équipement de la catégorie
|
||||
if (_selectedCategory != null) {
|
||||
final hasEquipmentOfCategory = container.equipmentIds.any((eqId) {
|
||||
final equipment = allEquipment.firstWhere(
|
||||
(e) => e.id == eqId,
|
||||
orElse: () => EquipmentModel(
|
||||
id: '',
|
||||
name: '',
|
||||
category: EquipmentCategory.other,
|
||||
status: EquipmentStatus.available,
|
||||
maintenanceIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return equipment.id.isNotEmpty && equipment.category == _selectedCategory;
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
if (!hasEquipmentOfCategory) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Vérifier si le container a des équipements enfants en conflit
|
||||
final hasConflictingChildren = container.equipmentIds.any(
|
||||
(eqId) => _conflictingEquipmentIds.contains(eqId),
|
||||
);
|
||||
|
||||
// Filtre par recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final searchLower = _searchQuery.toLowerCase();
|
||||
return container.id.toLowerCase().contains(searchLower) ||
|
||||
container.name.toLowerCase().contains(searchLower);
|
||||
}
|
||||
if (hasConflictingChildren) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
itemWidgets = filteredContainers.map((container) {
|
||||
return _buildContainerCard(container, key: ValueKey('container_${container.id}'));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Filtrage des équipements (TOUS, pas seulement les orphelins)
|
||||
final filteredEquipment = allEquipment.where((eq) {
|
||||
// Filtre par conflit (masquer si non cochée et en conflit)
|
||||
if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) {
|
||||
return false;
|
||||
}
|
||||
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),
|
||||
|
||||
// Filtre par catégorie
|
||||
if (_selectedCategory != null && eq.category != _selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
// Items
|
||||
...itemWidgets,
|
||||
|
||||
// Filtre par recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final searchLower = _searchQuery.toLowerCase();
|
||||
return eq.id.toLowerCase().contains(searchLower) ||
|
||||
(eq.brand?.toLowerCase().contains(searchLower) ?? false) ||
|
||||
(eq.model?.toLowerCase().contains(searchLower) ?? false);
|
||||
}
|
||||
// Indicateur de chargement en bas
|
||||
if (_isLoadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||
),
|
||||
),
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
return ListView(
|
||||
controller: _scrollController, // Préserve la position de scroll
|
||||
padding: const EdgeInsets.all(16),
|
||||
cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll
|
||||
children: [
|
||||
// SECTION 1 : BOÎTES
|
||||
if (filteredContainers.isNotEmpty) ...[
|
||||
_buildCollapsibleSectionHeader(
|
||||
'Boîtes',
|
||||
Icons.inventory,
|
||||
filteredContainers.length,
|
||||
_containersExpanded,
|
||||
(expanded) {
|
||||
setState(() {
|
||||
_containersExpanded = expanded;
|
||||
});
|
||||
},
|
||||
// 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),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_containersExpanded) ...[
|
||||
...filteredContainers.map((container) => _buildContainerCard(
|
||||
container,
|
||||
key: ValueKey('container_${container.id}'),
|
||||
)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
],
|
||||
|
||||
// SECTION 2 : TOUS LES ÉQUIPEMENTS
|
||||
if (filteredEquipment.isNotEmpty) ...[
|
||||
_buildCollapsibleSectionHeader(
|
||||
'Tous les équipements',
|
||||
Icons.inventory_2,
|
||||
filteredEquipment.length,
|
||||
_equipmentExpanded,
|
||||
(expanded) {
|
||||
setState(() {
|
||||
_equipmentExpanded = expanded;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_equipmentExpanded) ...[
|
||||
...filteredEquipment.map((equipment) => _buildEquipmentCard(
|
||||
equipment,
|
||||
key: ValueKey('equipment_${equipment.id}'),
|
||||
)),
|
||||
],
|
||||
],
|
||||
|
||||
// Message si rien n'est trouvé
|
||||
if (filteredContainers.isEmpty && filteredEquipment.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun résultat trouvé',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
); // Fin du ValueListenableBuilder
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1873,10 +2014,10 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${container.itemCount} équipement(s)',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.blue.shade700,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
if (hasConflict) ...[
|
||||
@@ -1965,68 +2106,65 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Widget pour afficher les équipements enfants d'un conteneur
|
||||
Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) {
|
||||
return Consumer<EquipmentProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return StreamBuilder<List<EquipmentModel>>(
|
||||
stream: provider.equipmentStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
// Utiliser les équipements paginés et le cache
|
||||
final allEquipment = [..._paginatedEquipments, ..._cachedEquipment];
|
||||
final childEquipments = allEquipment
|
||||
.where((eq) => container.equipmentIds.contains(eq.id))
|
||||
.toList();
|
||||
|
||||
final allEquipment = snapshot.data ?? [];
|
||||
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 (childEquipments.isEmpty) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
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,
|
||||
),
|
||||
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) {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...childEquipments.map((eq) {
|
||||
final hasConflict = _equipmentConflicts.containsKey(eq.id);
|
||||
final conflicts = _equipmentConflicts[eq.id] ?? [];
|
||||
|
||||
@@ -2115,10 +2253,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionPanel() {
|
||||
|
||||
Reference in New Issue
Block a user