Merge branch 'perf/search-debounce' into perf/listview-optimization

This commit is contained in:
ElPoyo
2026-05-26 13:43:24 +02:00
4 changed files with 98 additions and 59 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/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debouncer.dart';
class ContainerFormPage extends StatefulWidget {
final ContainerModel? container;
@@ -658,6 +659,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
EquipmentCategory? _filterCategory;
String _searchQuery = '';
late Set<String> _tempSelectedIds;
final _searchDebouncer = Debouncer();
final List<EquipmentModel> _paginatedEquipments = [];
bool _isLoadingMore = false;
@@ -677,6 +679,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
void dispose() {
_searchController.dispose();
_scrollController.dispose();
_searchDebouncer.dispose();
super.dispose();
}
@@ -790,7 +793,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_searchQuery = value;
});
_reloadData();
_searchDebouncer(_reloadData);
},
),
const SizedBox(height: 16),
@@ -20,6 +20,7 @@ import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
import 'package:em2rp/utils/debouncer.dart';
class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key});
@@ -33,6 +34,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final _searchDebouncer = Debouncer();
EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@@ -87,6 +89,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose();
_searchDebouncer.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose();
@@ -169,9 +172,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
@@ -342,9 +343,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
@@ -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:em2rp/models/equipment_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/api_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/debouncer.dart';
/// Type de s├®lection dans le dialog
enum SelectionType { equipment, container }
@@ -108,6 +109,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
bool _isLoadingConflicts = false;
String _searchQuery = '';
final _searchDebouncer = Debouncer();
// Nouvelles options d'affichage
bool _showConflictingItems = false; // Afficher les ├®quipements/bo├«tes en conflit
@@ -435,6 +437,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
_searchController.dispose();
_scrollController.dispose(); // Nettoyer le ScrollController
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
_searchDebouncer.dispose();
super.dispose();
}
@@ -1209,7 +1212,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase());
// Recharger depuis le d├®but avec le nouveau filtre
_reloadData();
_searchDebouncer(_reloadData);
},
),
@@ -1379,12 +1382,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
// Containers
final filteredContainers = _paginatedContainers.where((container) {
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)) {
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(
(eqId) => _conflictingEquipmentIds.contains(eqId),
);
@@ -1412,60 +1415,73 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}
return false;
},
child: 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),
// Items
...itemWidgets,
// Indicateur de chargement en bas
if (_isLoadingMore)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.rouge),
child: Builder(
builder: (context) {
// ✅ Construction de la liste complète avant le ListView.builder
// pour permettre l'utilisation de ListView.builder (lazy rendering)
// à la place de ListView(children:[]) qui construit tous les widgets
// en mémoire d'un coup.
// Pas d'itemExtent : les cards ont des hauteurs très variables
// (80px sans conflit, jusqu'à 300px+ avec détails de conflits).
final List<Widget> allChildren = [
// Header
_buildSectionHeader(
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
itemWidgets.length,
),
),
const SizedBox(height: 12),
// 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),
// Items
...itemWidgets,
// Indicateur de chargement en bas
if (_isLoadingMore)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
),
),
),
// 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),
// 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),
),
],
),
),
),
),
],
// 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],
);
},
),
);
},