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:
ElPoyo
2026-01-18 12:40:23 +01:00
parent b79791ff7a
commit a182f1b922
21 changed files with 2069 additions and 1588 deletions

View File

@@ -99,7 +99,7 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return UpdateChecker(
child: MaterialApp(
title: 'EM2 ERP',
title: 'EM2 Hub',
theme: ThemeData(
primarySwatch: Colors.red,
primaryColor: AppColors.noir,

View File

@@ -1,27 +1,48 @@
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;
List<ContainerModel> get containers => _containers;
// 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 {
@@ -31,19 +52,43 @@ class ContainerProvider with ChangeNotifier {
await loadContainers();
}
/// Charger tous les containers via l'API
/// Charger tous les containers via l'API (avec pagination automatique)
Future<void> loadContainers() async {
_isLoading = true;
notifyListeners();
try {
final containers = await _containerService.getContainers(
type: _selectedType,
status: _selectedStatus,
searchQuery: _searchQuery,
);
_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');
}
_containers = containers;
_isLoading = false;
_isInitialized = true;
notifyListeners();
@@ -80,22 +125,144 @@ class ContainerProvider with ChangeNotifier {
}
/// Définir le type sélectionné
/// Définir le type sélectionné
void setSelectedType(ContainerType? type) {
void setSelectedType(ContainerType? type) async {
if (_selectedType == type) return;
_selectedType = type;
notifyListeners();
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le statut sélectionné
void setSelectedStatus(EquipmentStatus? status) {
void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status;
notifyListeners();
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir la requête de recherche
/// 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

View File

@@ -1,109 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import '../models/equipment_model.dart';
class ContainerProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<ContainerModel> _containers = [];
ContainerType? _selectedType;
EquipmentStatus? _selectedStatus;
String _searchQuery = '';
bool _isLoading = false;
// Getters
List<ContainerModel> get containers => _filteredContainers;
ContainerType? get selectedType => _selectedType;
EquipmentStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
/// Charger tous les conteneurs via l'API
Future<void> loadContainers() async {
_isLoading = true;
notifyListeners();
try {
final containersData = await _dataService.getContainers();
_containers = containersData.map((data) {
return ContainerModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading containers: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Obtenir les conteneurs filtrés
List<ContainerModel> get _filteredContainers {
var filtered = _containers;
if (_selectedType != null) {
filtered = filtered.where((c) => c.type == _selectedType).toList();
}
if (_selectedStatus != null) {
filtered = filtered.where((c) => c.status == _selectedStatus).toList();
}
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filtered = filtered.where((c) {
return c.name.toLowerCase().contains(query) ||
c.id.toLowerCase().contains(query);
}).toList();
}
return filtered;
}
/// Définir le filtre de type
void setSelectedType(ContainerType? type) {
_selectedType = type;
notifyListeners();
}
/// Définir le filtre de statut
void setSelectedStatus(EquipmentStatus? status) {
_selectedStatus = status;
notifyListeners();
}
/// Définir la requête de recherche
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
/// Réinitialiser tous les filtres
void clearFilters() {
_selectedType = null;
_selectedStatus = null;
_searchQuery = '';
notifyListeners();
}
/// Recharger les conteneurs
Future<void> refresh() async {
await loadContainers();
}
/// Obtenir un conteneur par ID
ContainerModel? getById(String id) {
try {
return _containers.firstWhere((c) => c.id == id);
} catch (e) {
return null;
}
}
}

View File

@@ -1,29 +1,43 @@
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
List<EquipmentModel> _paginatedEquipment = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour getEquipmentsByIds et compatibilité
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; // Flag pour savoir si les équipements ont été chargés
bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
// Constructeur - Ne charge PAS automatiquement
// Les équipements seront chargés à la demande (page de gestion ou via getEquipmentsByIds)
EquipmentProvider();
// Getters
List<EquipmentModel> get equipment => _filteredEquipment;
List<EquipmentModel> get allEquipment => _equipment; // Tous les équipements sans filtre
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;
@@ -31,42 +45,86 @@ class EquipmentProvider extends ChangeNotifier {
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 {
if (_isInitialized || _isLoading) {
print('[EquipmentProvider] Equipment already loaded or loading, skipping...');
// 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 la page de gestion)
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
Future<void> loadEquipments() async {
print('[EquipmentProvider] Starting to load equipments...');
print('[EquipmentProvider] Starting to load ALL equipments...');
_isLoading = true;
notifyListeners();
try {
print('[EquipmentProvider] Calling getEquipments API...');
final equipmentsData = await _dataService.getEquipments();
print('[EquipmentProvider] Received ${equipmentsData.length} equipments from API');
_equipment.clear();
String? lastVisible;
bool hasMore = true;
int pageCount = 0;
_equipment = equipmentsData.map((data) {
return EquipmentModel.fromMap(data, data['id'] as String);
}).toList();
print('[EquipmentProvider] Mapped ${_equipment.length} equipment models');
// 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');
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
} catch (e) {
print('[EquipmentProvider] Error loading equipments: $e');
_isLoading = false;
@@ -118,7 +176,8 @@ class EquipmentProvider extends ChangeNotifier {
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
final loadedEquipments = equipmentsData.map((data) {
return EquipmentModel.fromMap(data, data['id'] as String);
final id = data['id'] as String; // L'ID vient du backend
return EquipmentModel.fromMap(data, id);
}).toList();
// Ajouter au cache
@@ -185,58 +244,205 @@ class EquipmentProvider extends ChangeNotifier {
return filtered;
}
/// Définir le filtre de catégorie
void setSelectedCategory(EquipmentCategory? category) {
_selectedCategory = category;
// ============================================================================
// 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) {
void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status;
notifyListeners();
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le filtre de modèle
void setSelectedModel(String? model) {
void setSelectedModel(String? model) async {
if (_selectedModel == model) return;
_selectedModel = model;
notifyListeners();
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir la requête de recherche
/// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query;
notifyListeners();
// 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() {
void clearFilters() async {
_selectedCategory = null;
_selectedStatus = null;
_selectedModel = null;
_searchQuery = '';
notifyListeners();
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Recharger les équipements
// ============================================================================
// MÉTHODES COMPATIBILITÉ (pour ancien code)
// ============================================================================
/// Recharger les équipements (ancien système)
Future<void> refresh() async {
await loadEquipments();
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
}
// === MÉTHODES STREAM (COMPATIBILITÉ) ===
/// Stream des équipements (pour compatibilité avec ancien code)
Stream<List<EquipmentModel>> get equipmentStream async* {
yield _equipment;
if (!_isInitialized && !_usePagination) {
await loadEquipments();
}
yield equipment;
}
/// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async {
try {
await _dataService.deleteEquipment(equipmentId);
await loadEquipments(); // Recharger la liste
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
print('Error deleting equipment: $e');
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
rethrow;
}
}
@@ -245,9 +451,13 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> addEquipment(EquipmentModel equipment) async {
try {
await _dataService.createEquipment(equipment.id, equipment.toMap());
await loadEquipments(); // Recharger la liste
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
print('Error adding equipment: $e');
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
rethrow;
}
}
@@ -256,52 +466,67 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> updateEquipment(EquipmentModel equipment) async {
try {
await _dataService.updateEquipment(equipment.id, equipment.toMap());
await loadEquipments(); // Recharger la liste
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
print('Error updating equipment: $e');
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
rethrow;
}
}
/// Charger les marques
Future<void> loadBrands() async {
// Les marques sont déjà chargées avec loadEquipments
await ensureLoaded();
_extractUniqueValues();
}
/// Charger les modèles
Future<void> loadModels() async {
// Les modèles sont déjà chargés avec loadEquipments
await ensureLoaded();
_extractUniqueValues();
}
/// Charger les modèles d'une marque spécifique
Future<List<String>> loadModelsByBrand(String brand) async {
// Filtrer les modèles par marque
final modelsByBrand = _equipment
.where((eq) => eq.brand == brand && eq.model != null)
.map((eq) => eq.model!)
await ensureLoaded();
return _equipment
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
.map((eq) => eq.model ?? '')
.where((model) => model.isNotEmpty)
.toSet()
.toList();
return modelsByBrand;
.toList()
..sort();
}
/// Charger les sous-catégories d'une catégorie spécifique
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
// Filtrer les sous-catégories par catégorie
final subCategoriesByCategory = _equipment
.where((eq) => eq.category == category && eq.subCategory != null && eq.subCategory!.isNotEmpty)
.map((eq) => eq.subCategory!)
await ensureLoaded();
return _equipment
.where((eq) => eq.category == category)
.map((eq) => eq.subCategory ?? '')
.where((sub) => sub.isNotEmpty)
.toSet()
.toList()
..sort();
return subCategoriesByCategory;
}
/// Calculer le statut réel d'un équipement (compatibilité)
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
// Pour l'instant, retourner le statut stocké
// TODO: Implémenter le calcul réel si nécessaire
/// 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;
}
}

View File

@@ -277,6 +277,63 @@ class FirebaseFunctionsApiService implements ApiService {
);
}
}
/// Appelle une Cloud Function avec pagination
Future<Map<String, dynamic>> callPaginated(
String functionName,
Map<String, dynamic> params,
) async {
try {
final headers = await _getHeaders();
final url = Uri.parse('$_baseUrl/$functionName');
DebugLog.info('[API] Calling paginated function: $functionName with params: $params');
final response = await http.post(
url,
headers: headers,
body: jsonEncode({'data': params}),
);
DebugLog.info('[API] Response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data;
} else {
DebugLog.error('[API] Error response: ${response.body}');
throw Exception('API call failed with status ${response.statusCode}: ${response.body}');
}
} catch (e) {
DebugLog.error('[API] Exception in callPaginated: $e');
rethrow;
}
}
/// Recherche rapide avec autocomplétion
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
final params = {
'query': query,
'limit': limit,
'includeEquipments': includeEquipments.toString(),
'includeContainers': includeContainers.toString(),
};
final response = await callPaginated('quickSearch', params);
final results = response['results'] as List<dynamic>? ?? [];
return results.cast<Map<String, dynamic>>();
} catch (e) {
DebugLog.error('[API] Error in quickSearch: $e');
return [];
}
}
}
/// Exception personnalisée pour les erreurs API

View File

@@ -169,7 +169,8 @@ class ContainerService {
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
for (var data in equipmentsData) {
final equipment = EquipmentModel.fromMap(data, data['id'] as String);
final id = data['id'] as String;
final equipment = EquipmentModel.fromMap(data, id);
if (equipment.status != EquipmentStatus.available) {
unavailableEquipment.add('${equipment.name} (${equipment.status})');
}
@@ -202,7 +203,10 @@ class ContainerService {
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
return equipmentsData
.map((data) => EquipmentModel.fromMap(data, data['id'] as String))
.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList();
} catch (e) {
print('Error getting container equipment: $e');

View File

@@ -1,4 +1,5 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Service générique pour les opérations de lecture de données via Cloud Functions
class DataService {
@@ -300,7 +301,7 @@ class DataService {
}
}
/// Récupère plusieurs conteneurs par leurs IDs
/// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
try {
if (containerIds.isEmpty) return [];
@@ -318,37 +319,119 @@ class DataService {
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting containers by IDs: $e');
throw Exception('Erreur lors de la récupération des conteneurs: $e');
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
/// Récupère les maintenances (optionnellement filtrées par équipement)
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
// ============================================================================
// EQUIPMENTS & CONTAINERS - Pagination
// ============================================================================
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
/// Récupère les équipements avec pagination et filtrage
Future<Map<String, dynamic>> getEquipmentsPaginated({
int limit = 20,
String? startAfter,
String? category,
String? status,
String? searchQuery,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (category != null) params['category'] = category;
if (status != null) params['status'] = status;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated',
params,
);
return {
'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
}
}
/// Récupère les containers contenant un équipement spécifique
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
/// Récupère les containers avec pagination et filtrage
Future<Map<String, dynamic>> getContainersPaginated({
int limit = 20,
String? startAfter,
String? type,
String? status,
String? searchQuery,
String? category,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (type != null) params['type'] = type;
if (status != null) params['status'] = status;
if (category != null) params['category'] = category;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated',
params,
);
return {
'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
throw Exception('Erreur lors de la récupération des containers pour l\'équipement: $e');
DebugLog.error('[DataService] Error in getContainersPaginated', e);
throw Exception('Erreur lors de la récupération paginée des containers: $e');
}
}
/// Recherche rapide (autocomplétion)
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
query,
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
);
} catch (e) {
DebugLog.error('[DataService] Error in quickSearch', e);
return [];
}
}
@@ -454,6 +537,21 @@ class DataService {
// MAINTENANCES
// ============================================================================
/// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
}
}
/// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async {
try {
@@ -463,6 +561,20 @@ class DataService {
}
}
/// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
// ============================================================================
// USERS
// ============================================================================

View File

@@ -9,6 +9,34 @@ class EquipmentService {
final ApiService _apiService = apiService;
final DataService _dataService = DataService(apiService);
// ============================================================================
// Helper privée - Charge TOUS les équipements avec pagination
// ============================================================================
/// Charge tous les équipements en utilisant la pagination
Future<List<Map<String, dynamic>>> _getAllEquipmentsPaginated() async {
final allEquipments = <Map<String, dynamic>>[];
String? lastVisible;
bool hasMore = true;
while (hasMore) {
final result = await _dataService.getEquipmentsPaginated(
limit: 100,
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments = result['equipments'] as List<dynamic>;
allEquipments.addAll(equipments.cast<Map<String, dynamic>>());
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
}
return allEquipments;
}
// ============================================================================
// CRUD Operations - Utilise le backend sécurisé
// ============================================================================
@@ -82,10 +110,13 @@ class EquipmentService {
String? searchQuery,
}) async {
try {
final equipmentsData = await _dataService.getEquipments();
final equipmentsData = await _getAllEquipmentsPaginated();
var equipmentList = equipmentsData
.map((data) => EquipmentModel.fromMap(data, data['id'] as String))
.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList();
// Filtres côté client
@@ -165,7 +196,11 @@ class EquipmentService {
});
final alternatives = (response['alternatives'] as List?)
?.map((a) => EquipmentModel.fromMap(a as Map<String, dynamic>, a['id'] as String))
?.map((a) {
final map = a as Map<String, dynamic>;
final id = map['id'] as String;
return EquipmentModel.fromMap(map, id);
})
.toList() ?? [];
return alternatives;
@@ -204,27 +239,6 @@ class EquipmentService {
}
}
/// Vérifier les stocks critiques et créer des alertes
Future<void> checkCriticalStock() async {
try {
final equipmentsData = await _dataService.getEquipments();
for (var data in equipmentsData) {
final equipment = EquipmentModel.fromMap(data, data['id'] as String);
// Filtrer uniquement les consommables et câbles
if ((equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) &&
equipment.isCriticalStock) {
await _createLowStockAlert(equipment);
}
}
} catch (e) {
print('Error checking critical stock: $e');
rethrow;
}
}
/// Créer une alerte de stock faible
Future<void> _createLowStockAlert(EquipmentModel equipment) async {
try {
@@ -251,50 +265,10 @@ class EquipmentService {
return equipmentId;
}
/// Récupérer tous les modèles uniques (pour l'indexation/autocomplete)
Future<List<String>> getAllModels() async {
try {
final equipmentsData = await _dataService.getEquipments();
final models = <String>{};
for (var data in equipmentsData) {
final model = data['model'] as String?;
if (model != null && model.isNotEmpty) {
models.add(model);
}
}
return models.toList()..sort();
} catch (e) {
print('Error getting all models: $e');
rethrow;
}
}
/// Récupérer toutes les marques uniques (pour l'indexation/autocomplete)
Future<List<String>> getAllBrands() async {
try {
final equipmentsData = await _dataService.getEquipments();
final brands = <String>{};
for (var data in equipmentsData) {
final brand = data['brand'] as String?;
if (brand != null && brand.isNotEmpty) {
brands.add(brand);
}
}
return brands.toList()..sort();
} catch (e) {
print('Error getting all brands: $e');
rethrow;
}
}
/// Récupérer les modèles filtrés par marque
Future<List<String>> getModelsByBrand(String brand) async {
try {
final equipmentsData = await _dataService.getEquipments();
final equipmentsData = await _getAllEquipmentsPaginated();
final models = <String>{};
for (var data in equipmentsData) {
@@ -316,7 +290,7 @@ class EquipmentService {
/// Récupérer les sous-catégories filtrées par catégorie
Future<List<String>> getSubCategoriesByCategory(EquipmentCategory category) async {
try {
final equipmentsData = await _dataService.getEquipments();
final equipmentsData = await _getAllEquipmentsPaginated();
final subCategories = <String>{};
final categoryString = equipmentCategoryToString(category);
@@ -375,7 +349,10 @@ class EquipmentService {
final equipmentsData = await _dataService.getEquipmentsByIds(ids);
return equipmentsData
.map((data) => EquipmentModel.fromMap(data, data['id'] as String))
.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList();
} catch (e) {
print('Error getting equipments by IDs: $e');

View File

@@ -231,7 +231,7 @@ END:VCALENDAR''';
// Lien vers l'application
buffer.writeln('');
buffer.writeln('---');
buffer.writeln('Généré par EM2 ERP ${AppVersion.fullVersion} http://app.em2events.fr');
buffer.writeln('Généré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr');
return buffer.toString();
}

View File

@@ -6,7 +6,6 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/views/equipment_detail_page.dart';
@@ -14,10 +13,11 @@ import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_search_bar.dart';
import 'package:em2rp/views/widgets/management/management_card.dart';
import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class ContainerManagementPage extends StatefulWidget {
const ContainerManagementPage({super.key});
@@ -30,13 +30,61 @@ class ContainerManagementPage extends StatefulWidget {
class _ContainerManagementPageState extends State<ContainerManagementPage>
with SelectionModeMixin<ContainerManagementPage> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
ContainerType? _selectedType;
EquipmentStatus? _selectedStatus;
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@override
void initState() {
super.initState();
// Activer le mode pagination
final provider = context.read<ContainerProvider>();
provider.enablePagination();
// Ajouter le listener de scroll
_scrollController.addListener(_onScroll);
// Charger la première page
WidgetsBinding.instance.addPostFrameCallback((_) {
provider.loadFirstPage();
});
}
void _onScroll() {
// Éviter les appels multiples
if (_isLoadingMore) return;
final provider = context.read<ContainerProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
});
}
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose();
context.read<ContainerProvider>().disablePagination();
super.dispose();
}
@@ -73,6 +121,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
style: const TextStyle(color: Colors.white),
),
actions: [
const NotificationBadge(),
if (hasSelection) ...[
IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white),
@@ -87,44 +136,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
],
],
)
: AppBar(
title: const Text('Gestion des Containers'),
backgroundColor: AppColors.rouge,
: CustomAppBar(
title: 'Gestion des Containers',
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la gestion des équipements',
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
),
actions: [
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
final shouldLogout = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Déconnexion'),
),
],
),
);
if (shouldLogout == true && context.mounted) {
await context.read<LocalUserProvider>().signOut();
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/login');
}
}
},
),
],
showLogoutButton: true,
),
drawer: const MainDrawer(currentPage: '/container_management'),
floatingActionButton: !isSelectionMode
@@ -174,21 +193,36 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
}
Widget _buildSearchBar() {
return ManagementSearchBar(
return SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher un container...',
onChanged: (value) {
context.read<ContainerProvider>().setSearchQuery(value);
},
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode,
showSelectionModeButton: !isSelectionMode,
additionalActions: [
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.qr_code_scanner, color: AppColors.rouge),
tooltip: 'Scanner un QR Code',
onClear: () {
_searchController.clear();
context.read<ContainerProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
);
}
@@ -274,30 +308,12 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
...ContainerType.values.map((type) {
return _buildFilterOption(type, type.label);
}),
const Divider(height: 32),
// Filtre par statut
Text(
'Statut',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
const SizedBox(height: 8),
_buildStatusFilter(null, 'Tous les statuts'),
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
],
),
);
}
Widget _buildFilterOption(ContainerType? type, String label) {
final isSelected = _selectedType == type;
return RadioListTile<ContainerType?>(
title: Text(label),
value: type,
@@ -314,36 +330,62 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
);
}
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
final isSelected = _selectedStatus == status;
return RadioListTile<EquipmentStatus?>(
title: Text(label),
value: status,
groupValue: _selectedStatus,
activeColor: AppColors.rouge,
dense: true,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
setState(() {
_selectedStatus = value;
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
});
},
);
}
Widget _buildContainerList() {
return Consumer<ContainerProvider>(
builder: (context, provider, child) {
return ManagementList<ContainerModel>(
stream: provider.containersStream,
cachedItems: _cachedContainers,
emptyMessage: 'Aucun container trouvé',
emptyIcon: Icons.inventory_2_outlined,
onDataReceived: (items) {
_cachedContainers = items;
// Afficher l'indicateur de chargement initial
if (provider.isLoading && provider.containers.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final containers = provider.containers;
// Afficher le message vide
if (containers.isEmpty && !provider.isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucun container trouvé',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
],
),
);
}
// Calculer le nombre total d'items
final itemCount = containers.length + (provider.hasMore ? 1 : 0);
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == containers.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
);
}
return _buildContainerCard(containers[index]);
},
itemBuilder: (container) => _buildContainerCard(container),
);
},
);

View File

@@ -18,6 +18,8 @@ import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key});
@@ -31,23 +33,66 @@ class EquipmentManagementPage extends StatefulWidget {
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@override
void initState() {
super.initState();
DebugLog.info('[EquipmentManagementPage] initState called');
// Charger les équipements au démarrage
// Activer le mode pagination
final provider = context.read<EquipmentProvider>();
provider.enablePagination();
// Ajouter le listener de scroll pour le chargement infini
_scrollController.addListener(_onScroll);
// Charger la première page au démarrage
WidgetsBinding.instance.addPostFrameCallback((_) {
DebugLog.info('[EquipmentManagementPage] Loading equipments...');
context.read<EquipmentProvider>().loadEquipments();
DebugLog.info('[EquipmentManagementPage] Loading first page...');
provider.loadFirstPage();
});
}
void _onScroll() {
// Éviter les appels multiples
if (_isLoadingMore) return;
final provider = context.read<EquipmentProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
});
}
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose();
}
@@ -84,6 +129,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
style: const TextStyle(color: Colors.white),
),
actions: [
const NotificationBadge(),
if (hasSelection) ...[
IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white),
@@ -100,13 +146,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
)
: CustomAppBar(
title: 'Gestion du matériel',
actions: [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
onPressed: toggleSelectionMode,
),
],
),
drawer: const MainDrawer(currentPage: '/equipment_management'),
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
@@ -130,61 +169,39 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildMobileLayout() {
return Column(
children: [
// Barre de recherche et bouton boîtes
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par nom, modèle ou ID...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
// Barre de recherche et boutons d'action
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
const SizedBox(width: 8),
// Bouton Scanner QR
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
const SizedBox(width: 8),
// Bouton Gérer les boîtes
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
),
],
),
// Menu horizontal de filtres par catégorie
SizedBox(
@@ -249,49 +266,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
),
child: Column(
children: [
// Bouton Gérer les boîtes
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2, color: Colors.white),
label: const Text(
'Gérer les boîtes',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
minimumSize: const Size(double.infinity, 50),
),
),
),
// Bouton Scanner QR
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0),
child: ElevatedButton.icon(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner, color: Colors.white),
label: const Text(
'Scanner QR Code',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[700],
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
minimumSize: const Size(double.infinity, 50),
),
),
),
const Divider(),
const SizedBox(height: 16),
// En-tête filtres
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -312,37 +287,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
],
),
),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
context
.read<EquipmentProvider>()
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
),
// Filtres par catégorie
Padding(
padding:
@@ -396,7 +340,56 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
),
),
// Contenu principal
Expanded(child: _buildEquipmentList()),
Expanded(
child: Column(
children: [
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
Expanded(child: _buildEquipmentList()),
],
),
),
],
);
}
@@ -469,8 +462,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
if (provider.isLoading && _cachedEquipment == null) {
DebugLog.info('[EquipmentManagementPage] Showing loading indicator');
// Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator());
}
@@ -501,9 +495,26 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
}
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
return ListView.builder(
itemCount: equipments.length,
controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == equipments.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
);
}
return _buildEquipmentCard(equipments[index]);
},
);

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
class SearchActionsBar extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
final List<Widget> actions;
const SearchActionsBar({
super.key,
required this.controller,
required this.hintText,
required this.onChanged,
required this.onClear,
this.actions = const [],
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: onChanged,
),
),
if (actions.isNotEmpty) ...[
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
actions[i],
],
],
),
],
],
),
);
}
}

View File

@@ -49,19 +49,28 @@ class _EquipmentAssociatedEventsSectionState
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API
final containersData = await _dataService.getContainers();
// Collecter tous les IDs de containers utilisés dans les événements
final allContainerIds = <String>{};
for (var eventData in eventsData) {
final assignedContainers = eventData['assignedContainers'] as List<dynamic>? ?? [];
allContainerIds.addAll(assignedContainers.map((id) => id.toString()));
}
// Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger)
final containersWithEquipment = <String>[];
for (var containerData in containersData) {
try {
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (allContainerIds.isNotEmpty) {
final containersData = await _dataService.getContainersByIds(allContainerIds.toList());
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerData['id'] as String);
for (var containerData in containersData) {
try {
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerData['id'] as String);
}
} catch (e) {
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
}
} catch (e) {
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
}
}

View File

@@ -43,19 +43,28 @@ class _EquipmentCurrentEventsSectionState
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API
final containersData = await _dataService.getContainers();
// Collecter tous les IDs de containers utilisés dans les événements
final allContainerIds = <String>{};
for (var eventData in eventsData) {
final assignedContainers = eventData['assignedContainers'] as List<dynamic>? ?? [];
allContainerIds.addAll(assignedContainers.map((id) => id.toString()));
}
// Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger)
final containersWithEquipment = <String>[];
for (var containerData in containersData) {
try {
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (allContainerIds.isNotEmpty) {
final containersData = await _dataService.getContainersByIds(allContainerIds.toList());
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerData['id'] as String);
for (var containerData in containersData) {
try {
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerData['id'] as String);
}
} catch (e) {
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
}
} catch (e) {
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
}
}

View File

@@ -16,35 +16,25 @@ class EquipmentStatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
// Calculer le statut réel (synchrone maintenant)
final status = provider.calculateRealStatus(equipment);
// Logs désactivés en production
return FutureBuilder<EquipmentStatus>(
// On calcule le statut réel de manière asynchrone
future: provider.calculateRealStatus(equipment),
// En attendant, on affiche le statut stocké
initialData: equipment.status,
builder: (context, snapshot) {
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
final status = snapshot.data ?? equipment.status;
// Logs désactivés en production
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
},
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
}
}

View File

@@ -1,435 +0,0 @@
import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour sélectionner les boîtes parentes d'un équipement
class ParentBoxesSelector extends StatefulWidget {
final List<ContainerModel> availableBoxes;
final List<String> selectedBoxIds;
final Function(List<String>) onSelectionChanged;
const ParentBoxesSelector({
super.key,
required this.availableBoxes,
required this.selectedBoxIds,
required this.onSelectionChanged,
});
@override
State<ParentBoxesSelector> createState() => _ParentBoxesSelectorState();
}
class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(ParentBoxesSelector oldWidget) {
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<ContainerModel> get _filteredBoxes {
if (_searchQuery.isEmpty) {
return widget.availableBoxes;
}
final query = _searchQuery.toLowerCase();
return widget.availableBoxes.where((box) {
return box.name.toLowerCase().contains(query) ||
box.id.toLowerCase().contains(query) ||
box.type.label.toLowerCase().contains(query);
}).toList();
}
void _toggleSelection(String boxId) {
final newSelection = List<String>.from(widget.selectedBoxIds);
if (newSelection.contains(boxId)) {
newSelection.remove(boxId);
} else {
newSelection.add(boxId);
}
widget.onSelectionChanged(newSelection);
}
@override
Widget build(BuildContext context) {
if (widget.availableBoxes.isEmpty && widget.selectedBoxIds.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey.shade600),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Aucune boîte disponible',
style: TextStyle(color: Colors.grey),
),
),
],
),
),
);
}
final filteredBoxes = _filteredBoxes;
final selectedCount = widget.selectedBoxIds.length;
// Vérifier s'il y a des boîtes sélectionnées qui ne sont pas dans la liste
final missingBoxIds = widget.selectedBoxIds
.where((id) => !widget.availableBoxes.any((box) => box.id == id))
.toList();
return Card(
elevation: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec titre et compteur
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
const Text(
'Boîtes parentes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (selectedCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.rouge.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
'$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.rouge,
),
),
),
],
),
),
const Divider(height: 1),
// Message d'avertissement si des boîtes sélectionnées sont manquantes
if (missingBoxIds.isNotEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade700),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Boîtes introuvables',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
const SizedBox(height: 4),
Text(
'Les boîtes suivantes sont sélectionnées mais n\'existent plus : ${missingBoxIds.join(", ")}',
style: TextStyle(
fontSize: 13,
color: Colors.orange.shade800,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
// Retirer les boîtes manquantes de la sélection
final newSelection = widget.selectedBoxIds
.where((id) => !missingBoxIds.contains(id))
.toList();
widget.onSelectionChanged(newSelection);
},
tooltip: 'Retirer ces boîtes',
),
],
),
),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: 'Rechercher par nom, ID ou type...',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchController.clear();
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.rouge, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
// Message si aucun résultat
if (filteredBoxes.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Center(
child: Column(
children: [
Icon(Icons.search_off, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'Aucune boîte trouvée',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Essayez une autre recherche',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
],
),
),
)
else
// Liste des boîtes
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: filteredBoxes.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final box = filteredBoxes[index];
final isSelected = widget.selectedBoxIds.contains(box.id);
if (index == 0) {
DebugLog.info('[ParentBoxesSelector] Building item $index');
DebugLog.info('[ParentBoxesSelector] Box ID: ${box.id}');
DebugLog.info('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
DebugLog.info('[ParentBoxesSelector] Is selected: $isSelected');
}
return _buildBoxCard(box, isSelected);
},
),
const SizedBox(height: 16),
],
),
);
}
Widget _buildBoxCard(ContainerModel box, bool isSelected) {
return Card(
elevation: isSelected ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected ? AppColors.rouge : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: InkWell(
onTap: () => _toggleSelection(box.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: (value) => _toggleSelection(box.id),
activeColor: AppColors.rouge,
),
const SizedBox(width: 8),
// Icône du type de container
CircleAvatar(
backgroundColor: isSelected
? AppColors.rouge.withValues(alpha: 0.15)
: Colors.grey.shade200,
radius: 24,
child: box.type.getIconForAvatar(
size: 24,
color: isSelected ? AppColors.rouge : Colors.grey.shade700,
),
),
const SizedBox(width: 12),
// Informations de la boîte
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
box.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: isSelected ? AppColors.rouge : Colors.black87,
),
),
const SizedBox(height: 4),
Text(
box.type.label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 6),
// Badges
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildInfoChip(
icon: Icons.inventory,
label: '${box.itemCount} équip.',
color: Colors.blue,
),
if (box.weight != null)
_buildInfoChip(
icon: Icons.scale,
label: '${box.weight!.toStringAsFixed(1)} kg',
color: Colors.orange,
),
_buildInfoChip(
icon: Icons.tag,
label: box.id,
color: Colors.grey,
isCompact: true,
),
],
),
],
),
),
// Indicateur de sélection
if (isSelected)
const Icon(
Icons.check_circle,
color: AppColors.rouge,
size: 24,
),
],
),
),
),
);
}
Widget _buildInfoChip({
required IconData icon,
required String label,
required Color color,
bool isCompact = false,
}) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: isCompact ? 10 : 12,
color: color.withValues(alpha: 0.8),
),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: isCompact ? 9 : 11,
fontWeight: FontWeight.w600,
color: color.withValues(alpha: 0.9),
),
),
],
),
);
}
}

View File

@@ -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() {

View File

@@ -156,212 +156,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// Charger les équipements et conteneurs
final containerProvider = context.read<ContainerProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
// On enregistre directement la sélection
final allContainers = await containerProvider.containersStream.first;
final allEquipment = await equipmentProvider.equipmentStream.first;
DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...');
final allConflicts = <String, List<AvailabilityConflict>>{};
// 1. Vérifier les conflits pour les équipements directs
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
for (var eq in newEquipment) {
DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
final equipment = allEquipment.firstWhere(
(e) => e.id == eq.equipmentId,
orElse: () => EquipmentModel(
id: eq.equipmentId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
// Pour les équipements quantifiables (consommables/câbles)
if (equipment.hasQuantity) {
// Vérifier la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: equipment,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// ⚠️ Ne créer un conflit QUE si la quantité demandée est supérieure à la quantité disponible
if (eq.quantity > availableQty) {
// Il y a vraiment un conflit de quantité
final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity(
equipment: equipment,
requestedQuantity: eq.quantity,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// Ne garder que les conflits réels (quand il n'y a pas assez de stock)
if (conflicts.isNotEmpty) {
allConflicts[eq.equipmentId] = conflicts;
}
}
// ✅ Sinon, pas de conflit : il y a assez de stock disponible
} else {
// Pour les équipements non quantifiables (vérification classique)
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
if (conflicts.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
allConflicts[eq.equipmentId] = conflicts;
} else {
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
}
}
}
// 2. Vérifier les conflits pour les boîtes et leur contenu
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
for (var containerId in newContainers) {
final container = allContainers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
// Récupérer les équipements de la boîte
final containerEquipment = container.equipmentIds
.map((eqId) => allEquipment.firstWhere(
(e) => e.id == eqId,
orElse: () => EquipmentModel(
id: eqId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
))
.toList();
// Vérifier chaque équipement de la boîte individuellement
final containerConflicts = <AvailabilityConflict>[];
for (var equipment in containerEquipment) {
if (equipment.hasQuantity) {
// Pour les consommables/câbles, vérifier la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: equipment,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// La boîte contient 1 unité de cet équipement
// Si la quantité disponible est insuffisante, créer un conflit
if (availableQty < 1) {
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
containerConflicts.addAll(conflicts);
}
} else {
// Pour les équipements non quantifiables
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
containerConflicts.addAll(conflicts);
}
}
if (containerConflicts.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
allConflicts[containerId] = containerConflicts;
} else {
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
}
}
DebugLog.info('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
if (allConflicts.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
// Afficher le dialog de conflits
final action = await showDialog<String>(
context: context,
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
);
DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action');
if (action == 'cancel') {
return; // Annuler l'ajout
} else if (action == 'force_removed') {
// Identifier quels équipements/conteneurs retirer
final removedIds = allConflicts.keys.toSet();
// Retirer les équipements directs en conflit
newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId));
// Retirer les boîtes en conflit
newContainers.removeWhere((containerId) => removedIds.contains(containerId));
// Informer l'utilisateur des boîtes retirées
for (var containerId in removedIds.where((id) => newContainers.contains(id))) {
if (mounted) {
final container = allContainers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('La boîte "${container.name}" a été retirée en raison de conflits.'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
),
);
}
}
}
// Si 'force_all', on garde tout
}
// Fusionner avec l'existant
final updatedEquipment = [...widget.assignedEquipment];
final updatedContainers = [...widget.assignedContainers];
@@ -398,7 +195,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// Recharger le cache
await _loadEquipmentAndContainers();
}
}
void _removeEquipment(String equipmentId) {
final updated = widget.assignedEquipment

View File

@@ -9,12 +9,14 @@ class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final bool showLogoutButton;
final Widget? leading;
const CustomAppBar({
super.key,
required this.title,
this.actions,
this.showLogoutButton = true,
this.leading,
});
@override
@@ -30,6 +32,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
return AppBar(
title: Text(widget.title),
backgroundColor: AppColors.rouge,
leading: widget.leading,
actions: [
NotificationBadge(),
if (widget.showLogoutButton)