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:
@@ -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]);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user