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`).
1881 lines
70 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|