perf: ajout d'un Debouncer 400ms sur toutes les barres de recherche

This commit is contained in:
ElPoyo
2026-05-26 13:41:21 +02:00
parent 0bbc77ffc8
commit 32f1718a8c
4 changed files with 103 additions and 62 deletions
+21
View File
@@ -0,0 +1,21 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Utilitaire pour différer l'exécution d'une action après un délai.
/// Utilisé principalement pour les champs de recherche afin d'éviter
/// des requêtes à chaque frappe clavier.
class Debouncer {
final Duration delay;
Timer? _timer;
Debouncer({this.delay = const Duration(milliseconds: 400)});
void call(VoidCallback action) {
_timer?.cancel();
_timer = Timer(delay, action);
}
void dispose() {
_timer?.cancel();
}
}
+4 -1
View File
@@ -9,6 +9,7 @@ import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/id_generator.dart'; import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debouncer.dart';
class ContainerFormPage extends StatefulWidget { class ContainerFormPage extends StatefulWidget {
final ContainerModel? container; final ContainerModel? container;
@@ -658,6 +659,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
EquipmentCategory? _filterCategory; EquipmentCategory? _filterCategory;
String _searchQuery = ''; String _searchQuery = '';
late Set<String> _tempSelectedIds; late Set<String> _tempSelectedIds;
final _searchDebouncer = Debouncer();
final List<EquipmentModel> _paginatedEquipments = []; final List<EquipmentModel> _paginatedEquipments = [];
bool _isLoadingMore = false; bool _isLoadingMore = false;
@@ -677,6 +679,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_searchDebouncer.dispose();
super.dispose(); super.dispose();
} }
@@ -790,7 +793,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_searchQuery = value; _searchQuery = value;
}); });
_reloadData(); _searchDebouncer(_reloadData);
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
+10 -9
View File
@@ -20,6 +20,7 @@ import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart'; import 'package:em2rp/views/widgets/notification_badge.dart';
import 'package:em2rp/utils/debouncer.dart';
class EquipmentManagementPage extends StatefulWidget { class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key}); const EquipmentManagementPage({super.key});
@@ -33,6 +34,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> { with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final _searchDebouncer = Debouncer();
EquipmentCategory? _selectedCategory; EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment; List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@@ -87,6 +89,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_scrollController.removeListener(_onScroll); _scrollController.removeListener(_onScroll);
_scrollController.dispose(); _scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
_searchDebouncer.dispose();
// Désactiver le mode pagination en quittant // Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination(); context.read<EquipmentProvider>().disablePagination();
super.dispose(); super.dispose();
@@ -169,9 +172,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar( SearchActionsBar(
controller: _searchController, controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...', hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) { onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () { onClear: () {
_searchController.clear(); _searchController.clear();
context.read<EquipmentProvider>().setSearchQuery(''); context.read<EquipmentProvider>().setSearchQuery('');
@@ -342,9 +343,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar( SearchActionsBar(
controller: _searchController, controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...', hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) { onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () { onClear: () {
_searchController.clear(); _searchController.clear();
context.read<EquipmentProvider>().setSearchQuery(''); context.read<EquipmentProvider>().setSearchQuery('');
@@ -501,9 +500,11 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return ListView.builder( return ListView.builder(
controller: _scrollController, controller: _scrollController,
itemCount: itemCount, itemCount: itemCount,
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll // ✅ prototypeItem utilisé car les cartes ont des hauteurs variables :
// Note : À ajuster selon la hauteur réelle de vos cartes // - Les équipements standards (ListTile + margin) font ~88px
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur // - Les consommables/câbles affichent _buildQuantityDisplay en plus (~30px)
// - prototypeItem permet à Flutter d'optimiser le scroll sans couper les items
prototypeItem: const SizedBox(height: 88),
// ✅ Augmenter le cache pour un scroll plus fluide // ✅ Augmenter le cache pour un scroll plus fluide
cacheExtent: 500, // Précharger 500px en plus cacheExtent: 500, // Précharger 500px en plus
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -1,4 +1,4 @@
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
@@ -7,6 +7,7 @@ import 'package:em2rp/services/event_availability_service.dart';
import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/debouncer.dart';
/// Type de s├®lection dans le dialog /// Type de s├®lection dans le dialog
enum SelectionType { equipment, container } enum SelectionType { equipment, container }
@@ -108,6 +109,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
bool _isLoadingConflicts = false; bool _isLoadingConflicts = false;
String _searchQuery = ''; String _searchQuery = '';
final _searchDebouncer = Debouncer();
// Nouvelles options d'affichage // Nouvelles options d'affichage
bool _showConflictingItems = false; // Afficher les ├®quipements/bo├«tes en conflit bool _showConflictingItems = false; // Afficher les ├®quipements/bo├«tes en conflit
@@ -435,6 +437,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
_searchController.dispose(); _searchController.dispose();
_scrollController.dispose(); // Nettoyer le ScrollController _scrollController.dispose(); // Nettoyer le ScrollController
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier _selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
_searchDebouncer.dispose();
super.dispose(); super.dispose();
} }
@@ -1209,7 +1212,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
onChanged: (value) { onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase()); setState(() => _searchQuery = value.toLowerCase());
// Recharger depuis le d├®but avec le nouveau filtre // Recharger depuis le d├®but avec le nouveau filtre
_reloadData(); _searchDebouncer(_reloadData);
}, },
), ),
@@ -1379,12 +1382,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
// Containers // Containers
final filteredContainers = _paginatedContainers.where((container) { final filteredContainers = _paginatedContainers.where((container) {
if (!_showConflictingItems) { if (!_showConflictingItems) {
// V├®rifier si le container lui-m├¬me est en conflit // Vérifier si le container lui-même est en conflit
if (_conflictingContainerIds.contains(container.id)) { if (_conflictingContainerIds.contains(container.id)) {
return false; return false;
} }
// V├®rifier si le container a des ├®quipements enfants en conflit // Vérifier si le container a des équipements enfants en conflit
final hasConflictingChildren = container.equipmentIds.any( final hasConflictingChildren = container.equipmentIds.any(
(eqId) => _conflictingEquipmentIds.contains(eqId), (eqId) => _conflictingEquipmentIds.contains(eqId),
); );
@@ -1412,60 +1415,73 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
} }
return false; return false;
}, },
child: ListView( child: Builder(
controller: _scrollController, builder: (context) {
padding: const EdgeInsets.all(16), // ✅ Construction de la liste complète avant le ListView.builder
children: [ // pour permettre l'utilisation de ListView.builder (lazy rendering)
// Header // à la place de ListView(children:[]) qui construit tous les widgets
_buildSectionHeader( // en mémoire d'un coup.
_displayType == SelectionType.equipment ? '├ëquipements' : 'Containers', // Pas d'itemExtent : les cards ont des hauteurs très variables
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory, // (80px sans conflit, jusqu'à 300px+ avec détails de conflits).
itemWidgets.length, final List<Widget> allChildren = [
), // Header
const SizedBox(height: 12), _buildSectionHeader(
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
// Items _displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
...itemWidgets, itemWidgets.length,
// Indicateur de chargement en bas
if (_isLoadingMore)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.rouge),
), ),
), const SizedBox(height: 12),
// Message si fin de liste // Items
if (!_isLoadingMore && !(_displayType == SelectionType.equipment ? _hasMoreEquipments : _hasMoreContainers)) ...itemWidgets,
Padding(
padding: const EdgeInsets.all(16), // Indicateur de chargement en bas
child: Center( if (_isLoadingMore)
child: Text( const Padding(
'Fin de la liste', padding: EdgeInsets.all(16),
style: TextStyle(color: Colors.grey.shade600, fontSize: 14), child: Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
), ),
),
),
// Message si rien trouv├® // Message si fin de liste
if (itemWidgets.isEmpty && !_isLoadingMore) if (!_isLoadingMore && !(_displayType == SelectionType.equipment ? _hasMoreEquipments : _hasMoreContainers))
Center( Padding(
child: Padding( padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32), child: Center(
child: Column( child: Text(
children: [ 'Fin de la liste',
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400), style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
const SizedBox(height: 16),
Text(
'Aucun r├®sultat trouv├®',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
), ),
], ),
), ),
),
), // 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),
),
],
),
),
),
];
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: allChildren.length,
itemBuilder: (context, index) => allChildren[index],
);
},
), ),
); );
}, },