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.
453 lines
13 KiB
Dart
453 lines
13 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'dart:async';
|
|
import 'package:em2rp/models/container_model.dart';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/services/container_service.dart';
|
|
import 'package:em2rp/services/data_service.dart';
|
|
import 'package:em2rp/services/api_service.dart';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
|
|
class ContainerProvider with ChangeNotifier {
|
|
final ContainerService _containerService = ContainerService();
|
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
|
|
|
// Timer pour le debouncing de la recherche
|
|
Timer? _searchDebounceTimer;
|
|
|
|
// Liste paginée pour la page de gestion
|
|
List<ContainerModel> _paginatedContainers = [];
|
|
bool _hasMore = true;
|
|
bool _isLoadingMore = false;
|
|
String? _lastVisible;
|
|
|
|
// Cache complet pour compatibilité
|
|
List<ContainerModel> _containers = [];
|
|
|
|
// Filtres et recherche
|
|
ContainerType? _selectedType;
|
|
EquipmentStatus? _selectedStatus;
|
|
String _searchQuery = '';
|
|
bool _isLoading = false;
|
|
bool _isInitialized = false;
|
|
|
|
// Mode de chargement (pagination vs full)
|
|
bool _usePagination = false;
|
|
|
|
// Getters
|
|
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
|
|
ContainerType? get selectedType => _selectedType;
|
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
|
String get searchQuery => _searchQuery;
|
|
bool get isLoading => _isLoading;
|
|
bool get isLoadingMore => _isLoadingMore;
|
|
bool get hasMore => _hasMore;
|
|
bool get isInitialized => _isInitialized;
|
|
bool get usePagination => _usePagination;
|
|
|
|
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
|
Future<void> ensureLoaded() async {
|
|
if (_isInitialized || _isLoading) {
|
|
return;
|
|
}
|
|
await loadContainers();
|
|
}
|
|
|
|
/// Charger tous les containers via l'API (avec pagination automatique)
|
|
Future<void> loadContainers() async {
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
_containers.clear();
|
|
String? lastVisible;
|
|
bool hasMore = true;
|
|
int pageCount = 0;
|
|
|
|
// Charger toutes les pages en boucle
|
|
while (hasMore) {
|
|
pageCount++;
|
|
print('[ContainerProvider] Loading page $pageCount...');
|
|
|
|
final result = await _dataService.getContainersPaginated(
|
|
limit: 100, // Charger 100 par page pour aller plus vite
|
|
startAfter: lastVisible,
|
|
sortBy: 'id',
|
|
sortOrder: 'asc',
|
|
type: _selectedType?.name,
|
|
status: _selectedStatus?.name,
|
|
searchQuery: _searchQuery,
|
|
);
|
|
|
|
final containers = (result['containers'] as List<dynamic>)
|
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
|
.toList();
|
|
|
|
_containers.addAll(containers);
|
|
hasMore = result['hasMore'] as bool? ?? false;
|
|
lastVisible = result['lastVisible'] as String?;
|
|
|
|
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
|
}
|
|
|
|
_isLoading = false;
|
|
_isInitialized = true;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
print('Error loading containers: $e');
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Récupérer les containers avec filtres appliqués
|
|
Future<List<ContainerModel>> getContainers() async {
|
|
return await _containerService.getContainers(
|
|
type: _selectedType,
|
|
status: _selectedStatus,
|
|
searchQuery: _searchQuery,
|
|
);
|
|
}
|
|
|
|
/// Stream des containers - retourne un stream depuis les données en cache
|
|
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
|
|
Stream<List<ContainerModel>> get containersStream async* {
|
|
// Si les données ne sont pas chargées, charger d'abord
|
|
if (!_isInitialized) {
|
|
await loadContainers();
|
|
}
|
|
|
|
// Émettre les données actuelles
|
|
yield _containers;
|
|
|
|
// Continuer à émettre les mises à jour du cache
|
|
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
|
|
// et notifier quand les données changent
|
|
}
|
|
|
|
/// Définir le type sélectionné
|
|
void setSelectedType(ContainerType? type) async {
|
|
if (_selectedType == type) return;
|
|
_selectedType = type;
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Définir le statut sélectionné
|
|
void setSelectedStatus(EquipmentStatus? status) async {
|
|
if (_selectedStatus == status) return;
|
|
_selectedStatus = status;
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Définir la requête de recherche (avec debouncing)
|
|
void setSearchQuery(String query) {
|
|
if (_searchQuery == query) return;
|
|
_searchQuery = query;
|
|
|
|
// Annuler le timer précédent
|
|
_searchDebounceTimer?.cancel();
|
|
|
|
if (_usePagination) {
|
|
// Attendre 500ms avant de recharger (debouncing)
|
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
|
reload();
|
|
});
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchDebounceTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
// ============================================================================
|
|
// PAGINATION - Nouvelles méthodes
|
|
// ============================================================================
|
|
|
|
/// Active le mode pagination (pour la page de gestion)
|
|
void enablePagination() {
|
|
if (!_usePagination) {
|
|
_usePagination = true;
|
|
DebugLog.info('[ContainerProvider] Pagination mode enabled');
|
|
}
|
|
}
|
|
|
|
/// Désactive le mode pagination (pour les autres pages)
|
|
void disablePagination() {
|
|
if (_usePagination) {
|
|
_usePagination = false;
|
|
DebugLog.info('[ContainerProvider] Pagination mode disabled');
|
|
}
|
|
}
|
|
|
|
/// Charge la première page (réinitialise tout)
|
|
Future<void> loadFirstPage() async {
|
|
DebugLog.info('[ContainerProvider] Loading first page...');
|
|
|
|
_paginatedContainers.clear();
|
|
_lastVisible = null;
|
|
_hasMore = true;
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
await loadNextPage();
|
|
_isInitialized = true;
|
|
} catch (e) {
|
|
DebugLog.error('[ContainerProvider] Error loading first page', e);
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Charge la page suivante (scroll infini)
|
|
Future<void> loadNextPage() async {
|
|
if (_isLoadingMore || !_hasMore) {
|
|
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
|
return;
|
|
}
|
|
|
|
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
|
|
|
|
_isLoadingMore = true;
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _dataService.getContainersPaginated(
|
|
limit: 20,
|
|
startAfter: _lastVisible,
|
|
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
|
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
|
sortBy: 'id',
|
|
sortOrder: 'asc',
|
|
);
|
|
|
|
final newContainers = (result['containers'] as List<dynamic>)
|
|
.map((data) {
|
|
final map = data as Map<String, dynamic>;
|
|
return ContainerModel.fromMap(map, map['id'] as String);
|
|
})
|
|
.toList();
|
|
|
|
_paginatedContainers.addAll(newContainers);
|
|
_hasMore = result['hasMore'] as bool? ?? false;
|
|
_lastVisible = result['lastVisible'] as String?;
|
|
|
|
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
|
|
|
|
_isLoadingMore = false;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
DebugLog.error('[ContainerProvider] Error loading next page', e);
|
|
_isLoadingMore = false;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Recharge en changeant de filtre ou recherche
|
|
Future<void> reload() async {
|
|
DebugLog.info('[ContainerProvider] Reloading with new filters...');
|
|
await loadFirstPage();
|
|
}
|
|
|
|
/// Créer un nouveau container
|
|
Future<void> createContainer(ContainerModel container) async {
|
|
await _containerService.createContainer(container);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Mettre à jour un container
|
|
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
|
await _containerService.updateContainer(id, data);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Supprimer un container
|
|
Future<void> deleteContainer(String id) async {
|
|
await _containerService.deleteContainer(id);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Récupérer un container par ID
|
|
Future<ContainerModel?> getContainerById(String id) async {
|
|
return await _containerService.getContainerById(id);
|
|
}
|
|
|
|
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
|
|
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
|
if (containerIds.isEmpty) return [];
|
|
|
|
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
|
|
|
try {
|
|
// Vérifier d'abord le cache local
|
|
final cachedContainers = <ContainerModel>[];
|
|
final missingIds = <String>[];
|
|
|
|
for (final id in containerIds) {
|
|
final cached = _containers.firstWhere(
|
|
(c) => c.id == id,
|
|
orElse: () => ContainerModel(
|
|
id: '',
|
|
name: '',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (cached.id.isNotEmpty) {
|
|
cachedContainers.add(cached);
|
|
} else {
|
|
missingIds.add(id);
|
|
}
|
|
}
|
|
|
|
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
|
|
|
// Si tous sont en cache, retourner directement
|
|
if (missingIds.isEmpty) {
|
|
return cachedContainers;
|
|
}
|
|
|
|
// Charger les manquants depuis l'API
|
|
final containersData = await _dataService.getContainersByIds(missingIds);
|
|
|
|
final loadedContainers = containersData.map((data) {
|
|
return ContainerModel.fromMap(data, data['id'] as String);
|
|
}).toList();
|
|
|
|
// Ajouter au cache
|
|
for (final container in loadedContainers) {
|
|
if (!_containers.any((c) => c.id == container.id)) {
|
|
_containers.add(container);
|
|
}
|
|
}
|
|
|
|
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
|
|
|
// Retourner tous les conteneurs (cache + chargés)
|
|
return [...cachedContainers, ...loadedContainers];
|
|
} catch (e) {
|
|
print('[ContainerProvider] Error loading containers by IDs: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Ajouter un équipement à un container
|
|
Future<Map<String, dynamic>> addEquipmentToContainer({
|
|
required String containerId,
|
|
required String equipmentId,
|
|
String? userId,
|
|
}) async {
|
|
final result = await _containerService.addEquipmentToContainer(
|
|
containerId: containerId,
|
|
equipmentId: equipmentId,
|
|
userId: userId,
|
|
);
|
|
notifyListeners();
|
|
return result;
|
|
}
|
|
|
|
/// Retirer un équipement d'un container
|
|
Future<void> removeEquipmentFromContainer({
|
|
required String containerId,
|
|
required String equipmentId,
|
|
String? userId,
|
|
}) async {
|
|
await _containerService.removeEquipmentFromContainer(
|
|
containerId: containerId,
|
|
equipmentId: equipmentId,
|
|
userId: userId,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Vérifier la disponibilité d'un container
|
|
Future<Map<String, dynamic>> checkContainerAvailability({
|
|
required String containerId,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
String? excludeEventId,
|
|
}) async {
|
|
return await _containerService.checkContainerAvailability(
|
|
containerId: containerId,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
excludeEventId: excludeEventId,
|
|
);
|
|
}
|
|
|
|
/// Récupérer les équipements d'un container
|
|
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
|
return await _containerService.getContainerEquipment(containerId);
|
|
}
|
|
|
|
/// Trouver tous les containers contenant un équipement
|
|
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
|
return await _containerService.findContainersWithEquipment(equipmentId);
|
|
}
|
|
|
|
/// Vérifier si un ID existe
|
|
Future<bool> checkContainerIdExists(String id) async {
|
|
return await _containerService.checkContainerIdExists(id);
|
|
}
|
|
|
|
/// Générer un ID unique pour un container
|
|
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
|
|
static String generateContainerId({
|
|
required ContainerType type,
|
|
required String name,
|
|
int? number,
|
|
}) {
|
|
// Obtenir le type en majuscules
|
|
final typeStr = containerTypeToString(type);
|
|
|
|
// Nettoyer le nom (enlever espaces, caractères spéciaux)
|
|
final cleanName = name
|
|
.replaceAll(' ', '_')
|
|
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
|
|
.toUpperCase();
|
|
|
|
if (number != null) {
|
|
return 'BOX_${typeStr}_${cleanName}_#$number';
|
|
}
|
|
|
|
return 'BOX_${typeStr}_$cleanName';
|
|
}
|
|
|
|
/// Assurer l'unicité d'un ID de container
|
|
static Future<String> ensureUniqueContainerId(
|
|
String baseId,
|
|
ContainerService service,
|
|
) async {
|
|
String uniqueId = baseId;
|
|
int counter = 1;
|
|
|
|
while (await service.checkContainerIdExists(uniqueId)) {
|
|
uniqueId = '${baseId}_$counter';
|
|
counter++;
|
|
}
|
|
|
|
return uniqueId;
|
|
}
|
|
}
|
|
|