eac103491f
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
- Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
- Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
- Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
- Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
- Refonte de l'interface mobile pour intégrer la barre de recherche.
- Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
- Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
- Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
- Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
534 lines
16 KiB
Dart
534 lines
16 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'dart:async';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/services/data_service.dart';
|
|
import 'package:em2rp/services/api_service.dart';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
|
|
class EquipmentProvider extends ChangeNotifier {
|
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
|
|
|
// Timer pour le debouncing de la recherche
|
|
Timer? _searchDebounceTimer;
|
|
|
|
// Liste paginée pour la page de gestion
|
|
final List<EquipmentModel> _paginatedEquipment = [];
|
|
bool _hasMore = true;
|
|
bool _isLoadingMore = false;
|
|
String? _lastVisible;
|
|
|
|
// Cache complet pour getEquipmentsByIds et compatibilité
|
|
final List<EquipmentModel> _equipment = [];
|
|
List<String> _models = [];
|
|
List<String> _brands = [];
|
|
|
|
// Filtres et recherche
|
|
EquipmentCategory? _selectedCategory;
|
|
EquipmentStatus? _selectedStatus;
|
|
String? _selectedModel;
|
|
String _searchQuery = '';
|
|
bool _isLoading = false;
|
|
bool _isInitialized = false;
|
|
|
|
// Mode de chargement (pagination vs full)
|
|
bool _usePagination = false;
|
|
|
|
EquipmentProvider();
|
|
|
|
// Getters
|
|
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
|
|
List<EquipmentModel> get allEquipment => _equipment;
|
|
List<String> get models => _models;
|
|
List<String> get brands => _brands;
|
|
EquipmentCategory? get selectedCategory => _selectedCategory;
|
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
|
String? get selectedModel => _selectedModel;
|
|
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 équipements sont chargés (charge si nécessaire)
|
|
Future<void> ensureLoaded() async {
|
|
// Si déjà en train de charger, attendre
|
|
if (_isLoading) {
|
|
print('[EquipmentProvider] Equipment loading in progress, waiting...');
|
|
return;
|
|
}
|
|
|
|
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
|
if (_isInitialized && _equipment.isEmpty) {
|
|
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
|
_isInitialized = false; // Réinitialiser le flag
|
|
await loadEquipments();
|
|
return;
|
|
}
|
|
|
|
// Si déjà initialisé avec des données, ne rien faire
|
|
if (_isInitialized) {
|
|
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
|
return;
|
|
}
|
|
|
|
print('[EquipmentProvider] Equipment not loaded, loading now...');
|
|
await loadEquipments();
|
|
}
|
|
|
|
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
|
|
Future<void> loadEquipments() async {
|
|
print('[EquipmentProvider] Starting to load ALL equipments...');
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
_equipment.clear();
|
|
String? lastVisible;
|
|
bool hasMore = true;
|
|
int pageCount = 0;
|
|
|
|
// Charger toutes les pages en boucle
|
|
while (hasMore) {
|
|
pageCount++;
|
|
print('[EquipmentProvider] Loading page $pageCount...');
|
|
|
|
final result = await _dataService.getEquipmentsPaginated(
|
|
limit: 100, // Charger 100 par page pour aller plus vite
|
|
startAfter: lastVisible,
|
|
sortBy: 'id',
|
|
sortOrder: 'asc',
|
|
);
|
|
|
|
final equipmentsData = result['equipments'] as List<dynamic>;
|
|
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
|
|
|
|
final pageEquipments = equipmentsData.map((data) {
|
|
final id = data['id'] as String;
|
|
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
|
}).toList();
|
|
|
|
_equipment.addAll(pageEquipments);
|
|
|
|
hasMore = result['hasMore'] as bool? ?? false;
|
|
lastVisible = result['lastVisible'] as String?;
|
|
|
|
if (!hasMore) {
|
|
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
|
|
}
|
|
}
|
|
|
|
// Extraire les modèles et marques uniques
|
|
_extractUniqueValues();
|
|
|
|
_isInitialized = true;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
|
|
} catch (e) {
|
|
print('[EquipmentProvider] Error loading equipments: $e');
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Charge plusieurs équipements par leurs IDs (optimisé pour les détails d'événement)
|
|
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> equipmentIds) async {
|
|
if (equipmentIds.isEmpty) return [];
|
|
|
|
print('[EquipmentProvider] Loading ${equipmentIds.length} equipments by IDs...');
|
|
|
|
try {
|
|
// Vérifier d'abord le cache local
|
|
final cachedEquipments = <EquipmentModel>[];
|
|
final missingIds = <String>[];
|
|
|
|
for (final id in equipmentIds) {
|
|
final cached = _equipment.firstWhere(
|
|
(eq) => eq.id == id,
|
|
orElse: () => EquipmentModel(
|
|
id: '',
|
|
name: '',
|
|
category: EquipmentCategory.other,
|
|
status: EquipmentStatus.available,
|
|
maintenanceIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (cached.id.isNotEmpty) {
|
|
cachedEquipments.add(cached);
|
|
} else {
|
|
missingIds.add(id);
|
|
}
|
|
}
|
|
|
|
print('[EquipmentProvider] Found ${cachedEquipments.length} in cache, ${missingIds.length} missing');
|
|
|
|
// Si tous sont en cache, retourner directement
|
|
if (missingIds.isEmpty) {
|
|
return cachedEquipments;
|
|
}
|
|
|
|
// Charger les manquants depuis l'API
|
|
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
|
|
|
|
final loadedEquipments = equipmentsData.map((data) {
|
|
final id = data['id'] as String; // L'ID vient du backend
|
|
return EquipmentModel.fromMap(data, id);
|
|
}).toList();
|
|
|
|
// Ajouter au cache
|
|
for (final eq in loadedEquipments) {
|
|
if (!_equipment.any((e) => e.id == eq.id)) {
|
|
_equipment.add(eq);
|
|
}
|
|
}
|
|
|
|
print('[EquipmentProvider] Loaded ${loadedEquipments.length} equipments from API');
|
|
|
|
// Retourner tous les équipements (cache + chargés)
|
|
return [...cachedEquipments, ...loadedEquipments];
|
|
} catch (e) {
|
|
print('[EquipmentProvider] Error loading equipments by IDs: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Extraire modèles et marques uniques
|
|
void _extractUniqueValues() {
|
|
final modelSet = <String>{};
|
|
final brandSet = <String>{};
|
|
|
|
for (final eq in _equipment) {
|
|
if (eq.model != null && eq.model!.isNotEmpty) {
|
|
modelSet.add(eq.model!);
|
|
}
|
|
if (eq.brand != null && eq.brand!.isNotEmpty) {
|
|
brandSet.add(eq.brand!);
|
|
}
|
|
}
|
|
|
|
_models = modelSet.toList()..sort();
|
|
_brands = brandSet.toList()..sort();
|
|
}
|
|
|
|
/// Obtenir les équipements filtrés
|
|
List<EquipmentModel> get _filteredEquipment {
|
|
var filtered = _equipment;
|
|
|
|
if (_selectedCategory != null) {
|
|
filtered = filtered.where((eq) => eq.category == _selectedCategory).toList();
|
|
}
|
|
|
|
if (_selectedStatus != null) {
|
|
filtered = filtered.where((eq) => eq.status == _selectedStatus).toList();
|
|
}
|
|
|
|
if (_selectedModel != null && _selectedModel!.isNotEmpty) {
|
|
filtered = filtered.where((eq) => eq.model == _selectedModel).toList();
|
|
}
|
|
|
|
if (_searchQuery.isNotEmpty) {
|
|
final query = _searchQuery.toLowerCase();
|
|
filtered = filtered.where((eq) {
|
|
return eq.name.toLowerCase().contains(query) ||
|
|
eq.id.toLowerCase().contains(query) ||
|
|
(eq.model?.toLowerCase().contains(query) ?? false) ||
|
|
(eq.brand?.toLowerCase().contains(query) ?? false);
|
|
}).toList();
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
// ============================================================================
|
|
// PAGINATION - Nouvelles méthodes
|
|
// ============================================================================
|
|
|
|
/// Active le mode pagination (pour la page de gestion)
|
|
void enablePagination() {
|
|
if (!_usePagination) {
|
|
_usePagination = true;
|
|
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
|
|
}
|
|
}
|
|
|
|
/// Désactive le mode pagination (pour les autres pages)
|
|
void disablePagination() {
|
|
if (_usePagination) {
|
|
_usePagination = false;
|
|
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
|
|
}
|
|
}
|
|
|
|
/// Charge la première page (réinitialise tout)
|
|
Future<void> loadFirstPage() async {
|
|
DebugLog.info('[EquipmentProvider] Loading first page...');
|
|
|
|
_paginatedEquipment.clear();
|
|
_lastVisible = null;
|
|
_hasMore = true;
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
await loadNextPage();
|
|
_isInitialized = true;
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentProvider] Error loading first page', e);
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Charge la page suivante (scroll infini)
|
|
Future<void> loadNextPage() async {
|
|
if (_isLoadingMore || !_hasMore) {
|
|
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
|
return;
|
|
}
|
|
|
|
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
|
|
|
|
_isLoadingMore = true;
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _dataService.getEquipmentsPaginated(
|
|
limit: 20,
|
|
startAfter: _lastVisible,
|
|
category: _selectedCategory?.name,
|
|
status: _selectedStatus?.name,
|
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : 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; // L'ID vient du backend dans le JSON
|
|
return EquipmentModel.fromMap(map, id);
|
|
})
|
|
.toList();
|
|
|
|
_paginatedEquipment.addAll(newEquipments);
|
|
_hasMore = result['hasMore'] as bool? ?? false;
|
|
_lastVisible = result['lastVisible'] as String?;
|
|
|
|
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
|
|
|
|
_isLoadingMore = false;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentProvider] Error loading next page', e);
|
|
_isLoadingMore = false;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Recharge en changeant de filtre ou recherche
|
|
Future<void> reload() async {
|
|
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
|
|
await loadFirstPage();
|
|
}
|
|
|
|
/// Définir le filtre de catégorie
|
|
void setSelectedCategory(EquipmentCategory? category) async {
|
|
if (_selectedCategory == category) return;
|
|
_selectedCategory = category;
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Définir le filtre de statut
|
|
void setSelectedStatus(EquipmentStatus? status) async {
|
|
if (_selectedStatus == status) return;
|
|
_selectedStatus = status;
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Définir le filtre de modèle
|
|
void setSelectedModel(String? model) async {
|
|
if (_selectedModel == model) return;
|
|
_selectedModel = model;
|
|
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();
|
|
}
|
|
|
|
/// Réinitialiser tous les filtres
|
|
void clearFilters() async {
|
|
_selectedCategory = null;
|
|
_selectedStatus = null;
|
|
_selectedModel = null;
|
|
_searchQuery = '';
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MÉTHODES COMPATIBILITÉ (pour ancien code)
|
|
// ============================================================================
|
|
|
|
/// Recharger les équipements (ancien système)
|
|
Future<void> refresh() async {
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
await loadEquipments();
|
|
}
|
|
}
|
|
|
|
/// Stream des équipements (pour compatibilité avec ancien code)
|
|
Stream<List<EquipmentModel>> get equipmentStream async* {
|
|
if (!_isInitialized && !_usePagination) {
|
|
await loadEquipments();
|
|
}
|
|
yield equipment;
|
|
}
|
|
|
|
/// Supprimer un équipement
|
|
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
|
|
try {
|
|
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
await loadEquipments();
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Ajouter un équipement
|
|
Future<void> addEquipment(EquipmentModel equipment) async {
|
|
try {
|
|
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
await loadEquipments();
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Mettre à jour un équipement
|
|
Future<void> updateEquipment(EquipmentModel equipment) async {
|
|
try {
|
|
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
|
if (_usePagination) {
|
|
await reload();
|
|
} else {
|
|
await loadEquipments();
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Charger les marques
|
|
Future<void> loadBrands() async {
|
|
await ensureLoaded();
|
|
_extractUniqueValues();
|
|
}
|
|
|
|
/// Charger les modèles
|
|
Future<void> loadModels() async {
|
|
await ensureLoaded();
|
|
_extractUniqueValues();
|
|
}
|
|
|
|
/// Charger les modèles d'une marque spécifique
|
|
Future<List<String>> loadModelsByBrand(String brand) async {
|
|
await ensureLoaded();
|
|
return _equipment
|
|
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
|
|
.map((eq) => eq.model ?? '')
|
|
.where((model) => model.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
}
|
|
|
|
/// Charger les sous-catégories d'une catégorie spécifique
|
|
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
|
|
await ensureLoaded();
|
|
return _equipment
|
|
.where((eq) => eq.category == category)
|
|
.map((eq) => eq.subCategory ?? '')
|
|
.where((sub) => sub.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
}
|
|
|
|
/// Calculer le statut réel d'un équipement (pour badge)
|
|
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
|
|
// Pour les consommables/câbles, vérifier le seuil critique
|
|
if (equipment.category == EquipmentCategory.consumable ||
|
|
equipment.category == EquipmentCategory.cable) {
|
|
final availableQty = equipment.availableQuantity ?? 0;
|
|
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
|
|
|
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
|
|
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
|
|
}
|
|
}
|
|
|
|
// Sinon retourner le statut de base
|
|
return equipment.status;
|
|
}
|
|
}
|
|
|