Files
EM2_ERP/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart
ElPoyo 08f046c89c feat: Refactor event equipment management with advanced selection and conflict detection
This commit introduces a complete overhaul of how equipment is assigned to events, focusing on an enhanced user experience, advanced selection capabilities, and robust conflict detection.

**Key Features & Enhancements:**

-   **Advanced Equipment Selection UI (`EquipmentSelectionDialog`):**
    -   New full-screen dialog to select equipment and containers ("boîtes") for an event.
    -   Hierarchical view showing containers and a flat list of all individual equipment.
    -   Real-time search and filtering by equipment category.
    -   Side panel summarizing the current selection and providing recommendations for containers based on selected equipment.
    -   Supports quantity selection for consumables and cables.

-   **Conflict Detection & Management (`EventAvailabilityService`):**
    -   A new service (`EventAvailabilityService`) checks for equipment availability against other events based on the selected date range.
    -   The selection dialog visually highlights equipment and containers with scheduling conflicts (e.g., already used, partially unavailable).
    -   A dedicated conflict resolution dialog (`EquipmentConflictDialog`) appears if conflicting items are selected, allowing the user to either remove them or force the assignment.

-   **Integrated Event Form (`EventAssignedEquipmentSection`):**
    -   The event creation/editing form now includes a new section for managing assigned equipment.
    -   It clearly displays assigned containers and standalone equipment, showing the composition of each container.
    -   Integrates the new selection dialog, ensuring all assignments are checked for conflicts before being saved.

-   **Event Preparation & Return Workflow (`EventPreparationPage`):**
    -   New page (`EventPreparationPage`) for managing the check-out (preparation) and check-in (return) of equipment for an event.
    -   Provides a checklist of all assigned equipment.
    -   Users can validate each item, with options to "validate all" or finalize with missing items.
    -   Includes a dialog (`MissingEquipmentDialog`) to handle discrepancies.
    -   Supports tracking returned quantities for consumables.

**Data Model and Other Changes:**

-   The `EventModel` now includes `assignedContainers` to explicitly link containers to an event.
-   `EquipmentAssociatedEventsSection` on the equipment detail page is now functional, displaying current, upcoming, and past events for that item.
-   Added deployment and versioning scripts (`scripts/deploy.js`, `scripts/increment_version.js`, `scripts/toggle_env.js`) to automate the release process.
-   Introduced an application version display in the main drawer (`AppVersion`).
2025-11-30 20:33:03 +01:00

1881 lines
70 KiB
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/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 EventAvailabilityService _availabilityService = EventAvailabilityService();
EquipmentCategory? _selectedCategory;
Map<String, SelectedItem> _selectedItems = {};
Map<String, int> _availableQuantities = {}; // Pour consommables
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
bool _isLoadingQuantities = false;
bool _isLoadingConflicts = false;
String _searchQuery = '';
@override
void initState() {
super.initState();
_initializeAlreadyAssigned();
_loadAvailableQuantities();
_loadEquipmentConflicts();
_loadContainerConflicts();
}
/// Initialise la sélection avec le matériel déjà assigné
Future<void> _initializeAlreadyAssigned() async {
// Ajouter les équipements déjà assignés
for (var eq in widget.alreadyAssigned) {
_selectedItems[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 = await containerProvider.containersStream.first;
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(),
),
);
_selectedItems[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 (!_selectedItems.containsKey(equipmentId)) {
_selectedItems[equipmentId] = SelectedItem(
id: equipmentId,
name: equipmentId,
type: SelectionType.equipment,
quantity: 1,
);
}
}
}
} catch (e) {
print('[EquipmentSelectionDialog] Error loading already assigned containers: $e');
}
}
print('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
/// Charge les quantités disponibles pour tous les consommables/câbles
Future<void> _loadAvailableQuantities() async {
setState(() => _isLoadingQuantities = true);
try {
final equipmentProvider = context.read<EquipmentProvider>();
// EquipmentProvider utilise un stream, récupérons les données
final equipmentStream = equipmentProvider.equipmentStream;
final equipment = await equipmentStream.first;
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) {
print('Error loading quantities: $e');
} finally {
if (mounted) setState(() => _isLoadingQuantities = false);
}
}
/// Charge les conflits de disponibilité pour tous les équipements
Future<void> _loadEquipmentConflicts() async {
setState(() => _isLoadingConflicts = true);
try {
print('[EquipmentSelectionDialog] Loading equipment conflicts...');
final equipmentProvider = context.read<EquipmentProvider>();
final equipment = await equipmentProvider.equipmentStream.first;
print('[EquipmentSelectionDialog] Checking conflicts for ${equipment.length} equipments');
for (var eq in equipment) {
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: eq.id,
equipmentName: eq.id,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
if (conflicts.isNotEmpty) {
print('[EquipmentSelectionDialog] Found ${conflicts.length} conflict(s) for ${eq.id}');
_equipmentConflicts[eq.id] = conflicts;
}
}
print('[EquipmentSelectionDialog] Total equipments with conflicts: ${_equipmentConflicts.length}');
} catch (e) {
print('[EquipmentSelectionDialog] Error loading conflicts: $e');
} finally {
if (mounted) setState(() => _isLoadingConflicts = false);
}
}
/// Charge les conflits de disponibilité pour tous les conteneurs
Future<void> _loadContainerConflicts() async {
try {
print('[EquipmentSelectionDialog] Loading container conflicts...');
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.containersStream.first;
print('[EquipmentSelectionDialog] Checking conflicts for ${containers.length} containers');
for (var container in containers) {
final conflictingChildren = <String>[];
// Vérifier chaque équipement enfant
for (var equipmentId in container.equipmentIds) {
if (_equipmentConflicts.containsKey(equipmentId)) {
conflictingChildren.add(equipmentId);
}
}
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,
);
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
}
}
print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
} catch (e) {
print('[EquipmentSelectionDialog] Error loading container conflicts: $e');
}
}
/// 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) {
print('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 && _equipmentConflicts.containsKey(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
print('[EquipmentSelectionDialog] Deselecting $type: $id');
print('[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 sans setState pour éviter le flash
_selectedItems.remove(id);
print('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
print('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
// Forcer uniquement la reconstruction du panneau de sélection et de la card concernée
if (mounted) setState(() {});
} else {
// Sélectionner
print('[EquipmentSelectionDialog] Selecting $type: $id');
// Mise à jour sans setState pour éviter le flash
_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);
}
// Forcer uniquement la reconstruction du panneau de sélection et de la card concernée
if (mounted) setState(() {});
}
}
/// 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>();
final containers = await containerProvider.containersStream.first;
final equipment = await equipmentProvider.equipmentStream.first;
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,
);
});
}
}
}
} catch (e) {
print('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>();
final containers = await containerProvider.containersStream.first;
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(),
),
);
// 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);
print('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
} catch (e) {
print('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: _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),
);
}),
],
),
),
],
),
);
}
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
Widget _buildHierarchicalList() {
return Consumer2<ContainerProvider, EquipmentProvider>(
builder: (context, containerProvider, equipmentProvider, child) {
return StreamBuilder<List<ContainerModel>>(
stream: containerProvider.containersStream,
builder: (context, containerSnapshot) {
return StreamBuilder<List<EquipmentModel>>(
stream: equipmentProvider.equipmentStream,
builder: (context, equipmentSnapshot) {
if (containerSnapshot.connectionState == ConnectionState.waiting ||
equipmentSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final allContainers = containerSnapshot.data ?? [];
final allEquipment = equipmentSnapshot.data ?? [];
// Filtrage des boîtes
final filteredContainers = allContainers.where((container) {
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 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(
padding: const EdgeInsets.all(16),
children: [
// SECTION 1 : BOÎTES
if (filteredContainers.isNotEmpty) ...[
_buildSectionHeader('Boîtes', Icons.inventory, filteredContainers.length),
const SizedBox(height: 12),
...filteredContainers.map((container) => _buildContainerCard(
container,
key: ValueKey('container_${container.id}'),
)),
const SizedBox(height: 24),
],
// SECTION 2 : TOUS LES ÉQUIPEMENTS
if (filteredEquipment.isNotEmpty) ...[
_buildSectionHeader('Tous les équipements', Icons.inventory_2, filteredEquipment.length),
const SizedBox(height: 12),
...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),
),
],
),
),
),
],
);
},
);
},
);
},
);
}
/// Header de section
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,
),
),
),
],
),
);
}
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 = _equipmentConflicts.containsKey(equipment.id);
final conflicts = _equipmentConflicts[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 && availableQty != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}',
style: TextStyle(
color: availableQty > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
),
],
),
),
// Sélecteur de quantité pour consommables
if (isSelected && isConsumable && availableQty != null)
_buildQuantitySelector(equipment.id, selectedItem!, availableQty),
],
),
// 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 ${conflicts.length} événement(s) :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
],
),
const SizedBox(height: 6),
...conflicts.take(2).map((conflict) => Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Text(
'${conflict.conflictingEvent.name} (${conflict.overlapDays} jour(s))',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
),
),
)),
if (conflicts.length > 2)
Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Text(
'... et ${conflicts.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é (sans setState global pour éviter le refresh)
Widget _buildQuantitySelector(String equipmentId, SelectedItem selectedItem, int maxQuantity) {
return Container(
width: 120,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: selectedItem.quantity > 1
? () {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
});
}
: null,
iconSize: 20,
),
Expanded(
child: Text(
'${selectedItem.quantity}',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: selectedItem.quantity < maxQuantity
? () {
setState(() {
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
});
}
: null,
iconSize: 20,
),
],
),
);
}
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: () {
setState(() {
if (isExpanded) {
_expandedContainers.remove(container.id);
} else {
_expandedContainers.add(container.id);
}
});
},
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: () {
setState(() {
if (isExpanded) {
_expandedContainers.remove(id);
} else {
_expandedContainers.add(id);
}
});
},
),
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 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'),
),
],
),
);
}
}