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.
2463 lines
93 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|