diff --git a/em2rp/.gitignore b/em2rp/.gitignore index 29a3a50..1bcb3e4 100644 --- a/em2rp/.gitignore +++ b/em2rp/.gitignore @@ -41,3 +41,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Environment configuration with credentials +lib/config/env.dev.dart + diff --git a/em2rp/README.md b/em2rp/README.md deleted file mode 100644 index 9243876..0000000 --- a/em2rp/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# em2rp - -A new Flutter project. diff --git a/em2rp/deploy.bat b/em2rp/deploy.bat new file mode 100644 index 0000000..832ca19 --- /dev/null +++ b/em2rp/deploy.bat @@ -0,0 +1,60 @@ +@echo off +REM Script Windows pour incrémenter la version et déployer sur Firebase + +echo ================================================ +echo Déploiement Firebase Hosting avec EM2RP +echo ================================================ +echo. + +echo [0/4] Basculement en mode PRODUCTION... +node scripts\toggle_env.js prod +if %ERRORLEVEL% NEQ 0 ( + echo Erreur lors du basculement en mode production + pause + exit /b 1 +) +echo. + +echo [1/4] Incrémentation de la version... +node scripts\increment_version.js +if %ERRORLEVEL% NEQ 0 ( + echo Erreur lors de l'incrémentation de la version + node scripts\toggle_env.js dev + pause + exit /b 1 +) +echo. + +echo [2/4] Build Flutter Web... +call flutter build web --release +if %ERRORLEVEL% NEQ 0 ( + echo Erreur lors du build Flutter + node scripts\toggle_env.js dev + pause + exit /b 1 +) +echo. + +echo [3/4] Déploiement Firebase Hosting... +call firebase deploy --only hosting +if %ERRORLEVEL% NEQ 0 ( + echo Erreur lors du déploiement Firebase + node scripts\toggle_env.js dev + pause + exit /b 1 +) + +echo. +echo [4/4] Retour en mode DÉVELOPPEMENT... +node scripts\toggle_env.js dev +if %ERRORLEVEL% NEQ 0 ( + echo ATTENTION: Impossible de rebascule en mode dev + echo Exécutez manuellement: node scripts\toggle_env.js dev +) + +echo. +echo ================================================ +echo Déploiement terminé avec succès! +echo ================================================ +pause + diff --git a/em2rp/env_dev.bat b/em2rp/env_dev.bat new file mode 100644 index 0000000..705f8a0 --- /dev/null +++ b/em2rp/env_dev.bat @@ -0,0 +1,15 @@ +@echo off +REM Script Windows pour basculer en mode DÉVELOPPEMENT + +echo Basculement en mode DÉVELOPPEMENT... +node scripts\toggle_env.js dev + +if %ERRORLEVEL% EQU 0 ( + echo ✅ Mode DÉVELOPPEMENT activé + echo - isDevelopment = true + echo - Auto-login activé +) else ( + echo ❌ Erreur lors du basculement + echo Vérifiez que le fichier env.dev.dart existe +) + diff --git a/em2rp/env_prod.bat b/em2rp/env_prod.bat new file mode 100644 index 0000000..e9c35e7 --- /dev/null +++ b/em2rp/env_prod.bat @@ -0,0 +1,16 @@ +@echo off +REM Script Windows pour basculer en mode PRODUCTION + +echo Basculement en mode PRODUCTION... +node scripts\toggle_env.js prod + +if %ERRORLEVEL% EQU 0 ( + echo ✅ Mode PRODUCTION activé + echo - isDevelopment = false + echo - Credentials masqués +) else ( + echo ❌ Erreur lors du basculement +) + +pause + diff --git a/em2rp/increment_version.bat b/em2rp/increment_version.bat new file mode 100644 index 0000000..7cde1c3 --- /dev/null +++ b/em2rp/increment_version.bat @@ -0,0 +1,14 @@ +@echo off +REM Script Windows pour incrémenter uniquement la version + +echo Incrémentation de la version... +node scripts\increment_version.js + +if %ERRORLEVEL% EQU 0 ( + echo Version incrémentée avec succès! +) else ( + echo Erreur lors de l'incrémentation +) + +pause + diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart new file mode 100644 index 0000000..0c6572d --- /dev/null +++ b/em2rp/lib/config/app_version.dart @@ -0,0 +1,11 @@ +/// Configuration de la version de l'application +class AppVersion { + static const String version = '0.3.4'; + + /// Retourne la version complète de l'application + static String get fullVersion => 'v$version'; + + /// Retourne la version avec un préfixe personnalisé + static String getVersionWithPrefix(String prefix) => '$prefix $version'; +} + diff --git a/em2rp/lib/config/env.dart b/em2rp/lib/config/env.dart index f6c470d..539a953 100644 --- a/em2rp/lib/config/env.dart +++ b/em2rp/lib/config/env.dart @@ -3,8 +3,7 @@ class Env { // Configuration de l'auto-login en développement static const String devAdminEmail = 'paul.fournel@em2events.fr'; - static const String devAdminPassword = - "Pastis51!"; // À remplacer par le vrai mot de passe + static const String devAdminPassword = 'Pastis51!'; // URLs et endpoints static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; @@ -15,3 +14,4 @@ class Env { // Autres configurations static const int apiTimeout = 30000; // 30 secondes } + diff --git a/em2rp/lib/config/env.dev.dart b/em2rp/lib/config/env.dev.dart new file mode 100644 index 0000000..539a953 --- /dev/null +++ b/em2rp/lib/config/env.dev.dart @@ -0,0 +1,17 @@ +class Env { + static const bool isDevelopment = true; + + // Configuration de l'auto-login en développement + static const String devAdminEmail = 'paul.fournel@em2events.fr'; + static const String devAdminPassword = 'Pastis51!'; + + // URLs et endpoints + static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; + + // Configuration Firebase + static const String firebaseProjectId = 'em2rp-951dc'; + + // Autres configurations + static const int apiTimeout = 30000; // 30 secondes +} + diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 0e26cca..b865523 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -34,6 +34,8 @@ class EventFormController extends ChangeNotifier { List> _selectedOptions = []; bool _formChanged = false; EventStatus _selectedStatus = EventStatus.waitingForApproval; + List _assignedEquipment = []; + List _assignedContainers = []; // Getters DateTime? get startDateTime => _startDateTime; @@ -49,6 +51,8 @@ class EventFormController extends ChangeNotifier { bool get isLoadingUsers => _isLoadingUsers; List> get uploadedFiles => _uploadedFiles; List> get selectedOptions => _selectedOptions; + List get assignedEquipment => _assignedEquipment; + List get assignedContainers => _assignedContainers; bool get formChanged => _formChanged; EventStatus get selectedStatus => _selectedStatus; @@ -95,6 +99,8 @@ class EventFormController extends ChangeNotifier { addressController.text = event.address; _startDateTime = event.startDateTime; _endDateTime = event.endDateTime; + _assignedEquipment = List.from(event.assignedEquipment); + _assignedContainers = List.from(event.assignedContainers); _selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null; _selectedUserIds = event.workforce.map((ref) => ref.id).toList(); _uploadedFiles = List>.from(event.documents); @@ -206,6 +212,13 @@ class EventFormController extends ChangeNotifier { notifyListeners(); } + void setAssignedEquipment(List equipment, List containers) { + _assignedEquipment = equipment; + _assignedContainers = containers; + _onAnyFieldChanged(); + notifyListeners(); + } + Future pickAndUploadFiles() async { final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true); if (result != null && result.files.isNotEmpty) { @@ -297,6 +310,10 @@ class EventFormController extends ChangeNotifier { documents: finalDocuments, options: _selectedOptions, status: _selectedStatus, + assignedEquipment: _assignedEquipment, + assignedContainers: _assignedContainers, + preparationStatus: existingEvent.preparationStatus, + returnStatus: existingEvent.returnStatus, ); await EventFormService.updateEvent(updatedEvent); @@ -335,6 +352,8 @@ class EventFormController extends ChangeNotifier { documents: _uploadedFiles, options: _selectedOptions, status: _selectedStatus, + assignedContainers: _assignedContainers, + assignedEquipment: _assignedEquipment, ); final eventId = await EventFormService.createEvent(newEvent); diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index b638a99..e9c0297 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -11,6 +11,7 @@ import 'package:em2rp/views/equipment_management_page.dart'; import 'package:em2rp/views/container_management_page.dart'; import 'package:em2rp/views/container_form_page.dart'; import 'package:em2rp/views/container_detail_page.dart'; +import 'package:em2rp/views/event_preparation_page.dart'; import 'package:em2rp/models/container_model.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; @@ -150,6 +151,12 @@ class MyApp extends StatelessWidget { child: ContainerDetailPage(container: container), ); }, + '/event_preparation': (context) { + final eventId = ModalRoute.of(context)!.settings.arguments as String; + return AuthGuard( + child: EventPreparationPage(eventId: eventId), + ); + }, }, ); } diff --git a/em2rp/lib/models/equipment_model.dart b/em2rp/lib/models/equipment_model.dart index 88e977a..b6c2184 100644 --- a/em2rp/lib/models/equipment_model.dart +++ b/em2rp/lib/models/equipment_model.dart @@ -147,6 +147,28 @@ extension EquipmentCategoryExtension on EquipmentCategory { } } + /// Retourne la couleur associée à la catégorie + Color get color { + switch (this) { + case EquipmentCategory.lighting: + return Colors.yellow.shade700; + case EquipmentCategory.sound: + return Colors.purple; + case EquipmentCategory.video: + return Colors.blue; + case EquipmentCategory.effect: + return Colors.pink; + case EquipmentCategory.structure: + return Colors.brown; + case EquipmentCategory.consumable: + return Colors.orange; + case EquipmentCategory.cable: + return Colors.grey; + case EquipmentCategory.other: + return Colors.blueGrey; + } + } + /// Retourne le chemin de l'icône personnalisée (si disponible) String? get customIconPath { switch (this) { @@ -228,6 +250,7 @@ extension EquipmentCategoryExtension on EquipmentCategory { } } + extension EquipmentStatusExtension on EquipmentStatus { /// Retourne le label français du statut String get label { diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index 7f9fb52..573d8d6 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -174,6 +174,7 @@ class EventModel { // Nouveaux champs pour la gestion du matériel final List assignedEquipment; + final List assignedContainers; // IDs des conteneurs assignés final PreparationStatus? preparationStatus; final ReturnStatus? returnStatus; @@ -197,6 +198,7 @@ class EventModel { this.options = const [], this.status = EventStatus.waitingForApproval, this.assignedEquipment = const [], + this.assignedContainers = const [], this.preparationStatus, this.returnStatus, }); @@ -295,6 +297,18 @@ class EventModel { } } + // Gestion des conteneurs assignés + final assignedContainersRaw = map['assignedContainers'] ?? []; + final List assignedContainers = []; + + if (assignedContainersRaw is List) { + for (var e in assignedContainersRaw) { + if (e is String) { + assignedContainers.add(e); + } + } + } + return EventModel( id: id, name: (map['Name'] ?? '').toString().trim(), @@ -303,6 +317,7 @@ class EventModel { endDateTime: endDate, basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0), installationTime: _parseInt(map['InstallationTime'] ?? 0), + assignedContainers: assignedContainers, disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0), eventTypeId: eventTypeId, eventTypeRef: eventTypeRef, @@ -370,6 +385,7 @@ class EventModel { 'options': options, 'status': eventStatusToString(status), 'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(), + 'assignedContainers': assignedContainers, 'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null, 'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null, }; diff --git a/em2rp/lib/services/event_availability_service.dart b/em2rp/lib/services/event_availability_service.dart new file mode 100644 index 0000000..beb30e1 --- /dev/null +++ b/em2rp/lib/services/event_availability_service.dart @@ -0,0 +1,185 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Informations sur un conflit de disponibilité +class AvailabilityConflict { + final String equipmentId; + final String equipmentName; + final EventModel conflictingEvent; + final int overlapDays; + + AvailabilityConflict({ + required this.equipmentId, + required this.equipmentName, + required this.conflictingEvent, + required this.overlapDays, + }); +} + +/// Service pour vérifier la disponibilité du matériel +class EventAvailabilityService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + /// Vérifie si un équipement est disponible pour une plage de dates + Future> checkEquipmentAvailability({ + required String equipmentId, + required String equipmentName, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, // Pour exclure l'événement en cours d'édition + }) async { + final conflicts = []; + + try { + print('[EventAvailabilityService] Checking availability for equipment: $equipmentId'); + print('[EventAvailabilityService] Date range: $startDate - $endDate'); + + // Récupérer TOUS les événements (on filtre côté client car arrayContains avec objet ne marche pas) + final eventsSnapshot = await _firestore.collection('events').get(); + + print('[EventAvailabilityService] Found ${eventsSnapshot.docs.length} total events'); + + for (var doc in eventsSnapshot.docs) { + if (excludeEventId != null && doc.id == excludeEventId) { + continue; // Ignorer l'événement en cours d'édition + } + + try { + final event = EventModel.fromMap(doc.data(), doc.id); + + // Vérifier si cet événement contient l'équipement recherché + final assignedEquipment = event.assignedEquipment.firstWhere( + (eq) => eq.equipmentId == equipmentId, + orElse: () => EventEquipment(equipmentId: ''), + ); + + // Si l'équipement est assigné et non retourné + if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) { + print('[EventAvailabilityService] Equipment $equipmentId found in event: ${event.name}'); + + // Vérifier le chevauchement des dates + if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) { + final overlapDays = _calculateOverlapDays( + startDate, + endDate, + event.startDateTime, + event.endDateTime, + ); + + print('[EventAvailabilityService] CONFLICT detected! Overlap: $overlapDays days'); + + conflicts.add(AvailabilityConflict( + equipmentId: equipmentId, + equipmentName: equipmentName, + conflictingEvent: event, + overlapDays: overlapDays, + )); + } + } + } catch (e) { + print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); + } + } + + print('[EventAvailabilityService] Total conflicts found for $equipmentId: ${conflicts.length}'); + } catch (e) { + print('[EventAvailabilityService] Error checking equipment availability: $e'); + } + + return conflicts; + } + + /// Vérifie la disponibilité pour une liste d'équipements + Future>> checkMultipleEquipmentAvailability({ + required List equipmentIds, + required Map equipmentNames, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + final allConflicts = >{}; + + for (var equipmentId in equipmentIds) { + final conflicts = await checkEquipmentAvailability( + equipmentId: equipmentId, + equipmentName: equipmentNames[equipmentId] ?? equipmentId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + + if (conflicts.isNotEmpty) { + allConflicts[equipmentId] = conflicts; + } + } + + return allConflicts; + } + + /// Vérifie si deux plages de dates se chevauchent + bool _datesOverlap(DateTime start1, DateTime end1, DateTime start2, DateTime end2) { + // Deux plages se chevauchent si elles ne sont PAS complètement séparées + // Elles sont séparées si : end1 < start2 OU end2 < start1 + // Donc elles se chevauchent si : NOT (end1 < start2 OU end2 < start1) + // Équivalent à : end1 >= start2 ET end2 >= start1 + return !end1.isBefore(start2) && !end2.isBefore(start1); + } + + /// Calcule le nombre de jours de chevauchement + int _calculateOverlapDays(DateTime start1, DateTime end1, DateTime start2, DateTime end2) { + final overlapStart = start1.isAfter(start2) ? start1 : start2; + final overlapEnd = end1.isBefore(end2) ? end1 : end2; + + return overlapEnd.difference(overlapStart).inDays + 1; + } + + /// Récupère la quantité disponible pour un consommable/câble + Future getAvailableQuantity({ + required EquipmentModel equipment, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + if (!equipment.hasQuantity) { + return 1; // Équipement non consommable + } + + final totalQuantity = equipment.totalQuantity ?? 0; + int reservedQuantity = 0; + + try { + // Récupérer tous les événements (on filtre côté client) + final eventsSnapshot = await _firestore.collection('events').get(); + + for (var doc in eventsSnapshot.docs) { + if (excludeEventId != null && doc.id == excludeEventId) { + continue; + } + + try { + final event = EventModel.fromMap(doc.data(), doc.id); + + // Vérifier le chevauchement des dates + if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) { + final assignedEquipment = event.assignedEquipment.firstWhere( + (eq) => eq.equipmentId == equipment.id, + orElse: () => EventEquipment(equipmentId: ''), + ); + + if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) { + reservedQuantity += assignedEquipment.quantity; + } + } + } catch (e) { + print('[EventAvailabilityService] Error processing event ${doc.id} for quantity: $e'); + } + } + } catch (e) { + print('[EventAvailabilityService] Error getting available quantity: $e'); + } + + return totalQuantity - reservedQuantity; + } +} + diff --git a/em2rp/lib/services/event_preparation_service.dart b/em2rp/lib/services/event_preparation_service.dart index 011ee30..3f173be 100644 --- a/em2rp/lib/services/event_preparation_service.dart +++ b/em2rp/lib/services/event_preparation_service.dart @@ -148,7 +148,10 @@ class EventPreparationService { } /// Valider tous les retours - Future validateAllReturn(String eventId) async { + Future validateAllReturn( + String eventId, [ + Map? returnedQuantities, + ]) async { try { final event = await _getEvent(eventId); if (event == null) { @@ -157,9 +160,12 @@ class EventPreparationService { // Marquer tous les équipements comme retournés final updatedEquipment = event.assignedEquipment.map((eq) { + final returnedQty = returnedQuantities?[eq.equipmentId] ?? + eq.returnedQuantity ?? + eq.quantity; return eq.copyWith( isReturned: true, - returnedQuantity: eq.returnedQuantity ?? eq.quantity, + returnedQuantity: returnedQty, ); }).toList(); diff --git a/em2rp/lib/views/equipment_detail_page.dart b/em2rp/lib/views/equipment_detail_page.dart index 077db4e..c63eeeb 100644 --- a/em2rp/lib/views/equipment_detail_page.dart +++ b/em2rp/lib/views/equipment_detail_page.dart @@ -108,7 +108,7 @@ class _EquipmentDetailPageState extends State { ], // 4. Événements associés - const EquipmentAssociatedEventsSection(), + EquipmentAssociatedEventsSection(equipment: widget.equipment), const SizedBox(height: 24), // 5-7. Prix, Historique des maintenances, Dates en layout responsive diff --git a/em2rp/lib/views/event_add_page.dart b/em2rp/lib/views/event_add_page.dart index 25ec0f4..6b78095 100644 --- a/em2rp/lib/views/event_add_page.dart +++ b/em2rp/lib/views/event_add_page.dart @@ -5,6 +5,7 @@ import 'package:em2rp/controllers/event_form_controller.dart'; import 'package:em2rp/views/widgets/event_form/event_basic_info_section.dart'; import 'package:em2rp/views/widgets/event_form/event_details_section.dart'; import 'package:em2rp/views/widgets/event_form/event_staff_and_documents_section.dart'; +import 'package:em2rp/views/widgets/event_form/event_assigned_equipment_section.dart'; import 'package:em2rp/views/widgets/event_form/event_form_actions.dart'; import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart'; @@ -221,6 +222,17 @@ class _EventAddEditPageState extends State { eventTypeRequired: controller.selectedEventTypeId == null, isMobile: isMobile, ), + const SizedBox(height: 16), + // Section Matériel Assigné + EventAssignedEquipmentSection( + assignedEquipment: controller.assignedEquipment, + assignedContainers: controller.assignedContainers, + startDate: controller.startDateTime, + endDate: controller.endDateTime, + onChanged: controller.setAssignedEquipment, + eventId: widget.event?.id, + ), + const SizedBox(height: 16), EventDetailsSection( descriptionController: controller.descriptionController, installationController: controller.installationController, diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart new file mode 100644 index 0000000..e936e6e --- /dev/null +++ b/em2rp/lib/views/event_preparation_page.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/providers/event_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:em2rp/services/event_preparation_service.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/event/equipment_checklist_item.dart'; +import 'package:em2rp/views/widgets/event/missing_equipment_dialog.dart'; +import 'package:em2rp/views/widgets/event/preparation_success_dialog.dart'; + +class EventPreparationPage extends StatefulWidget { + final String eventId; + + const EventPreparationPage({ + super.key, + required this.eventId, + }); + + @override + State createState() => _EventPreparationPageState(); +} + +class _EventPreparationPageState extends State { + final EventPreparationService _preparationService = EventPreparationService(); + EventModel? _event; + Map _equipmentMap = {}; + Map _returnedQuantities = {}; // Pour les quantités retournées (consommables) + bool _isLoading = true; + bool _isSaving = false; + + // Mode déterminé automatiquement + bool get _isReturnMode { + if (_event == null) return false; + // Mode retour si préparation complétée et retour pas encore complété + return _event!.preparationStatus == PreparationStatus.completed || + _event!.preparationStatus == PreparationStatus.completedWithMissing; + } + + String get _pageTitle => _isReturnMode ? 'Retour matériel' : 'Préparation matériel'; + + @override + void initState() { + super.initState(); + _loadEventAndEquipment(); + } + + Future _loadEventAndEquipment() async { + try { + // Charger l'événement + final eventProvider = context.read(); + final event = await eventProvider.getEvent(widget.eventId); + + if (event == null) { + throw Exception('Événement non trouvé'); + } + + // Charger tous les équipements assignés + final equipmentProvider = context.read(); + final Map equipmentMap = {}; + + for (var assignedEq in event.assignedEquipment) { + final equipment = await equipmentProvider.getEquipmentById(assignedEq.equipmentId); + if (equipment != null) { + equipmentMap[assignedEq.equipmentId] = equipment; + + // Initialiser les quantités retournées pour les consommables + if (_isReturnMode && + (equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable)) { + _returnedQuantities[assignedEq.equipmentId] = assignedEq.returnedQuantity ?? assignedEq.quantity; + } + } + } + + setState(() { + _event = event; + _equipmentMap = equipmentMap; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _toggleEquipmentValidation(String equipmentId, bool isValidated) async { + try { + if (_isReturnMode) { + if (isValidated) { + final returnedQty = _returnedQuantities[equipmentId]; + await _preparationService.validateEquipmentReturn( + widget.eventId, + equipmentId, + returnedQuantity: returnedQty, + ); + } + } else { + await _preparationService.validateEquipmentPreparation(widget.eventId, equipmentId); + } + + // Recharger l'événement + await _loadEventAndEquipment(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), + ); + } + } + } + + Future _validateAllQuickly() async { + setState(() => _isSaving = true); + + try { + if (_isReturnMode) { + await _preparationService.validateAllReturn(widget.eventId, _returnedQuantities); + } else { + await _preparationService.validateAllPreparation(widget.eventId); + } + + // Afficher le dialog de succès avec animation + if (mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => PreparationSuccessDialog(isReturnMode: _isReturnMode), + ); + + // Retour à la page précédente + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), + ); + } + } finally { + setState(() => _isSaving = false); + } + } + + Future _validatePreparation() async { + if (_event == null) return; + + // Vérifier quels équipements ne sont pas validés + final missingEquipment = []; + final missingIds = []; + + for (var assignedEq in _event!.assignedEquipment) { + final isValidated = _isReturnMode ? assignedEq.isReturned : assignedEq.isPrepared; + + if (!isValidated) { + final equipment = _equipmentMap[assignedEq.equipmentId]; + if (equipment != null) { + missingEquipment.add(equipment); + missingIds.add(assignedEq.equipmentId); + } + } + } + + // Si tout est validé, on finalise directement + if (missingEquipment.isEmpty) { + await _validateAllQuickly(); + return; + } + + // Sinon, afficher le dialog des manquants + if (mounted) { + final result = await showDialog( + context: context, + builder: (context) => MissingEquipmentDialog( + missingEquipments: missingEquipment, + eventId: widget.eventId, + isReturnMode: _isReturnMode, + ), + ); + + if (result == 'confirm_with_missing') { + setState(() => _isSaving = true); + try { + if (_isReturnMode) { + await _preparationService.completeReturnWithMissing(widget.eventId, missingIds); + } else { + await _preparationService.completePreparationWithMissing(widget.eventId, missingIds); + } + + if (mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => PreparationSuccessDialog(isReturnMode: _isReturnMode), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), + ); + } + } finally { + setState(() => _isSaving = false); + } + } else if (result == 'validate_missing') { + // Valider tous les manquants + setState(() => _isSaving = true); + try { + for (var equipmentId in missingIds) { + await _toggleEquipmentValidation(equipmentId, true); + } + await _validateAllQuickly(); + } finally { + setState(() => _isSaving = false); + } + } + } + } + + @override + Widget build(BuildContext context) { + final userProvider = context.watch(); + final userId = userProvider.uid; + final hasManagePermission = userProvider.hasPermission('manage_events'); + + // Vérifier si l'utilisateur fait partie de l'équipe + final isInWorkforce = _event?.workforce.any((ref) => ref.id == userId) ?? false; + final hasPermission = hasManagePermission || isInWorkforce; + + if (!hasPermission) { + return Scaffold( + appBar: const CustomAppBar(title: 'Accès refusé'), + body: const Center( + child: Text('Vous n\'avez pas les permissions pour accéder à cette page.'), + ), + ); + } + + return Scaffold( + appBar: CustomAppBar(title: _pageTitle), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _event == null + ? const Center(child: Text('Événement introuvable')) + : Column( + children: [ + // En-tête avec info de l'événement + _buildEventHeader(), + + // Bouton "Tout confirmer" + _buildQuickValidateButton(), + + // Liste des équipements + Expanded(child: _buildEquipmentList()), + + // Bouton de validation final + _buildValidateButton(), + ], + ), + ); + } + + Widget _buildEventHeader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.rouge.withOpacity(0.1), + border: Border( + bottom: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _event!.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '${_event!.assignedEquipment.length} équipement(s) assigné(s)', + style: TextStyle(color: Colors.grey.shade700), + ), + ], + ), + ); + } + + Widget _buildQuickValidateButton() { + return Container( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: _isSaving ? null : _validateAllQuickly, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + minimumSize: const Size(double.infinity, 50), + ), + icon: const Icon(Icons.check_circle, color: Colors.white), + label: Text( + _isReturnMode ? 'Tout confirmer comme retourné' : 'Tout confirmer comme préparé', + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ); + } + + Widget _buildEquipmentList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _event!.assignedEquipment.length, + itemBuilder: (context, index) { + final assignedEq = _event!.assignedEquipment[index]; + final equipment = _equipmentMap[assignedEq.equipmentId]; + + if (equipment == null) { + return const SizedBox.shrink(); + } + + final isValidated = _isReturnMode ? assignedEq.isReturned : assignedEq.isPrepared; + + return EquipmentChecklistItem( + equipment: equipment, + isValidated: isValidated, + onValidate: (value) => _toggleEquipmentValidation(assignedEq.equipmentId, value), + isReturnMode: _isReturnMode, + quantity: assignedEq.quantity, + returnedQuantity: _returnedQuantities[assignedEq.equipmentId], + onReturnedQuantityChanged: _isReturnMode + ? (value) { + setState(() { + _returnedQuantities[assignedEq.equipmentId] = value; + }); + } + : null, + ); + }, + ); + } + + Widget _buildValidateButton() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, -2), + ), + ], + ), + child: ElevatedButton( + onPressed: _isSaving ? null : _validatePreparation, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + minimumSize: const Size(double.infinity, 50), + ), + child: _isSaving + ? const CircularProgressIndicator(color: Colors.white) + : Text( + _isReturnMode ? 'Finaliser le retour' : 'Finaliser la préparation', + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart index 54fbf28..b202a5a 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart @@ -1,9 +1,122 @@ import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/utils/colors.dart'; +import 'package:intl/intl.dart'; -/// Widget pour afficher les événements associés -class EquipmentAssociatedEventsSection extends StatelessWidget { - const EquipmentAssociatedEventsSection({super.key}); +enum EventFilter { + current, // Événements en cours (préparés mais pas encore retournés) + upcoming, // Événements à venir + past, // Événements passés +} + +/// Widget pour afficher les événements associés à un équipement +class EquipmentAssociatedEventsSection extends StatefulWidget { + final EquipmentModel equipment; + + const EquipmentAssociatedEventsSection({ + super.key, + required this.equipment, + }); + + @override + State createState() => + _EquipmentAssociatedEventsSectionState(); +} + +class _EquipmentAssociatedEventsSectionState + extends State { + EventFilter _selectedFilter = EventFilter.current; + List _events = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadEvents(); + } + + Future _loadEvents() async { + setState(() => _isLoading = true); + + try { + final eventsSnapshot = await FirebaseFirestore.instance + .collection('events') + .where('assignedEquipment', + arrayContains: {'equipmentId': widget.equipment.id}) + .get(); + + final events = eventsSnapshot.docs + .map((doc) => EventModel.fromMap(doc.data(), doc.id)) + .toList(); + + // Filtrer selon le statut + final now = DateTime.now(); + final filteredEvents = events.where((event) { + switch (_selectedFilter) { + case EventFilter.current: + // Événement en cours = préparation complétée ET retour pas encore complété + return (event.preparationStatus == PreparationStatus.completed || + event.preparationStatus == + PreparationStatus.completedWithMissing) && + (event.returnStatus == null || + event.returnStatus == ReturnStatus.notStarted || + event.returnStatus == ReturnStatus.inProgress); + + case EventFilter.upcoming: + // Événements à venir = date de début dans le futur OU préparation pas encore faite + return event.startDateTime.isAfter(now) || + event.preparationStatus == PreparationStatus.notStarted; + + case EventFilter.past: + // Événements passés = retour complété + return event.returnStatus == ReturnStatus.completed || + event.returnStatus == ReturnStatus.completedWithMissing; + } + }).toList(); + + // Trier par date + filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + + setState(() { + _events = filteredEvents; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors du chargement: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + String _getFilterLabel(EventFilter filter) { + switch (filter) { + case EventFilter.current: + return 'En cours'; + case EventFilter.upcoming: + return 'À venir'; + case EventFilter.past: + return 'Passés'; + } + } + + IconData _getFilterIcon(EventFilter filter) { + switch (filter) { + case EventFilter.current: + return Icons.play_circle; + case EventFilter.upcoming: + return Icons.upcoming; + case EventFilter.past: + return Icons.history; + } + } @override Widget build(BuildContext context) { @@ -14,32 +127,302 @@ class EquipmentAssociatedEventsSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // En-tête avec filtre Row( children: [ const Icon(Icons.event, color: AppColors.rouge), const SizedBox(width: 8), - Text( - 'Événements associés', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + Expanded( + child: Text( + 'Événements associés', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + // Menu déroulant pour filtrer + PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getFilterIcon(_selectedFilter), + size: 20, + color: AppColors.rouge, ), + const SizedBox(width: 4), + Text( + _getFilterLabel(_selectedFilter), + style: const TextStyle( + color: AppColors.rouge, + fontWeight: FontWeight.bold, + ), + ), + const Icon(Icons.arrow_drop_down, color: AppColors.rouge), + ], + ), + onSelected: (filter) { + setState(() => _selectedFilter = filter); + _loadEvents(); + }, + itemBuilder: (context) => EventFilter.values.map((filter) { + return PopupMenuItem( + value: filter, + child: Row( + children: [ + Icon(_getFilterIcon(filter), size: 20), + const SizedBox(width: 8), + Text(_getFilterLabel(filter)), + ], + ), + ); + }).toList(), ), ], ), const Divider(height: 24), - const Padding( - padding: EdgeInsets.all(16.0), - child: Center( - child: Text( - 'Fonctionnalité à implémenter', - style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), + + // Liste des événements + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: CircularProgressIndicator(), ), + ) + else if (_events.isEmpty) + Padding( + padding: const EdgeInsets.all(32.0), + child: Center( + child: Column( + children: [ + Icon( + Icons.event_busy, + size: 48, + color: Colors.grey.shade400, + ), + const SizedBox(height: 8), + Text( + 'Aucun événement ${_getFilterLabel(_selectedFilter).toLowerCase()}', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + ), + ], + ), + ), + ) + else + Column( + children: _events.map((event) => _buildEventCard(event)).toList(), ), - ), ], ), ), ); } + + Widget _buildEventCard(EventModel event) { + final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); + final isInProgress = (event.preparationStatus == PreparationStatus.completed || + event.preparationStatus == PreparationStatus.completedWithMissing) && + (event.returnStatus == null || + event.returnStatus == ReturnStatus.notStarted || + event.returnStatus == ReturnStatus.inProgress); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isInProgress + ? const BorderSide(color: AppColors.rouge, width: 2) + : BorderSide.none, + ), + child: InkWell( + onTap: () { + // Navigation vers les détails de l'événement si nécessaire + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre de l'événement + Row( + children: [ + Expanded( + child: Text( + event.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + if (isInProgress) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.rouge, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'EN COURS', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // Dates + Row( + children: [ + const Icon(Icons.calendar_today, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + '${dateFormat.format(event.startDateTime)} → ${dateFormat.format(event.endDateTime)}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Statuts de préparation et retour + Row( + children: [ + _buildStatusChip( + 'Préparation', + event.preparationStatus ?? PreparationStatus.notStarted, + ), + const SizedBox(width: 8), + _buildStatusChip( + 'Retour', + event.returnStatus ?? ReturnStatus.notStarted, + ), + ], + ), + + // Boutons d'action + if (isInProgress && _selectedFilter == EventFilter.current) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.pushNamed( + context, + '/event_preparation', + arguments: event.id, + ); + }, + icon: const Icon(Icons.logout, size: 16), + label: const Text('Check-out'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.rouge, + ), + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildStatusChip(String label, dynamic status) { + Color color; + String text; + + if (status is PreparationStatus) { + switch (status) { + case PreparationStatus.notStarted: + color = Colors.grey; + text = 'Non démarrée'; + break; + case PreparationStatus.inProgress: + color = Colors.orange; + text = 'En cours'; + break; + case PreparationStatus.completed: + color = Colors.green; + text = 'Complétée'; + break; + case PreparationStatus.completedWithMissing: + color = Colors.red; + text = 'Manquants'; + break; + } + } else if (status is ReturnStatus) { + switch (status) { + case ReturnStatus.notStarted: + color = Colors.grey; + text = 'Non démarré'; + break; + case ReturnStatus.inProgress: + color = Colors.orange; + text = 'En cours'; + break; + case ReturnStatus.completed: + color = Colors.green; + text = 'Complété'; + break; + case ReturnStatus.completedWithMissing: + color = Colors.red; + text = 'Manquants'; + break; + } + } else { + color = Colors.grey; + text = 'Inconnu'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + text, + style: TextStyle( + fontSize: 11, + color: color, + ), + ), + ], + ), + ); + } } diff --git a/em2rp/lib/views/widgets/event/equipment_checklist_item.dart b/em2rp/lib/views/widgets/event/equipment_checklist_item.dart new file mode 100644 index 0000000..bd1a7a4 --- /dev/null +++ b/em2rp/lib/views/widgets/event/equipment_checklist_item.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Widget pour afficher un équipement avec checkbox de validation +class EquipmentChecklistItem extends StatelessWidget { + final EquipmentModel equipment; + final bool isValidated; + final ValueChanged onValidate; + final bool isReturnMode; + final int? quantity; + final int? returnedQuantity; + final ValueChanged? onReturnedQuantityChanged; + + const EquipmentChecklistItem({ + super.key, + required this.equipment, + required this.isValidated, + required this.onValidate, + this.isReturnMode = false, + this.quantity, + this.returnedQuantity, + this.onReturnedQuantityChanged, + }); + + bool get _isConsumable => + equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: isValidated ? 0 : 2, + color: isValidated ? Colors.green.shade50 : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: isValidated ? Colors.green : Colors.grey.shade300, + width: isValidated ? 2 : 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Checkbox de validation + Checkbox( + value: isValidated, + onChanged: (value) => onValidate(value ?? false), + activeColor: Colors.green, + ), + + const SizedBox(width: 12), + + // Icône de l'équipement + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: equipment.category.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: equipment.category.getIcon( + size: 24, + color: equipment.category.color, + ), + ), + + const SizedBox(width: 12), + + // Informations de l'équipement + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Nom/ID + Text( + equipment.id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + + const SizedBox(height: 4), + + // Marque/Modèle + if (equipment.brand != null || equipment.model != null) + Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + + const SizedBox(height: 4), + + // Quantité assignée (consommables uniquement) + if (_isConsumable && quantity != null) + Text( + 'Quantité assignée : $quantity', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + + // Champ de quantité retournée (mode retour + consommables) + if (isReturnMode && _isConsumable && onReturnedQuantityChanged != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Text( + 'Quantité retournée :', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 80, + child: TextField( + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + hintText: quantity?.toString() ?? '0', + ), + controller: TextEditingController( + text: returnedQuantity?.toString() ?? quantity?.toString() ?? '0', + ), + onChanged: (value) { + final intValue = int.tryParse(value) ?? 0; + if (onReturnedQuantityChanged != null) { + onReturnedQuantityChanged!(intValue); + } + }, + ), + ), + ], + ), + ), + ], + ), + ), + + // Icône de statut + if (isValidated) + const Icon( + Icons.check_circle, + color: Colors.green, + size: 28, + ), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart b/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart new file mode 100644 index 0000000..78bbeaa --- /dev/null +++ b/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/services/event_availability_service.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:intl/intl.dart'; + +/// Dialog affichant les conflits de disponibilité du matériel +class EquipmentConflictDialog extends StatefulWidget { + final Map> conflicts; + + const EquipmentConflictDialog({ + super.key, + required this.conflicts, + }); + + @override + State createState() => _EquipmentConflictDialogState(); +} + +class _EquipmentConflictDialogState extends State { + final Set _removedEquipmentIds = {}; + + int get totalConflicts => widget.conflicts.values.fold(0, (sum, list) => sum + list.length); + int get remainingConflicts => widget.conflicts.entries + .where((entry) => !_removedEquipmentIds.contains(entry.key)) + .fold(0, (sum, entry) => sum + entry.value.length); + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('dd/MM/yyyy'); + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 700, maxHeight: 700), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête avec icône warning + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + size: 32, + color: Colors.orange.shade700, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Conflits de disponibilité détectés', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '$remainingConflicts conflit(s) sur $totalConflicts équipement(s)', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop('cancel'), + ), + ], + ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + + // Liste des conflits + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.conflicts.length, + itemBuilder: (context, index) { + final entry = widget.conflicts.entries.elementAt(index); + final equipmentId = entry.key; + final conflictsList = entry.value; + final isRemoved = _removedEquipmentIds.contains(equipmentId); + + if (conflictsList.isEmpty) return const SizedBox.shrink(); + + final firstConflict = conflictsList.first; + + return Opacity( + opacity: isRemoved ? 0.4 : 1.0, + child: Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: isRemoved ? 0 : 2, + color: isRemoved ? Colors.grey.shade200 : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Nom de l'équipement + Row( + children: [ + Icon( + Icons.inventory_2, + color: isRemoved ? Colors.grey : AppColors.rouge, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + firstConflict.equipmentName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + decoration: isRemoved ? TextDecoration.lineThrough : null, + ), + ), + ), + if (isRemoved) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'RETIRÉ', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Liste des événements en conflit + ...conflictsList.map((conflict) { + return Padding( + padding: const EdgeInsets.only(bottom: 8, left: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.event, + size: 16, + color: Colors.grey.shade600, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + conflict.conflictingEvent.name, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey.shade800, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${dateFormat.format(conflict.conflictingEvent.startDateTime)} → ${dateFormat.format(conflict.conflictingEvent.endDateTime)}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + Text( + 'Chevauchement : ${conflict.overlapDays} jour(s)', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + + // Boutons d'action par équipement + if (!isRemoved) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + const Spacer(), + OutlinedButton.icon( + onPressed: () { + setState(() { + _removedEquipmentIds.add(equipmentId); + }); + }, + icon: const Icon(Icons.remove_circle_outline, size: 16), + label: const Text('Retirer'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + + // Boutons d'action globaux + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop('cancel'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text('Annuler tout'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: remainingConflicts == 0 + ? () => Navigator.of(context).pop('force_removed') + : () => Navigator.of(context).pop('force_all'), + style: ElevatedButton.styleFrom( + backgroundColor: remainingConflicts == 0 ? Colors.green : Colors.orange, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text( + remainingConflicts == 0 + ? 'Valider sans les retirés' + : 'Forcer malgré les conflits', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart new file mode 100644 index 0000000..46cecac --- /dev/null +++ b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart @@ -0,0 +1,1880 @@ +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 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 alreadyAssigned; + final List alreadyAssignedContainers; + final String? excludeEventId; + + const EquipmentSelectionDialog({ + super.key, + required this.startDate, + required this.endDate, + this.alreadyAssigned = const [], + this.alreadyAssignedContainers = const [], + this.excludeEventId, + }); + + @override + State createState() => _EquipmentSelectionDialogState(); +} + +class _EquipmentSelectionDialogState extends State { + final TextEditingController _searchController = TextEditingController(); + final EventAvailabilityService _availabilityService = EventAvailabilityService(); + + EquipmentCategory? _selectedCategory; + + Map _selectedItems = {}; + Map _availableQuantities = {}; // Pour consommables + Map> _recommendedContainers = {}; // Recommandations + Map> _equipmentConflicts = {}; // Conflits de disponibilité + Map _containerConflicts = {}; // Conflits des conteneurs + Set _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 _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(); + 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 _loadAvailableQuantities() async { + setState(() => _isLoadingQuantities = true); + + try { + final equipmentProvider = context.read(); + + // 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 _loadEquipmentConflicts() async { + setState(() => _isLoadingConflicts = true); + + try { + print('[EquipmentSelectionDialog] Loading equipment conflicts...'); + final equipmentProvider = context.read(); + 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 _loadContainerConflicts() async { + try { + print('[EquipmentSelectionDialog] Loading container conflicts...'); + final containerProvider = context.read(); + final containers = await containerProvider.containersStream.first; + + print('[EquipmentSelectionDialog] Checking conflicts for ${containers.length} containers'); + + for (var container in containers) { + final conflictingChildren = []; + + // 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 _findRecommendedContainers(String equipmentId) async { + try { + final containerProvider = context.read(); + + // 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 _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 _selectContainerChildren(String containerId) async { + try { + final containerProvider = context.read(); + final equipmentProvider = context.read(); + + 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 _deselectContainerChildren(String containerId) async { + try { + final containerProvider = context.read(); + 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 _showForceConfirmationDialog(String equipmentId) async { + final conflicts = _equipmentConflicts[equipmentId] ?? []; + + return showDialog( + 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( + builder: (context, containerProvider, equipmentProvider, child) { + return StreamBuilder>( + stream: containerProvider.containersStream, + builder: (context, containerSnapshot) { + return StreamBuilder>( + 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( + builder: (context, provider, child) { + return StreamBuilder>( + 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 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 _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> _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'), + ), + ], + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/event/missing_equipment_dialog.dart b/em2rp/lib/views/widgets/event/missing_equipment_dialog.dart new file mode 100644 index 0000000..e299121 --- /dev/null +++ b/em2rp/lib/views/widgets/event/missing_equipment_dialog.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Dialog affichant la liste des équipements non validés +class MissingEquipmentDialog extends StatelessWidget { + final List missingEquipments; + final String eventId; + final bool isReturnMode; + + const MissingEquipmentDialog({ + super.key, + required this.missingEquipments, + required this.eventId, + this.isReturnMode = false, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 600), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icône d'avertissement + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + size: 48, + color: Colors.orange.shade700, + ), + ), + + const SizedBox(height: 16), + + // Titre + Text( + isReturnMode + ? 'Équipements non retournés' + : 'Équipements non préparés', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 8), + + // Description + Text( + isReturnMode + ? 'Les équipements suivants n\'ont pas été marqués comme retournés :' + : 'Les équipements suivants n\'ont pas été marqués comme préparés :', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 16), + + // Liste des équipements manquants + Flexible( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: missingEquipments.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final equipment = missingEquipments[index]; + return ListTile( + dense: true, + leading: equipment.category.getIcon( + size: 20, + color: equipment.category.color, + ), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: equipment.brand != null || equipment.model != null + ? Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()) + : null, + ); + }, + ), + ), + ), + + const SizedBox(height: 24), + + // Boutons d'action + Column( + children: [ + // Bouton 1 : Confirmer malgré les manquants (primaire - rouge) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.of(context).pop('confirm_with_missing'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: Text( + isReturnMode + ? 'Confirmer malgré les manquants' + : 'Valider malgré les manquants', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + const SizedBox(height: 12), + + // Bouton 2 : Marquer comme validés (secondaire - vert) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.of(context).pop('validate_missing'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + icon: const Icon(Icons.done_all, color: Colors.white), + label: Text( + isReturnMode + ? 'Marquer tout comme retourné' + : 'Marquer tout comme préparé', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + const SizedBox(height: 12), + + // Bouton 3 : Retourner à la liste (tertiaire - outline) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: BorderSide(color: Colors.grey.shade400), + ), + icon: Icon(Icons.arrow_back, color: Colors.grey.shade700), + label: Text( + 'Retourner à la liste', + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/event/preparation_success_dialog.dart b/em2rp/lib/views/widgets/event/preparation_success_dialog.dart new file mode 100644 index 0000000..d51f3fa --- /dev/null +++ b/em2rp/lib/views/widgets/event/preparation_success_dialog.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Dialog de succès avec animation de camion +class PreparationSuccessDialog extends StatefulWidget { + final bool isReturnMode; + + const PreparationSuccessDialog({ + super.key, + this.isReturnMode = false, + }); + + @override + State createState() => _PreparationSuccessDialogState(); +} + +class _PreparationSuccessDialogState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _truckAnimation; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 2500), + vsync: this, + ); + + // Animation du camion qui part (translation) + _truckAnimation = Tween( + begin: 0.0, + end: 1.5, + ).animate(CurvedAnimation( + parent: _controller, + curve: const Interval(0.3, 1.0, curve: Curves.easeInBack), + )); + + // Animation de fade out + _fadeAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: const Interval(0.7, 1.0, curve: Curves.easeOut), + )); + + // Animation de scale pour le check + _scaleAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.3, curve: Curves.elasticOut), + )); + + _controller.forward(); + + // Auto-fermer après l'animation + Future.delayed(const Duration(milliseconds: 2500), () { + if (mounted) { + Navigator.of(context).pop(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Animation du check qui pop + Transform.scale( + scale: _scaleAnimation.value, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 50, + ), + ), + ), + + const SizedBox(height: 24), + + // Texte + Text( + widget.isReturnMode + ? 'Retour validé !' + : 'Préparation validée !', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + // Animation du camion avec pneus qui crissent + SizedBox( + height: 100, + child: Stack( + children: [ + // Traces de pneus (lignes qui apparaissent) + if (_truckAnimation.value > 0.1) + Positioned( + left: 0, + right: MediaQuery.of(context).size.width * 0.3, + bottom: 30, + child: CustomPaint( + painter: TireMarksPainter( + progress: (_truckAnimation.value - 0.1).clamp(0.0, 1.0), + ), + ), + ), + + // Camion qui part + Positioned( + left: MediaQuery.of(context).size.width * _truckAnimation.value - 100, + bottom: 20, + child: Transform.rotate( + angle: _truckAnimation.value > 0.5 ? -0.1 : 0, + child: const Icon( + Icons.local_shipping, + size: 60, + color: AppColors.rouge, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + Text( + widget.isReturnMode + ? 'Le matériel est de retour au dépôt' + : 'Le matériel est prêt pour l\'événement', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Custom painter pour dessiner les traces de pneus +class TireMarksPainter extends CustomPainter { + final double progress; + + TireMarksPainter({required this.progress}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.shade400 + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + final dashWidth = 10.0; + final dashSpace = 5.0; + final maxWidth = size.width * progress; + + // Dessiner deux lignes de traces (pour les deux roues) + for (var i = 0; i < 2; i++) { + final y = i * 15.0; + var startX = 0.0; + + while (startX < maxWidth) { + final endX = (startX + dashWidth).clamp(0.0, maxWidth); + canvas.drawLine( + Offset(startX, y), + Offset(endX, y), + paint, + ); + startX += dashWidth + dashSpace; + } + } + } + + @override + bool shouldRepaint(TireMarksPainter oldDelegate) { + return oldDelegate.progress != progress; + } +} + diff --git a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart new file mode 100644 index 0000000..310e746 --- /dev/null +++ b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart @@ -0,0 +1,703 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart'; +import 'package:em2rp/views/widgets/event/equipment_conflict_dialog.dart'; +import 'package:em2rp/services/event_availability_service.dart'; + +/// Section pour afficher et gérer le matériel assigné à un événement +class EventAssignedEquipmentSection extends StatefulWidget { + final List assignedEquipment; + final List assignedContainers; // IDs des conteneurs + final DateTime? startDate; + final DateTime? endDate; + final Function(List, List) onChanged; + final String? eventId; // Pour exclure l'événement actuel de la vérification + + const EventAssignedEquipmentSection({ + super.key, + required this.assignedEquipment, + required this.assignedContainers, + required this.startDate, + required this.endDate, + required this.onChanged, + this.eventId, + }); + + @override + State createState() => _EventAssignedEquipmentSectionState(); +} + +class _EventAssignedEquipmentSectionState extends State { + // ...existing code... + + bool get _canAddMaterial => widget.startDate != null && widget.endDate != null; + final EventAvailabilityService _availabilityService = EventAvailabilityService(); + Map _equipmentCache = {}; + Map _containerCache = {}; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadEquipmentAndContainers(); + } + + @override + void didUpdateWidget(EventAssignedEquipmentSection oldWidget) { + super.didUpdateWidget(oldWidget); + // Recharger si les équipements ou conteneurs ont changé + if (oldWidget.assignedEquipment != widget.assignedEquipment || + oldWidget.assignedContainers != widget.assignedContainers) { + _loadEquipmentAndContainers(); + } + } + + Future _loadEquipmentAndContainers() async { + setState(() => _isLoading = true); + + try { + print('[EventAssignedEquipmentSection] Loading equipment and containers...'); + print('[EventAssignedEquipmentSection] assignedEquipment: ${widget.assignedEquipment.map((e) => e.equipmentId).toList()}'); + print('[EventAssignedEquipmentSection] assignedContainers: ${widget.assignedContainers}'); + + final equipmentProvider = context.read(); + final containerProvider = context.read(); + + // Charger depuis les streams + final equipment = await equipmentProvider.equipmentStream.first; + final containers = await containerProvider.containersStream.first; + + print('[EventAssignedEquipmentSection] Available equipment count: ${equipment.length}'); + print('[EventAssignedEquipmentSection] Available containers count: ${containers.length}'); + + // Créer le cache des équipements + for (var eq in widget.assignedEquipment) { + print('[EventAssignedEquipmentSection] Looking for equipment: ${eq.equipmentId}'); + final equipmentItem = equipment.firstWhere( + (e) { + print('[EventAssignedEquipmentSection] Comparing "${e.id}" with "${eq.equipmentId}"'); + return e.id == eq.equipmentId; + }, + orElse: () { + print('[EventAssignedEquipmentSection] Equipment NOT FOUND: ${eq.equipmentId}'); + return EquipmentModel( + id: eq.equipmentId, + name: 'Équipement inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + }, + ); + _equipmentCache[eq.equipmentId] = equipmentItem; + print('[EventAssignedEquipmentSection] Cached equipment: ${equipmentItem.id} (${equipmentItem.name})'); + } + + // Créer le cache des conteneurs + for (var containerId in widget.assignedContainers) { + print('[EventAssignedEquipmentSection] Looking for container: $containerId'); + final container = containers.firstWhere( + (c) { + print('[EventAssignedEquipmentSection] Comparing "${c.id}" with "$containerId"'); + return c.id == containerId; + }, + orElse: () { + print('[EventAssignedEquipmentSection] Container NOT FOUND: $containerId'); + return ContainerModel( + id: containerId, + name: 'Conteneur inconnu', + type: ContainerType.flightCase, + status: EquipmentStatus.available, + equipmentIds: [], + updatedAt: DateTime.now(), + createdAt: DateTime.now(), + ); + }, + ); + _containerCache[containerId] = container; + print('[EventAssignedEquipmentSection] Cached container: ${container.id} (${container.name})'); + } + + print('[EventAssignedEquipmentSection] Equipment cache: ${_equipmentCache.keys.toList()}'); + print('[EventAssignedEquipmentSection] Container cache: ${_containerCache.keys.toList()}'); + } catch (e) { + print('[EventAssignedEquipmentSection] Error loading equipment/containers: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _openSelectionDialog() async { + if (widget.startDate == null || widget.endDate == null) { + return; // Ne devrait jamais arriver car le bouton est désactivé + } + + final result = await showDialog>( + context: context, + builder: (context) => EquipmentSelectionDialog( + startDate: widget.startDate!, + endDate: widget.endDate!, + alreadyAssigned: widget.assignedEquipment, + alreadyAssignedContainers: widget.assignedContainers, + excludeEventId: widget.eventId, + ), + ); + + if (result != null && result.isNotEmpty) { + await _processSelection(result); + } + } + + Future _processSelection(Map selection) async { + // Séparer équipements et conteneurs + final newEquipment = []; + final newContainers = []; + + for (var item in selection.values) { + if (item.type == SelectionType.equipment) { + newEquipment.add(EventEquipment( + equipmentId: item.id, + quantity: item.quantity, + )); + } else { + newContainers.add(item.id); + } + } + + // Charger les équipements des conteneurs pour vérifier les conflits + final containerProvider = context.read(); + final equipmentProvider = context.read(); + + final allContainers = await containerProvider.containersStream.first; + final allEquipment = await equipmentProvider.equipmentStream.first; + + // Collecter TOUS les équipements à vérifier (directs + enfants des boîtes) + final equipmentIds = newEquipment.map((e) => e.equipmentId).toList(); + final equipmentNames = {}; + + // Ajouter les équipements directs + for (var eq in newEquipment) { + final equipment = allEquipment.firstWhere( + (e) => e.id == eq.equipmentId, + orElse: () => EquipmentModel( + id: eq.equipmentId, + name: 'Inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + equipmentNames[eq.equipmentId] = equipment.id; + } + + // Ajouter les équipements des conteneurs (par composition) + for (var containerId in newContainers) { + final container = allContainers.firstWhere( + (c) => c.id == containerId, + orElse: () => ContainerModel( + id: containerId, + name: 'Inconnu', + type: ContainerType.flightCase, + status: EquipmentStatus.available, + equipmentIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + // Ajouter tous les équipements enfants pour vérification + for (var childEquipmentId in container.equipmentIds) { + if (!equipmentIds.contains(childEquipmentId)) { + equipmentIds.add(childEquipmentId); + + final equipment = allEquipment.firstWhere( + (e) => e.id == childEquipmentId, + orElse: () => EquipmentModel( + id: childEquipmentId, + name: 'Inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + equipmentNames[childEquipmentId] = '${equipment.id} (dans ${container.name})'; + } + } + } + + // Vérifier les conflits pour TOUS les équipements (directs + enfants) + final conflicts = await _availabilityService.checkMultipleEquipmentAvailability( + equipmentIds: equipmentIds, + equipmentNames: equipmentNames, + startDate: widget.startDate!, + endDate: widget.endDate!, + excludeEventId: widget.eventId, + ); + + if (conflicts.isNotEmpty) { + // Afficher le dialog de conflits + final action = await showDialog( + context: context, + builder: (context) => EquipmentConflictDialog(conflicts: conflicts), + ); + + if (action == 'cancel') { + return; // Annuler l'ajout + } else if (action == 'force_removed') { + // Identifier quels équipements retirer + final removedIds = conflicts.keys.toSet(); + + // Retirer les équipements directs en conflit + newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId)); + + // Retirer les boîtes dont au moins un équipement enfant est en conflit + final containersToRemove = []; + for (var containerId in newContainers) { + final container = allContainers.firstWhere((c) => c.id == containerId); + final hasConflict = container.equipmentIds.any((eqId) => removedIds.contains(eqId)); + + if (hasConflict) { + containersToRemove.add(containerId); + } + } + + for (var containerId in containersToRemove) { + newContainers.remove(containerId); + + // Informer l'utilisateur + if (mounted) { + final containerName = allContainers.firstWhere((c) => c.id == containerId).name; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('La boîte "$containerName" a été retirée car elle contient du matériel en conflit.'), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 4), + ), + ); + } + } + } + // Si 'force_all', on garde tout + } + + // Fusionner avec l'existant + final updatedEquipment = [...widget.assignedEquipment]; + final updatedContainers = [...widget.assignedContainers]; + + for (var eq in newEquipment) { + if (!updatedEquipment.any((e) => e.equipmentId == eq.equipmentId)) { + updatedEquipment.add(eq); + } + } + + for (var containerId in newContainers) { + if (!updatedContainers.contains(containerId)) { + updatedContainers.add(containerId); + } + } + + // Notifier le changement + widget.onChanged(updatedEquipment, updatedContainers); + + // Recharger le cache + await _loadEquipmentAndContainers(); + } + + void _removeEquipment(String equipmentId) { + final updated = widget.assignedEquipment + .where((eq) => eq.equipmentId != equipmentId) + .toList(); + + widget.onChanged(updated, widget.assignedContainers); + setState(() { + _equipmentCache.remove(equipmentId); + }); + } + + void _removeContainer(String containerId) { + // Récupérer le conteneur pour obtenir la liste de ses enfants + final container = _containerCache[containerId]; + + // Retirer le conteneur de la liste + final updatedContainers = widget.assignedContainers + .where((id) => id != containerId) + .toList(); + + // Retirer les équipements enfants de la liste des équipements assignés + final updatedEquipment = widget.assignedEquipment.where((eq) { + if (container != null) { + // Garder uniquement les équipements qui ne sont PAS dans ce conteneur + return !container.equipmentIds.contains(eq.equipmentId); + } + return true; + }).toList(); + + print('[EventAssignedEquipmentSection] Removing container $containerId'); + if (container != null) { + print('[EventAssignedEquipmentSection] Removing ${container.equipmentIds.length} children: ${container.equipmentIds}'); + } + print('[EventAssignedEquipmentSection] Equipment before: ${widget.assignedEquipment.length}, after: ${updatedEquipment.length}'); + + // Notifier le changement avec les deux listes mises à jour + widget.onChanged(updatedEquipment, updatedContainers); + + setState(() { + _containerCache.remove(containerId); + // Retirer aussi les équipements enfants du cache + if (container != null) { + for (var equipmentId in container.equipmentIds) { + _equipmentCache.remove(equipmentId); + } + } + }); + } + + /// Récupère les équipements qui ne sont PAS dans un conteneur assigné + List _getStandaloneEquipment() { + // Collecter tous les IDs des équipements qui sont dans des conteneurs assignés + final Set equipmentIdsInContainers = {}; + + for (var containerId in widget.assignedContainers) { + final container = _containerCache[containerId]; + if (container != null) { + equipmentIdsInContainers.addAll(container.equipmentIds); + } + } + + // Filtrer les équipements assignés pour garder uniquement ceux qui ne sont pas dans un conteneur + return widget.assignedEquipment + .where((eq) => !equipmentIdsInContainers.contains(eq.equipmentId)) + .toList(); + } + + @override + Widget build(BuildContext context) { + final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.rouge.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.inventory_2, + color: AppColors.rouge, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Matériel assigné', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + '$totalItems élément(s)', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ElevatedButton.icon( + onPressed: _canAddMaterial ? _openSelectionDialog : null, + icon: Icon(Icons.add, color: _canAddMaterial ? Colors.white : Colors.grey), + label: Text( + 'Ajouter', + style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey), + ), + style: ElevatedButton.styleFrom( + backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300, + ), + ), + ], + ), + + // Message si dates non sélectionnées + if (!_canAddMaterial) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Veuillez sélectionner les dates de début et de fin pour ajouter du matériel', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + fontStyle: FontStyle.italic, + ), + ), + ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + + // Contenu + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (totalItems == 0) + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Icons.inventory_2_outlined, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Aucun matériel assigné', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Cliquez sur "Ajouter" pour sélectionner du matériel', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Conteneurs + if (widget.assignedContainers.isNotEmpty) ...[ + Text( + 'Boîtes (${widget.assignedContainers.length})', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + ...widget.assignedContainers.map((containerId) { + final container = _containerCache[containerId]; + return _buildContainerItem(container); + }).toList(), + const SizedBox(height: 16), + ], + + // Équipements directs (qui ne sont PAS dans un conteneur assigné) + if (_getStandaloneEquipment().isNotEmpty) ...[ + Text( + 'Équipements (${_getStandaloneEquipment().length})', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + ..._getStandaloneEquipment().map((eq) { + final equipment = _equipmentCache[eq.equipmentId]; + return _buildEquipmentItem(equipment, eq); + }).toList(), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildContainerItem(ContainerModel? container) { + if (container == null) { + return const SizedBox.shrink(); + } + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ExpansionTile( + leading: container.type.getIcon(size: 24, color: AppColors.rouge), + title: Text( + container.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(container.name), + trailing: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _removeContainer(container.id), + ), + children: [ + // Afficher les équipements enfants (par composition) + Consumer( + builder: (context, provider, child) { + return StreamBuilder>( + 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 const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Aucun équipement dans ce conteneur', + style: TextStyle(color: Colors.grey), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contenu (${childEquipments.length} équipement(s))', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + ...childEquipments.map((eq) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + const SizedBox(width: 16), + Icon( + Icons.subdirectory_arrow_right, + size: 16, + color: Colors.grey.shade600, + ), + const SizedBox(width: 8), + eq.category.getIcon(size: 16, color: eq.category.color), + const SizedBox(width: 8), + Expanded( + child: Text( + eq.id, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ); + }, + ); + }, + ), + ], + ), + ); + } + + Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) { + if (equipment == null) { + return const SizedBox.shrink(); + } + + final isConsumable = equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: equipment.category.getIcon(size: 24, color: equipment.category.color), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: equipment.brand != null || equipment.model != null + ? Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isConsumable && eventEq.quantity > 1) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Qté: ${eventEq.quantity}', + style: TextStyle( + color: Colors.blue.shade900, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _removeEquipment(equipment.id), + ), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart index 8467069..f20aa9c 100644 --- a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart +++ b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart @@ -53,12 +53,25 @@ class _OptionSelectorWidgetState extends State { }); } + // Méthode publique pour mettre à jour les options depuis l'extérieur + void _updateOptions(List options) { + if (mounted) { + setState(() { + _allOptions = options; + }); + } + } + void _showOptionPicker() async { final selected = await showDialog>( context: context, builder: (ctx) => _OptionPickerDialog( allOptions: _allOptions, - eventType: widget.eventType, // Ajout du paramètre manquant + eventType: widget.eventType, + onOptionsUpdated: (updatedOptions) { + // Callback pour mettre à jour les options après création + _updateOptions(updatedOptions); + }, ), ); if (selected != null) { @@ -242,10 +255,12 @@ class _OptionSelectorWidgetState extends State { class _OptionPickerDialog extends StatefulWidget { final List allOptions; final String? eventType; + final Function(List)? onOptionsUpdated; const _OptionPickerDialog({ required this.allOptions, this.eventType, + this.onOptionsUpdated, }); @override @@ -256,15 +271,36 @@ class _OptionPickerDialog extends StatefulWidget { class _OptionPickerDialogState extends State<_OptionPickerDialog> { String _search = ''; bool _creating = false; + late List _currentOptions; + + @override + void initState() { + super.initState(); + _currentOptions = widget.allOptions; + } + + Future _reloadOptions() async { + final snapshot = await FirebaseFirestore.instance.collection('options').get(); + final updatedOptions = snapshot.docs + .map((doc) => EventOption.fromMap(doc.data(), doc.id)) + .toList(); + + setState(() { + _currentOptions = updatedOptions; + }); + + // Appeler le callback pour mettre à jour aussi le parent + widget.onOptionsUpdated?.call(updatedOptions); + } @override Widget build(BuildContext context) { // Debug: Afficher les informations de filtrage print('=== DEBUG OptionPickerDialog ==='); print('widget.eventType: ${widget.eventType}'); - print('widget.allOptions.length: ${widget.allOptions.length}'); + print('_currentOptions.length: ${_currentOptions.length}'); - final filtered = widget.allOptions.where((opt) { + final filtered = _currentOptions.where((opt) { print('Option: ${opt.name}'); print(' opt.eventTypes: ${opt.eventTypes}'); print(' widget.eventType: ${widget.eventType}'); @@ -381,8 +417,9 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { builder: (ctx) => _CreateOptionDialog(), ); setState(() => _creating = false); - if (created == true) { - Navigator.pop(context); + if (created == true && mounted) { + // Recharger les options depuis Firestore + await _reloadOptions(); } }, child: _creating @@ -473,7 +510,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { validator: (v) { if (v == null || v.isEmpty) return 'Champ requis'; if (v.length > 16) return 'Maximum 16 caractères'; - if (!RegExp(r'^[A-Z0-9_-]+$').hasMatch(v)) { + if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(v)) { return 'Seuls les lettres, chiffres, _ et - sont autorisés'; } return null; @@ -526,24 +563,29 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Types d\'événement associés :'), - Wrap( - spacing: 8, - children: _allEventTypes - .map((type) => FilterChip( - label: Text(type['name']), - selected: _selectedTypes.contains(type['id']), - onSelected: (selected) { - setState(() { - if (selected) { - _selectedTypes.add(type['id']); - } else { - _selectedTypes.remove(type['id']); - } - }); - }, - )) - .toList(), - ), + _loading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ) + : Wrap( + spacing: 8, + children: _allEventTypes + .map((type) => FilterChip( + label: Text(type['name']), + selected: _selectedTypes.contains(type['id']), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add(type['id']); + } else { + _selectedTypes.remove(type['id']); + } + }); + }, + )) + .toList(), + ), ], ), if (_error != null) diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index 7b861a5..c7d5fff 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -5,6 +5,7 @@ import 'package:em2rp/views/my_account_page.dart'; import 'package:em2rp/views/user_management_page.dart'; import 'package:em2rp/views/data_management_page.dart'; import 'package:em2rp/views/equipment_management_page.dart'; +import 'package:em2rp/config/app_version.dart'; import 'package:flutter/material.dart'; import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:provider/provider.dart'; @@ -173,6 +174,20 @@ class MainDrawer extends StatelessWidget { ], ), ), + // Version en bas du drawer + const Divider(), + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + AppVersion.fullVersion, + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ), + ), ], ), ); diff --git a/em2rp/package.json b/em2rp/package.json index a3e8d5e..e5fe355 100644 --- a/em2rp/package.json +++ b/em2rp/package.json @@ -1,4 +1,14 @@ { + "name": "em2rp", + "version": "0.3.2", + "description": "EM2RP - Gestion d'événements", + "scripts": { + "version:increment": "node scripts/increment_version.js", + "deploy": "node scripts/deploy.js", + "deploy:hosting": "flutter build web --release && firebase deploy --only hosting", + "env:prod": "node scripts/toggle_env.js prod", + "env:dev": "node scripts/toggle_env.js dev" + }, "dependencies": { "@google-cloud/storage": "^7.16.0" } diff --git a/em2rp/scripts/deploy.js b/em2rp/scripts/deploy.js new file mode 100644 index 0000000..51415d2 --- /dev/null +++ b/em2rp/scripts/deploy.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +/** + * Script de déploiement automatique pour Firebase Hosting + * - Bascule en mode PRODUCTION + * - Incrémente la version + * - Build l'application Flutter pour le web + * - Déploie sur Firebase Hosting + * - Rebascule en mode DÉVELOPPEMENT + */ + +const { execSync } = require('child_process'); +const { incrementVersion } = require('./increment_version'); +const { setProductionMode, setDevelopmentMode } = require('./toggle_env'); + +console.log('🚀 Démarrage du déploiement Firebase Hosting...\n'); + +// Étape 0: Basculer en mode production +console.log('🔒 Étape 0/4: Basculement en mode PRODUCTION'); +if (!setProductionMode()) { + console.error('❌ Impossible de basculer en mode production'); + process.exit(1); +} +console.log(''); + +// Étape 1: Incrémenter la version +console.log('📝 Étape 1/4: Incrémentation de la version'); +const newVersion = incrementVersion(); +console.log(''); + +// Étape 2: Build Flutter pour le web +console.log('🔨 Étape 2/4: Build Flutter Web'); +try { + execSync('flutter build web --release', { + stdio: 'inherit', + cwd: process.cwd() + }); + console.log('✅ Build terminé avec succès\n'); +} catch (error) { + console.error('❌ Erreur lors du build Flutter'); + // Rebascule en mode dev avant de quitter + setDevelopmentMode(); + process.exit(1); +} + +// Étape 3: Déploiement Firebase +console.log('🌐 Étape 3/4: Déploiement sur Firebase Hosting'); +try { + execSync('firebase deploy --only hosting', { + stdio: 'inherit', + cwd: process.cwd() + }); + console.log('\n✅ Déploiement terminé avec succès!'); + console.log(`🎉 Version ${newVersion} déployée sur Firebase Hosting`); +} catch (error) { + console.error('❌ Erreur lors du déploiement Firebase'); + // Rebascule en mode dev avant de quitter + setDevelopmentMode(); + process.exit(1); +} + +// Étape 4: Rebascule en mode développement +console.log('\n🔓 Étape 4/4: Retour en mode DÉVELOPPEMENT'); +if (!setDevelopmentMode()) { + console.warn('⚠️ Impossible de rebascule en mode développement'); + console.warn('⚠️ Exécutez manuellement: npm run env:dev'); +} else { + console.log('✅ Mode développement restauré'); +} + +console.log('\n✨ Processus de déploiement terminé!'); diff --git a/em2rp/scripts/increment_version.js b/em2rp/scripts/increment_version.js new file mode 100644 index 0000000..a9616d0 --- /dev/null +++ b/em2rp/scripts/increment_version.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +/** + * Script pour incrémenter automatiquement le numéro de version patch + * lors du déploiement Firebase Hosting + */ + +const fs = require('fs'); +const path = require('path'); + +const VERSION_FILE = path.join(__dirname, '../lib/config/app_version.dart'); + +function incrementVersion() { + try { + // Lire le fichier + let content = fs.readFileSync(VERSION_FILE, 'utf8'); + + // Extraire la version actuelle + const versionRegex = /static const String version = '(\d+)\.(\d+)\.(\d+)';/; + const match = content.match(versionRegex); + + if (!match) { + console.error('❌ Impossible de trouver la version dans le fichier'); + process.exit(1); + } + + const major = parseInt(match[1]); + const minor = parseInt(match[2]); + const patch = parseInt(match[3]); + + // Incrémenter le patch + const newPatch = patch + 1; + const newVersion = `${major}.${minor}.${newPatch}`; + + // Remplacer dans le fichier + const newContent = content.replace( + versionRegex, + `static const String version = '${newVersion}';` + ); + + // Écrire le fichier + fs.writeFileSync(VERSION_FILE, newContent, 'utf8'); + + console.log(`✅ Version incrémentée: ${match[0].match(/\d+\.\d+\.\d+/)[0]} → ${newVersion}`); + + return newVersion; + } catch (error) { + console.error('❌ Erreur lors de l\'incrémentation:', error.message); + process.exit(1); + } +} + +// Exécuter si appelé directement +if (require.main === module) { + incrementVersion(); +} + +module.exports = { incrementVersion }; + diff --git a/em2rp/scripts/toggle_env.js b/em2rp/scripts/toggle_env.js new file mode 100644 index 0000000..3b43c70 --- /dev/null +++ b/em2rp/scripts/toggle_env.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +/** + * Script pour basculer entre mode développement et production + */ + +const fs = require('fs'); +const path = require('path'); + +const ENV_FILE = path.join(__dirname, '../lib/config/env.dart'); +const ENV_DEV_FILE = path.join(__dirname, '../lib/config/env.dev.dart'); + +const PROD_CONFIG = `class Env { + static const bool isDevelopment = false; + + // Configuration de l'auto-login en développement + static const String devAdminEmail = ''; + static const String devAdminPassword = ''; + + // URLs et endpoints + static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; + + // Configuration Firebase + static const String firebaseProjectId = 'em2rp-951dc'; + + // Autres configurations + static const int apiTimeout = 30000; // 30 secondes +} +`; + +function setProductionMode() { + try { + // Sauvegarder la config actuelle dans env.dev.dart si elle n'existe pas + if (!fs.existsSync(ENV_DEV_FILE) && fs.existsSync(ENV_FILE)) { + const currentContent = fs.readFileSync(ENV_FILE, 'utf8'); + if (currentContent.includes('isDevelopment = true')) { + fs.writeFileSync(ENV_DEV_FILE, currentContent, 'utf8'); + console.log('✅ Configuration de développement sauvegardée dans env.dev.dart'); + } + } + + // Écrire la configuration de production + fs.writeFileSync(ENV_FILE, PROD_CONFIG, 'utf8'); + console.log('✅ Mode PRODUCTION activé (isDevelopment = false, credentials masqués)'); + return true; + } catch (error) { + console.error('❌ Erreur lors du basculement en mode production:', error.message); + return false; + } +} + +function setDevelopmentMode() { + try { + if (!fs.existsSync(ENV_DEV_FILE)) { + console.error('❌ Fichier env.dev.dart introuvable. Créez-le d\'abord avec vos credentials.'); + return false; + } + + const devContent = fs.readFileSync(ENV_DEV_FILE, 'utf8'); + fs.writeFileSync(ENV_FILE, devContent, 'utf8'); + console.log('✅ Mode DÉVELOPPEMENT activé (isDevelopment = true)'); + return true; + } catch (error) { + console.error('❌ Erreur lors du basculement en mode développement:', error.message); + return false; + } +} + +// Exécuter si appelé directement +if (require.main === module) { + const args = process.argv.slice(2); + const mode = args[0]; + + if (mode === 'prod' || mode === 'production') { + process.exit(setProductionMode() ? 0 : 1); + } else if (mode === 'dev' || mode === 'development') { + process.exit(setDevelopmentMode() ? 0 : 1); + } else { + console.log('Usage: node toggle_env.js [prod|dev]'); + console.log(' prod : Bascule en mode production (isDevelopment = false, sans credentials)'); + console.log(' dev : Bascule en mode développement (isDevelopment = true, avec credentials)'); + process.exit(1); + } +} + +module.exports = { setProductionMode, setDevelopmentMode }; +