refactor: Amélioration et stabilisation du sélecteur d'équipement

Cette mise à jour refactorise en profondeur la boîte de dialogue de sélection d'équipement pour améliorer ses performances, sa stabilité et son ergonomie. Le chargement des données a été entièrement revu pour être plus robuste et les interactions utilisateur sont désormais plus fluides et intuitives.

**Améliorations de performance et de stabilité :**
- **Chargement de données asynchrone et robuste :** Remplacement de la logique de chargement dans `initState` par une nouvelle méthode `_initializeData`. Cette méthode force le chargement des équipements et des conteneurs via `ensureLoaded()` et attend activement leur complétion avant de poursuivre, garantissant ainsi que toutes les données sont disponibles avant l'affichage.
- **Mise en cache locale :** Les données des équipements et conteneurs sont mises en cache (`_cachedEquipment`, `_cachedContainers`) après le chargement initial. Toute la boîte de dialogue utilise désormais ce cache, éliminant les appels répétés aux `Stream` et réduisant les rebuilds inutiles.
- **Fiabilisation des `setState` :** Les modifications de la sélection (`_selectedItems`) sont maintenant systématiquement wrappées dans des appels `setState` pour assurer que l'interface graphique se met à jour de manière fiable, corrigeant des bugs où la sélection n'était pas reflétée visuellement.

**Nouvelles fonctionnalités et améliorations de l'UX :**
- **Sections dépliables :** Les listes "Boîtes" et "Tous les équipements" sont désormais dans des sections qui peuvent être repliées, permettant à l'utilisateur de se concentrer sur l'une ou l'autre et d'améliorer la lisibilité sur les petits écrans.
- **Option d'affichage des conflits :** Ajout d'une checkbox "Afficher les équipements déjà utilisés". Lorsqu'elle est décochée, tous les équipements et boîtes en conflit avec les dates de l'événement sont masqués, simplifiant la recherche de matériel disponible.
- **Meilleure gestion des filtres :** Le filtre par catégorie s'applique désormais aussi aux boîtes, n'affichant que celles qui contiennent au moins un équipement de la catégorie sélectionnée.
- **Notifications de sélection affinées :** Le compteur dans le pied de page (`_selectedItems.length`) est maintenant mis à jour en temps réel à chaque modification de la sélection grâce à un `ValueListenableBuilder`.

**Refactorisation mineure :**
- **`EquipmentProvider` :** Ajout d'un getter `allEquipment` pour fournir un accès à la liste complète des équipements, non filtrée, utilisée par la boîte de dialogue pour sa logique de cache et de filtrage.
This commit is contained in:
ElPoyo
2026-01-15 23:59:25 +01:00
parent beaabceda4
commit 67b85d323c
2 changed files with 352 additions and 129 deletions

View File

@@ -23,6 +23,7 @@ class EquipmentProvider extends ChangeNotifier {
// Getters
List<EquipmentModel> get equipment => _filteredEquipment;
List<EquipmentModel> get allEquipment => _equipment; // Tous les équipements sans filtre
List<String> get models => _models;
List<String> get brands => _brands;
EquipmentCategory? get selectedCategory => _selectedCategory;

View File

@@ -111,9 +111,13 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
bool _isLoadingQuantities = false;
bool _isLoadingConflicts = false;
bool _conflictsLoaded = false; // Flag pour éviter de recharger indéfiniment
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
// Cache pour éviter les rebuilds inutiles
List<ContainerModel> _cachedContainers = [];
List<EquipmentModel> _cachedEquipment = [];
@@ -123,31 +127,84 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
void initState() {
super.initState();
// Charger après le premier frame pour éviter setState pendant build
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureEquipmentsLoaded();
_initializeAlreadyAssigned();
_loadAvailableQuantities();
_loadEquipmentConflicts();
// Charger immédiatement les données de manière asynchrone
_initializeData();
}
/// 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();
// 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é
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();
// Charger aussi les conteneurs si nécessaire
if (!containerProvider.isLoading && containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
// 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));
}
}
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é
Future<void> _initializeAlreadyAssigned() async {
final Map<String, SelectedItem> initialSelection = {};
// Ajouter les équipements déjà assignés
for (var eq in widget.alreadyAssigned) {
_selectedItems[eq.equipmentId] = SelectedItem(
initialSelection[eq.equipmentId] = SelectedItem(
id: eq.equipmentId,
name: eq.equipmentId,
type: SelectionType.equipment,
@@ -159,7 +216,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
if (widget.alreadyAssignedContainers.isNotEmpty) {
try {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.containersStream.first;
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
for (var containerId in widget.alreadyAssignedContainers) {
final container = containers.firstWhere(
@@ -175,7 +232,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
),
);
_selectedItems[containerId] = SelectedItem(
initialSelection[containerId] = SelectedItem(
id: containerId,
name: container.name,
type: SelectionType.container,
@@ -186,8 +243,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
// Ajouter les enfants comme sélectionnés aussi
for (var equipmentId in container.equipmentIds) {
if (!_selectedItems.containsKey(equipmentId)) {
_selectedItems[equipmentId] = SelectedItem(
if (!initialSelection.containsKey(equipmentId)) {
initialSelection[equipmentId] = SelectedItem(
id: equipmentId,
name: equipmentId,
type: SelectionType.equipment,
@@ -201,8 +258,15 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}
}
// Mettre à jour la sélection et notifier
if (mounted && initialSelection.isNotEmpty) {
setState(() {
_selectedItems = initialSelection;
});
_selectionChangeNotifier.value++;
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
}
}
@override
void dispose() {
_searchController.dispose();
@@ -213,14 +277,14 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
/// Charge les quantités disponibles pour tous les consommables/câbles
Future<void> _loadAvailableQuantities() async {
if (!mounted) return;
setState(() => _isLoadingQuantities = true);
try {
final equipmentProvider = context.read<EquipmentProvider>();
// EquipmentProvider utilise un stream, récupérons les données
final equipmentStream = equipmentProvider.equipmentStream;
final equipment = await equipmentStream.first;
// Utiliser directement allEquipment du provider (déjà chargé)
final equipment = equipmentProvider.allEquipment;
final consumables = equipment.where((eq) =>
eq.category == EquipmentCategory.consumable ||
@@ -287,7 +351,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
_conflictingContainerIds = conflictingContainerIds;
_conflictDetails = conflictDetails;
_equipmentQuantities = equipmentQuantities;
_conflictsLoaded = true; // Marquer comme chargé
});
}
@@ -381,7 +444,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final reservedQuantity = quantityInfo['reservedQuantity'] as int? ?? 0;
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
@@ -625,24 +687,33 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
await _deselectContainerChildren(id);
}
// Mise à jour sans setState - utiliser ValueNotifier pour notifier uniquement les cards concernées
// Mise à jour avec setState pour garantir le rebuild
if (mounted) {
setState(() {
_selectedItems.remove(id);
});
}
DebugLog.info('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
DebugLog.info('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
// Notifier le changement sans rebuilder toute la liste
// Notifier le changement
_selectionChangeNotifier.value++;
} else {
// Sélectionner
DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id');
// Mise à jour sans setState - utiliser ValueNotifier
// Mise à jour avec setState pour garantir le rebuild
if (mounted) {
setState(() {
_selectedItems[id] = SelectedItem(
id: id,
name: name,
type: type,
quantity: 1,
);
});
}
// Si c'est un équipement, chercher les conteneurs recommandés
if (type == SelectionType.equipment) {
@@ -654,7 +725,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
await _selectContainerChildren(id);
}
// Notifier le changement sans rebuilder toute la liste
// Notifier le changement
_selectionChangeNotifier.value++;
}
}
@@ -665,8 +736,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
final containerProvider = context.read<ContainerProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
final containers = await containerProvider.containersStream.first;
final equipment = await equipmentProvider.equipmentStream.first;
// Utiliser le cache si disponible
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final equipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.allEquipment;
final container = containers.firstWhere(
(c) => c.id == containerId,
@@ -713,6 +785,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}
}
}
DebugLog.info('[EquipmentSelectionDialog] Selected container $containerId with ${container.equipmentIds.length} children');
} catch (e) {
DebugLog.error('Error selecting container children', e);
}
@@ -722,7 +796,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
Future<void> _deselectContainerChildren(String containerId) async {
try {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.containersStream.first;
// Utiliser le cache si disponible
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final container = containers.firstWhere(
(c) => c.id == containerId,
@@ -737,6 +813,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
),
);
if (mounted) {
setState(() {
// Retirer les enfants de _selectedItems
for (var equipmentId in container.equipmentIds) {
_selectedItems.remove(equipmentId);
@@ -747,6 +825,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
// Retirer de la liste des conteneurs expandés
_expandedContainers.remove(containerId);
});
}
DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
} catch (e) {
@@ -969,6 +1049,36 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
],
),
),
const SizedBox(height: 12),
// Checkbox pour afficher les équipements en conflit
Row(
children: [
Checkbox(
value: _showConflictingItems,
onChanged: (value) {
setState(() {
_showConflictingItems = value ?? false;
});
},
activeColor: AppColors.rouge,
),
const Text(
'Afficher les équipements déjà utilisés',
style: TextStyle(fontSize: 14),
),
const SizedBox(width: 8),
Tooltip(
message: 'Afficher les équipements et boîtes qui sont déjà utilisés durant ces dates',
child: Icon(
Icons.info_outline,
size: 18,
color: Colors.grey.shade600,
),
),
],
),
],
),
);
@@ -1021,30 +1131,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
Widget _buildHierarchicalList() {
return Consumer2<ContainerProvider, EquipmentProvider>(
builder: (context, containerProvider, equipmentProvider, child) {
// Charger les données initiales dans le cache si pas encore fait
if (!_initialDataLoaded) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_cachedContainers = containerProvider.containers;
_cachedEquipment = equipmentProvider.equipment;
_initialDataLoaded = true;
});
}
});
}
// Utiliser les données du cache au lieu des streams
final allContainers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final allEquipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.equipment;
// Charger les conflits une seule fois après le chargement des données
if (!_isLoadingConflicts && !_conflictsLoaded && allEquipment.isNotEmpty) {
// Lancer le chargement des conflits en arrière-plan
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadEquipmentConflicts();
});
}
// 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;
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection
return ValueListenableBuilder<int>(
@@ -1052,16 +1141,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
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;
}
// 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,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
return equipment.id.isNotEmpty && equipment.category == _selectedCategory;
});
if (!hasEquipmentOfCategory) {
return false;
}
}
// Filtre par recherche
if (_searchQuery.isNotEmpty) {
final searchLower = _searchQuery.toLowerCase();
return container.id.toLowerCase().contains(searchLower) ||
container.name.toLowerCase().contains(searchLower);
}
return true;
}).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;
}
// Filtre par catégorie
if (_selectedCategory != null && eq.category != _selectedCategory) {
return false;
@@ -1085,24 +1210,48 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
children: [
// SECTION 1 : BOÎTES
if (filteredContainers.isNotEmpty) ...[
_buildSectionHeader('Boîtes', Icons.inventory, filteredContainers.length),
_buildCollapsibleSectionHeader(
'Boîtes',
Icons.inventory,
filteredContainers.length,
_containersExpanded,
(expanded) {
setState(() {
_containersExpanded = expanded;
});
},
),
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) ...[
_buildSectionHeader('Tous les équipements', Icons.inventory_2, filteredEquipment.length),
_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)
@@ -1129,7 +1278,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
);
}
/// Header de section
/// Header de section (version simple, gardée pour compatibilité)
Widget _buildSectionHeader(String title, IconData icon, int count) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
@@ -1170,6 +1319,66 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
);
}
/// Header de section repliable
Widget _buildCollapsibleSectionHeader(
String title,
IconData icon,
int count,
bool isExpanded,
Function(bool) onToggle,
) {
return InkWell(
onTap: () => onToggle(!isExpanded),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.rouge.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
color: AppColors.rouge,
size: 24,
),
const SizedBox(width: 8),
Icon(icon, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.rouge,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
final isSelected = _selectedItems.containsKey(equipment.id);
final isConsumable = equipment.category == EquipmentCategory.consumable ||
@@ -1469,8 +1678,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
icon: const Icon(Icons.remove_circle_outline),
onPressed: isSelected && selectedItem.quantity > 1
? () {
if (mounted) {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
});
_selectionChangeNotifier.value++; // Notifier le changement
}
}
: null,
iconSize: 20,
@@ -1500,8 +1713,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
);
} else {
// Item déjà sélectionné : incrémenter
if (mounted) {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
});
_selectionChangeNotifier.value++; // Notifier le changement
}
}
}
: null,
@@ -2195,6 +2412,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}
Widget _buildFooter() {
return ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -2235,6 +2455,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
],
),
);
},
);
}
}