Files
EM2_ERP/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart
ElPoyo 67b85d323c 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.
2026-01-15 23:59:25 +01:00

2463 lines
93 KiB
Dart

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';
import 'package:em2rp/utils/colors.dart';
/// Type de sélection dans le dialog
enum SelectionType { equipment, container }
/// Statut de conflit pour un conteneur
enum ContainerConflictStatus {
none, // Aucun conflit
partial, // Au moins un enfant en conflit
complete, // Tous les enfants en conflit
}
/// Informations sur les conflits d'un conteneur
class ContainerConflictInfo {
final ContainerConflictStatus status;
final List<String> conflictingEquipmentIds;
final int totalChildren;
ContainerConflictInfo({
required this.status,
required this.conflictingEquipmentIds,
required this.totalChildren,
});
String get description {
if (status == ContainerConflictStatus.none) return '';
if (status == ContainerConflictStatus.complete) {
return 'Tous les équipements sont déjà utilisés';
}
return '${conflictingEquipmentIds.length}/${totalChildren} équipement(s) déjà utilisé(s)';
}
}
/// Item sélectionné (équipement ou conteneur)
class SelectedItem {
final String id;
final String name;
final SelectionType type;
final int quantity; // Pour consommables/câbles
SelectedItem({
required this.id,
required this.name,
required this.type,
this.quantity = 1,
});
SelectedItem copyWith({int? quantity}) {
return SelectedItem(
id: id,
name: name,
type: type,
quantity: quantity ?? this.quantity,
);
}
}
/// Dialog complet de sélection de matériel pour un événement
class EquipmentSelectionDialog extends StatefulWidget {
final DateTime startDate;
final DateTime endDate;
final List<EventEquipment> alreadyAssigned;
final List<String> alreadyAssignedContainers;
final String? excludeEventId;
const EquipmentSelectionDialog({
super.key,
required this.startDate,
required this.endDate,
this.alreadyAssigned = const [],
this.alreadyAssignedContainers = const [],
this.excludeEventId,
});
@override
State<EquipmentSelectionDialog> createState() => _EquipmentSelectionDialogState();
}
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController(); // Préserve la position de scroll
final EventAvailabilityService _availabilityService = EventAvailabilityService();
final DataService _dataService = DataService(apiService);
EquipmentCategory? _selectedCategory;
Map<String, SelectedItem> _selectedItems = {};
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
Map<String, int> _availableQuantities = {}; // Pour consommables
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
// NOUVEAU : IDs en conflit récupérés en batch
Set<String> _conflictingEquipmentIds = {};
Set<String> _conflictingContainerIds = {};
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
// Cache pour éviter les rebuilds inutiles
List<ContainerModel> _cachedContainers = [];
List<EquipmentModel> _cachedEquipment = [];
bool _initialDataLoaded = false;
@override
void initState() {
super.initState();
// 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();
// 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) {
initialSelection[eq.equipmentId] = SelectedItem(
id: eq.equipmentId,
name: eq.equipmentId,
type: SelectionType.equipment,
quantity: eq.quantity,
);
}
// Ajouter les conteneurs déjà assignés
if (widget.alreadyAssignedContainers.isNotEmpty) {
try {
final containerProvider = context.read<ContainerProvider>();
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
for (var containerId in widget.alreadyAssignedContainers) {
final container = containers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
initialSelection[containerId] = SelectedItem(
id: containerId,
name: container.name,
type: SelectionType.container,
);
// Charger le cache des enfants
_containerEquipmentCache[containerId] = List.from(container.equipmentIds);
// Ajouter les enfants comme sélectionnés aussi
for (var equipmentId in container.equipmentIds) {
if (!initialSelection.containsKey(equipmentId)) {
initialSelection[equipmentId] = SelectedItem(
id: equipmentId,
name: equipmentId,
type: SelectionType.equipment,
quantity: 1,
);
}
}
}
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error loading already assigned containers', e);
}
}
// 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();
_scrollController.dispose(); // Nettoyer le ScrollController
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
super.dispose();
}
/// 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>();
// Utiliser directement allEquipment du provider (déjà chargé)
final equipment = equipmentProvider.allEquipment;
final consumables = equipment.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;
}
} catch (e) {
DebugLog.error('Error loading quantities', e);
} finally {
if (mounted) setState(() => _isLoadingQuantities = false);
}
}
/// Charge les conflits de disponibilité pour tous les équipements et conteneurs
/// Version optimisée : un seul appel API au lieu d'un par équipement
Future<void> _loadEquipmentConflicts() async {
setState(() => _isLoadingConflicts = true);
try {
DebugLog.info('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
final startTime = DateTime.now();
// UN SEUL appel API pour récupérer TOUS les équipements en conflit
final result = await _dataService.getConflictingEquipmentIds(
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
installationTime: 0, // TODO: Récupérer depuis l'événement si nécessaire
disassemblyTime: 0,
);
final endTime = DateTime.now();
final duration = endTime.difference(startTime);
DebugLog.info('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
// Extraire les IDs en conflit
final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toSet() ?? {};
final conflictingContainerIds = (result['conflictingContainerIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toSet() ?? {};
final conflictDetails = result['conflictDetails'] as Map<String, dynamic>? ?? {};
final equipmentQuantities = result['equipmentQuantities'] as Map<String, dynamic>? ?? {};
DebugLog.info('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
DebugLog.info('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
if (mounted) {
setState(() {
_conflictingEquipmentIds = conflictingEquipmentIds;
_conflictingContainerIds = conflictingContainerIds;
_conflictDetails = conflictDetails;
_equipmentQuantities = equipmentQuantities;
});
}
// Mettre à jour les statuts de conteneurs
await _updateContainerConflictStatus();
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error loading conflicts', e);
} finally {
if (mounted) setState(() => _isLoadingConflicts = false);
}
}
/// 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é
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) {
// Vérifier si le conteneur lui-même est en conflit
if (_conflictingContainerIds.contains(container.id)) {
_containerConflicts[container.id] = ContainerConflictInfo(
status: ContainerConflictStatus.complete,
conflictingEquipmentIds: [],
totalChildren: container.equipmentIds.length,
);
continue;
}
// Vérifier si des équipements enfants sont en conflit
final conflictingChildren = container.equipmentIds
.where((eqId) => _conflictingEquipmentIds.contains(eqId))
.toList();
if (conflictingChildren.isNotEmpty) {
final status = conflictingChildren.length == container.equipmentIds.length
? ContainerConflictStatus.complete
: ContainerConflictStatus.partial;
_containerConflicts[container.id] = ContainerConflictInfo(
status: status,
conflictingEquipmentIds: conflictingChildren,
totalChildren: container.equipmentIds.length,
);
DebugLog.info('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
}
}
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
}
}
/// Récupère les détails des conflits pour un équipement/conteneur donné
List<Map<String, dynamic>> _getConflictDetailsFor(String id) {
final details = _conflictDetails[id];
if (details == null) return [];
if (details is List) {
return details.cast<Map<String, dynamic>>();
}
return [];
}
/// Construit l'affichage des quantités pour les câbles/consommables
Widget _buildQuantityInfo(EquipmentModel equipment) {
final quantityInfo = _equipmentQuantities[equipment.id] as Map<String, dynamic>?;
if (quantityInfo == null) {
// Pas d'info de quantité, utiliser l'ancien système (availableQuantities)
final availableQty = _availableQuantities[equipment.id];
if (availableQty == null) return const SizedBox.shrink();
return Text(
'Disponible : $availableQty',
style: TextStyle(
color: availableQty > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
);
}
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
return Row(
children: [
Text(
'Disponible : $availableQuantity/$totalQuantity $unit',
style: TextStyle(
color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
if (reservations.isNotEmpty) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: () => _showQuantityDetailsDialog(equipment, quantityInfo),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.blue.shade700,
),
),
),
],
],
);
}
/// Affiche un dialog avec les détails des réservations de quantité
Future<void> _showQuantityDetailsDialog(EquipmentModel equipment, Map<String, dynamic> quantityInfo) async {
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.inventory_2, color: Colors.blue.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'Quantités - ${equipment.name}',
style: const TextStyle(fontSize: 18),
),
),
],
),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Résumé
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Quantité totale :',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
Text(
'$totalQuantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Disponible :',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
Text(
'$availableQuantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red,
fontSize: 16,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Liste des réservations
if (reservations.isNotEmpty) ...[
Text(
'Utilisé sur ${reservations.length} événement(s) :',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Column(
children: reservations.map((reservation) {
final res = reservation as Map<String, dynamic>;
final eventName = res['eventName'] as String? ?? 'Événement inconnu';
final quantity = res['quantity'] as int? ?? 0;
final viaContainer = res['viaContainer'] as String?;
final viaContainerName = res['viaContainerName'] as String?;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
dense: true,
leading: CircleAvatar(
backgroundColor: Colors.orange.shade100,
child: Text(
'$quantity',
style: TextStyle(
color: Colors.orange.shade900,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
eventName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: viaContainer != null
? Text(
'Via ${viaContainerName ?? viaContainer}',
style: const TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
),
)
: null,
trailing: Text(
'$quantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
),
);
}).toList(),
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
/// Recherche les conteneurs recommandés pour un équipement
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);
}
}
/// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache
List<ContainerModel> _getParentContainers(String equipmentId) {
return _recommendedContainers[equipmentId] ?? [];
}
void _toggleSelection(String id, String name, SelectionType type, {int? maxQuantity, bool force = false}) async {
// Vérifier si l'équipement est en conflit
if (!force && type == SelectionType.equipment && _conflictingEquipmentIds.contains(id)) {
// Demander confirmation pour forcer
final shouldForce = await _showForceConfirmationDialog(id);
if (shouldForce == true) {
_toggleSelection(id, name, type, maxQuantity: maxQuantity, force: true);
}
return;
}
if (_selectedItems.containsKey(id)) {
// Désélectionner
DebugLog.info('[EquipmentSelectionDialog] Deselecting $type: $id');
DebugLog.info('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
if (type == SelectionType.container) {
// Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone
await _deselectContainerChildren(id);
}
// 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
_selectionChangeNotifier.value++;
} else {
// Sélectionner
DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id');
// 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) {
_findRecommendedContainers(id);
}
// Si c'est un conteneur, sélectionner ses enfants en cascade
if (type == SelectionType.container) {
await _selectContainerChildren(id);
}
// Notifier le changement
_selectionChangeNotifier.value++;
}
}
/// 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(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
// Mettre à jour le cache
_containerEquipmentCache[containerId] = List.from(container.equipmentIds);
// 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(
(e) => e.id == equipmentId,
orElse: () => EquipmentModel(
id: equipmentId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
setState(() {
_selectedItems[equipmentId] = SelectedItem(
id: equipmentId,
name: eq.id,
type: SelectionType.equipment,
quantity: 1,
);
});
}
}
}
DebugLog.info('[EquipmentSelectionDialog] Selected container $containerId with ${container.equipmentIds.length} children');
} catch (e) {
DebugLog.error('Error selecting container children', e);
}
}
/// 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(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
setState(() {
// Retirer les enfants de _selectedItems
for (var equipmentId in container.equipmentIds) {
_selectedItems.remove(equipmentId);
}
// Nettoyer le cache
_containerEquipmentCache.remove(containerId);
// Retirer de la liste des conteneurs expandés
_expandedContainers.remove(containerId);
});
}
DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
} catch (e) {
DebugLog.error('Error deselecting container children', e);
}
}
/// Affiche un dialog pour confirmer le forçage d'un équipement en conflit
Future<bool?> _showForceConfirmationDialog(String equipmentId) async {
final conflicts = _equipmentConflicts[equipmentId] ?? [];
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text('Équipement déjà utilisé'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cet équipement est déjà utilisé sur ${conflicts.length} événement(s) :'),
const SizedBox(height: 12),
...conflicts.map((conflict) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.event, size: 16, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conflict.conflictingEvent.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'Chevauchement : ${conflict.overlapDays} jour(s)',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
],
),
)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: const Text('Forcer quand même'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final dialogWidth = screenSize.width * 0.9;
final dialogHeight = screenSize.height * 0.85;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: dialogWidth.clamp(600.0, 1200.0),
height: dialogHeight.clamp(500.0, 900.0),
child: Column(
children: [
_buildHeader(),
_buildSearchAndFilters(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste principale
Expanded(
flex: 2,
child: _buildMainList(),
),
// Panneau latéral : sélection + recommandations
Container(
width: 320,
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
left: BorderSide(color: Colors.grey.shade300),
),
),
child: Column(
children: [
Expanded(
child: ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) => _buildSelectionPanel(),
),
),
if (_hasRecommendations)
Container(
height: 200,
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade300),
),
),
child: _buildRecommendationsPanel(),
),
],
),
),
],
),
),
_buildFooter(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
const Icon(Icons.add_circle, color: Colors.white, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Ajouter du matériel',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
Widget _buildSearchAndFilters() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Column(
children: [
// Barre de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher du matériel ou des boîtes...',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase());
},
),
const SizedBox(height: 12),
// Filtres par catégorie (pour les équipements)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('Tout', null),
const SizedBox(width: 8),
...EquipmentCategory.values.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(category.label, category),
);
}),
],
),
),
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,
),
),
],
),
],
),
);
}
Widget _buildFilterChip(String label, EquipmentCategory? category) {
final isSelected = _selectedCategory == category;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedCategory = selected ? category : null;
});
},
selectedColor: AppColors.rouge,
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
),
);
}
Widget _buildMainList() {
// Afficher un indicateur de chargement si les données sont en cours de chargement
if (_isLoadingQuantities || _isLoadingConflicts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: AppColors.rouge),
const SizedBox(height: 16),
Text(
_isLoadingConflicts
? 'Vérification de la disponibilité...'
: 'Chargement des quantités disponibles...',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
);
}
// Vue hiérarchique unique : Boîtes en haut, TOUS les équipements en bas
return _buildHierarchicalList();
}
/// 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;
// 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;
}
// 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;
}
// 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);
}
return true;
}).toList();
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;
});
},
),
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
},
);
}
/// 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),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
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: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
/// 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 ||
equipment.category == EquipmentCategory.cable;
final availableQty = _availableQuantities[equipment.id];
final selectedItem = _selectedItems[equipment.id];
final hasConflict = _conflictingEquipmentIds.contains(equipment.id); // CORRECTION ICI !
final conflictDetails = _getConflictDetailsFor(equipment.id);
// Bloquer la sélection si en conflit et non forcé
final canSelect = !hasConflict || isSelected;
return RepaintBoundary(
key: key,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isSelected
? const BorderSide(color: AppColors.rouge, width: 2)
: hasConflict
? BorderSide(color: Colors.orange.shade300, width: 1)
: BorderSide.none,
),
child: InkWell(
onTap: canSelect
? () => _toggleSelection(
equipment.id,
equipment.id,
SelectionType.equipment,
maxQuantity: availableQty,
)
: null,
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: hasConflict && !isSelected
? BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
)
: null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: canSelect
? (value) => _toggleSelection(
equipment.id,
equipment.id,
SelectionType.equipment,
maxQuantity: availableQty,
)
: null,
activeColor: AppColors.rouge,
),
const SizedBox(width: 12),
// Icône
equipment.category.getIcon(size: 32, color: equipment.category.color),
const SizedBox(width: 16),
// Infos
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
if (hasConflict)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade700,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning, size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(
'Déjà utilisé',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
if (equipment.brand != null || equipment.model != null)
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(),
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 14,
),
),
// Affichage des boîtes parentes
if (_getParentContainers(equipment.id).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _getParentContainers(equipment.id).map((container) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
border: Border.all(color: Colors.blue.shade300),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory, size: 12, color: Colors.blue.shade700),
const SizedBox(width: 4),
Text(
container.name,
style: TextStyle(
fontSize: 10,
color: Colors.blue.shade700,
fontWeight: FontWeight.w500,
),
),
],
),
);
}).toList(),
),
),
if (isConsumable)
Padding(
padding: const EdgeInsets.only(top: 4),
child: _buildQuantityInfo(equipment),
),
],
),
),
// Sélecteur de quantité pour consommables (toujours affiché)
if (isConsumable && availableQty != null)
_buildQuantitySelector(
equipment.id,
selectedItem ?? SelectedItem(
id: equipment.id,
name: equipment.id,
type: SelectionType.equipment,
quantity: 0, // Quantité 0 si non sélectionné
),
availableQty,
isSelected: isSelected, // Passer l'état de sélection
),
],
),
// Affichage des conflits
if (hasConflict)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.orange.shade900),
const SizedBox(width: 6),
Text(
'Utilisé sur ${conflictDetails.length} événement(s) :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
],
),
const SizedBox(height: 6),
...conflictDetails.take(2).map((detail) {
final eventName = detail['eventName'] as String? ?? 'Événement inconnu';
final viaContainer = detail['viaContainer'] as String?;
final viaContainerName = detail['viaContainerName'] as String?;
return Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$eventName',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
),
),
if (viaContainer != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'via ${viaContainerName ?? viaContainer}',
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
}),
if (conflictDetails.length > 2)
Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Text(
'... et ${conflictDetails.length - 2} autre(s)',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
fontStyle: FontStyle.italic,
),
),
),
if (!isSelected)
Padding(
padding: const EdgeInsets.only(top: 8),
child: TextButton.icon(
onPressed: () => _toggleSelection(
equipment.id,
equipment.id,
SelectionType.equipment,
maxQuantity: availableQty,
force: true,
),
icon: const Icon(Icons.warning, size: 16),
label: const Text('Forcer quand même', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(
foregroundColor: Colors.orange.shade900,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
),
),
],
),
),
],
),
),
),
),
),
);
}
/// Widget pour le sélecteur de quantité
/// Si isSelected = false, le premier clic sur + sélectionne l'item avec quantité 1
Widget _buildQuantitySelector(
String equipmentId,
SelectedItem selectedItem,
int maxQuantity, {
required bool isSelected,
}) {
final displayQuantity = isSelected ? selectedItem.quantity : 0;
return Container(
width: 120,
child: Row(
children: [
IconButton(
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 le changement
}
}
: null,
iconSize: 20,
color: isSelected && selectedItem.quantity > 1 ? AppColors.rouge : Colors.grey,
),
Expanded(
child: Text(
'$displayQuantity',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected ? Colors.black : Colors.grey,
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: (isSelected && selectedItem.quantity < maxQuantity) || !isSelected
? () {
if (!isSelected) {
// Premier clic : sélectionner avec quantité 1
_toggleSelection(
equipmentId,
selectedItem.name,
SelectionType.equipment,
maxQuantity: maxQuantity,
);
} else {
// Item déjà sélectionné : incrémenter
if (mounted) {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
});
_selectionChangeNotifier.value++; // Notifier le changement
}
}
}
: null,
iconSize: 20,
color: AppColors.rouge,
),
],
),
);
}
Widget _buildContainerCard(ContainerModel container, {Key? key}) {
final isSelected = _selectedItems.containsKey(container.id);
final isExpanded = _expandedContainers.contains(container.id);
final conflictInfo = _containerConflicts[container.id];
final hasConflict = conflictInfo != null;
final isCompleteConflict = conflictInfo?.status == ContainerConflictStatus.complete;
// Bloquer la sélection si tous les enfants sont en conflit (sauf si déjà sélectionné)
final canSelect = !isCompleteConflict || isSelected;
return RepaintBoundary(
key: key,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isSelected
? const BorderSide(color: AppColors.rouge, width: 2)
: hasConflict
? BorderSide(
color: isCompleteConflict ? Colors.red.shade300 : Colors.orange.shade300,
width: 1,
)
: BorderSide.none,
),
child: Container(
decoration: hasConflict && !isSelected
? BoxDecoration(
color: isCompleteConflict ? Colors.red.shade50 : Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
)
: null,
child: Column(
children: [
InkWell(
onTap: canSelect
? () => _toggleSelection(
container.id,
container.name,
SelectionType.container,
)
: null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: canSelect
? (value) => _toggleSelection(
container.id,
container.name,
SelectionType.container,
)
: null,
activeColor: AppColors.rouge,
),
const SizedBox(width: 12),
// Icône du conteneur
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: container.type.getIcon(size: 28, color: AppColors.rouge),
),
const SizedBox(width: 16),
// Infos principales
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
container.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
// Badge de statut de conflit
if (hasConflict)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCompleteConflict
? Colors.red.shade700
: Colors.orange.shade700,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCompleteConflict ? Icons.block : Icons.warning,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
isCompleteConflict ? 'Indisponible' : 'Partiellement utilisée',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 4),
Text(
container.name,
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 14,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.inventory_2,
size: 14,
color: Colors.blue.shade700,
),
const SizedBox(width: 4),
Text(
'${container.itemCount} équipement(s)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.blue.shade700,
),
),
if (hasConflict) ...[
const SizedBox(width: 8),
Icon(
Icons.warning,
size: 14,
color: isCompleteConflict ? Colors.red.shade700 : Colors.orange.shade700,
),
const SizedBox(width: 4),
Text(
conflictInfo.description,
style: TextStyle(
fontSize: 11,
color: isCompleteConflict ? Colors.red.shade700 : Colors.orange.shade700,
fontWeight: FontWeight.w500,
),
),
],
],
),
],
),
),
// Bouton pour déplier/replier
IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: AppColors.rouge,
),
onPressed: () {
if (isExpanded) {
_expandedContainers.remove(container.id);
} else {
_expandedContainers.add(container.id);
}
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
},
tooltip: isExpanded ? 'Replier' : 'Voir le contenu',
),
],
),
// Avertissement pour conteneur complètement indisponible
if (isCompleteConflict && !isSelected)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.block, size: 20, color: Colors.red.shade900),
const SizedBox(width: 8),
Expanded(
child: Text(
'Cette boîte ne peut pas être sélectionnée car tous ses équipements sont déjà utilisés.',
style: TextStyle(
fontSize: 12,
color: Colors.red.shade900,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
),
// Liste des enfants (si déplié)
if (isExpanded)
_buildContainerChildren(container, conflictInfo),
],
),
),
),
);
}
/// 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()),
);
}
final allEquipment = snapshot.data ?? [];
final childEquipments = allEquipment
.where((eq) => container.equipmentIds.contains(eq.id))
.toList();
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,
),
),
],
),
const SizedBox(height: 12),
...childEquipments.map((eq) {
final hasConflict = _equipmentConflicts.containsKey(eq.id);
final conflicts = _equipmentConflicts[eq.id] ?? [];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: hasConflict ? Colors.orange.shade50 : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: hasConflict ? Colors.orange.shade300 : Colors.grey.shade300,
),
),
child: Row(
children: [
// Flèche de hiérarchie
Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey.shade600,
),
const SizedBox(width: 8),
// Icône de l'équipement
eq.category.getIcon(size: 20, color: eq.category.color),
const SizedBox(width: 12),
// Nom de l'équipement
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
eq.id,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade800,
),
),
if (eq.brand != null || eq.model != null)
Text(
'${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
),
// Indicateur de conflit
if (hasConflict) ...[
const SizedBox(width: 8),
Tooltip(
message: 'Utilisé sur ${conflicts.length} événement(s)',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.orange.shade700,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning, size: 12, color: Colors.white),
const SizedBox(width: 4),
Text(
'${conflicts.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
],
),
);
}),
],
),
);
},
);
},
);
}
Widget _buildSelectionPanel() {
// Compter uniquement les conteneurs et équipements "racine" (pas enfants de conteneurs)
final selectedContainers = _selectedItems.entries
.where((e) => e.value.type == SelectionType.container)
.toList();
// Collecter tous les IDs d'équipements qui sont enfants de conteneurs sélectionnés
final Set<String> equipmentIdsInContainers = {};
for (var containerEntry in selectedContainers) {
final childrenIds = _getContainerEquipmentIds(containerEntry.key);
equipmentIdsInContainers.addAll(childrenIds);
}
// Équipements qui ne sont PAS enfants d'un conteneur sélectionné
final selectedStandaloneEquipment = _selectedItems.entries
.where((e) => e.value.type == SelectionType.equipment)
.where((e) => !equipmentIdsInContainers.contains(e.key))
.toList();
final containerCount = selectedContainers.length;
final standaloneEquipmentCount = selectedStandaloneEquipment.length;
final totalDisplayed = containerCount + standaloneEquipmentCount;
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rouge,
),
child: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 8),
const Text(
'Sélection',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$totalDisplayed',
style: const TextStyle(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
Expanded(
child: totalDisplayed == 0
? const Center(
child: Text(
'Aucune sélection',
style: TextStyle(color: Colors.grey),
),
)
: ListView(
padding: const EdgeInsets.all(8),
children: [
if (containerCount > 0) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Boîtes ($containerCount)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
...selectedContainers.map((e) => _buildSelectedContainerTile(e.key, e.value)),
],
if (standaloneEquipmentCount > 0) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Équipements ($standaloneEquipmentCount)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
...selectedStandaloneEquipment.map((e) => _buildSelectedItemTile(e.key, e.value)),
],
],
),
),
],
);
}
/// Récupère les IDs des équipements d'un conteneur (depuis le cache)
List<String> _getContainerEquipmentIds(String containerId) {
// On doit récupérer le conteneur depuis le provider de manière synchrone
// Pour cela, on va maintenir un cache local
return _containerEquipmentCache[containerId] ?? [];
}
// Cache local pour les équipements des conteneurs
Map<String, List<String>> _containerEquipmentCache = {};
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
final isExpanded = _expandedContainers.contains(id);
final childrenIds = _getContainerEquipmentIds(id);
final childrenCount = childrenIds.length;
return Column(
children: [
ListTile(
dense: true,
leading: Icon(
Icons.inventory,
size: 20,
color: AppColors.rouge,
),
title: Text(
item.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'$childrenCount équipement(s)',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (childrenCount > 0)
IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
),
onPressed: () {
if (isExpanded) {
_expandedContainers.remove(id);
} else {
_expandedContainers.add(id);
}
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => _toggleSelection(id, item.name, item.type),
),
],
),
),
if (isExpanded && childrenCount > 0)
...childrenIds.map((equipmentId) {
final childItem = _selectedItems[equipmentId];
if (childItem != null) {
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
}
return const SizedBox.shrink();
}).toList(),
],
);
}
Widget _buildSelectedChildEquipmentTile(String id, SelectedItem item) {
return Padding(
padding: const EdgeInsets.only(left: 40),
child: ListTile(
dense: true,
leading: Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey.shade600,
),
title: Text(
item.name,
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
subtitle: item.quantity > 1
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 10))
: null,
// PAS de bouton de suppression pour les enfants
),
);
}
Widget _buildSelectedItemTile(String id, SelectedItem item) {
return ListTile(
dense: true,
leading: Icon(
Icons.inventory_2,
size: 20,
color: AppColors.rouge,
),
title: Text(
item.name,
style: const TextStyle(fontSize: 13),
),
subtitle: item.quantity > 1
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 11))
: null,
trailing: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => _toggleSelection(id, item.name, item.type),
),
);
}
bool get _hasRecommendations => _recommendedContainers.isNotEmpty;
Widget _buildRecommendationsPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade700,
),
child: const Row(
children: [
Icon(Icons.lightbulb, color: Colors.white, size: 20),
SizedBox(width: 8),
Text(
'Boîtes recommandées',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(8),
children: _recommendedContainers.values
.expand((list) => list)
.toSet() // Enlever les doublons
.map((container) => _buildRecommendedContainerTile(container))
.toList(),
),
),
],
);
}
Widget _buildRecommendedContainerTile(ContainerModel container) {
final isAlreadySelected = _selectedItems.containsKey(container.id);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
dense: true,
leading: container.type.getIcon(size: 24, color: Colors.blue.shade700),
title: Text(
container.name,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'${container.itemCount} équipement(s)',
style: const TextStyle(fontSize: 11),
),
trailing: isAlreadySelected
? const Icon(Icons.check_circle, color: Colors.green)
: IconButton(
icon: const Icon(Icons.add_circle_outline, color: Colors.blue),
onPressed: () => _toggleSelection(
container.id,
container.name,
SelectionType.container,
),
),
),
);
}
Widget _buildFooter() {
return ValueListenableBuilder<int>(
valueListenable: _selectionChangeNotifier,
builder: (context, _, __) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Text(
'${_selectedItems.length} élément(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _selectedItems.isEmpty
? null
: () => Navigator.of(context).pop(_selectedItems),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
),
child: const Text('Valider la sélection'),
),
],
),
);
},
);
}
}