feat: Refactor event equipment management with advanced selection and conflict detection
This commit introduces a complete overhaul of how equipment is assigned to events, focusing on an enhanced user experience, advanced selection capabilities, and robust conflict detection.
**Key Features & Enhancements:**
- **Advanced Equipment Selection UI (`EquipmentSelectionDialog`):**
- New full-screen dialog to select equipment and containers ("boîtes") for an event.
- Hierarchical view showing containers and a flat list of all individual equipment.
- Real-time search and filtering by equipment category.
- Side panel summarizing the current selection and providing recommendations for containers based on selected equipment.
- Supports quantity selection for consumables and cables.
- **Conflict Detection & Management (`EventAvailabilityService`):**
- A new service (`EventAvailabilityService`) checks for equipment availability against other events based on the selected date range.
- The selection dialog visually highlights equipment and containers with scheduling conflicts (e.g., already used, partially unavailable).
- A dedicated conflict resolution dialog (`EquipmentConflictDialog`) appears if conflicting items are selected, allowing the user to either remove them or force the assignment.
- **Integrated Event Form (`EventAssignedEquipmentSection`):**
- The event creation/editing form now includes a new section for managing assigned equipment.
- It clearly displays assigned containers and standalone equipment, showing the composition of each container.
- Integrates the new selection dialog, ensuring all assignments are checked for conflicts before being saved.
- **Event Preparation & Return Workflow (`EventPreparationPage`):**
- New page (`EventPreparationPage`) for managing the check-out (preparation) and check-in (return) of equipment for an event.
- Provides a checklist of all assigned equipment.
- Users can validate each item, with options to "validate all" or finalize with missing items.
- Includes a dialog (`MissingEquipmentDialog`) to handle discrepancies.
- Supports tracking returned quantities for consumables.
**Data Model and Other Changes:**
- The `EventModel` now includes `assignedContainers` to explicitly link containers to an event.
- `EquipmentAssociatedEventsSection` on the equipment detail page is now functional, displaying current, upcoming, and past events for that item.
- Added deployment and versioning scripts (`scripts/deploy.js`, `scripts/increment_version.js`, `scripts/toggle_env.js`) to automate the release process.
- Introduced an application version display in the main drawer (`AppVersion`).
This commit is contained in:
4
em2rp/.gitignore
vendored
4
em2rp/.gitignore
vendored
@@ -41,3 +41,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Environment configuration with credentials
|
||||||
|
lib/config/env.dev.dart
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# em2rp
|
|
||||||
|
|
||||||
A new Flutter project.
|
|
||||||
60
em2rp/deploy.bat
Normal file
60
em2rp/deploy.bat
Normal file
@@ -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
|
||||||
|
|
||||||
15
em2rp/env_dev.bat
Normal file
15
em2rp/env_dev.bat
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
16
em2rp/env_prod.bat
Normal file
16
em2rp/env_prod.bat
Normal file
@@ -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
|
||||||
|
|
||||||
14
em2rp/increment_version.bat
Normal file
14
em2rp/increment_version.bat
Normal file
@@ -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
|
||||||
|
|
||||||
11
em2rp/lib/config/app_version.dart
Normal file
11
em2rp/lib/config/app_version.dart
Normal file
@@ -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';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,8 +3,7 @@ class Env {
|
|||||||
|
|
||||||
// Configuration de l'auto-login en développement
|
// Configuration de l'auto-login en développement
|
||||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||||
static const String devAdminPassword =
|
static const String devAdminPassword = 'Pastis51!';
|
||||||
"Pastis51!"; // À remplacer par le vrai mot de passe
|
|
||||||
|
|
||||||
// URLs et endpoints
|
// URLs et endpoints
|
||||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
@@ -15,3 +14,4 @@ class Env {
|
|||||||
// Autres configurations
|
// Autres configurations
|
||||||
static const int apiTimeout = 30000; // 30 secondes
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
em2rp/lib/config/env.dev.dart
Normal file
17
em2rp/lib/config/env.dev.dart
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
List<Map<String, dynamic>> _selectedOptions = [];
|
List<Map<String, dynamic>> _selectedOptions = [];
|
||||||
bool _formChanged = false;
|
bool _formChanged = false;
|
||||||
EventStatus _selectedStatus = EventStatus.waitingForApproval;
|
EventStatus _selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
List<EventEquipment> _assignedEquipment = [];
|
||||||
|
List<String> _assignedContainers = [];
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
DateTime? get startDateTime => _startDateTime;
|
DateTime? get startDateTime => _startDateTime;
|
||||||
@@ -49,6 +51,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
bool get isLoadingUsers => _isLoadingUsers;
|
bool get isLoadingUsers => _isLoadingUsers;
|
||||||
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
|
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
|
||||||
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
|
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
|
||||||
|
List<EventEquipment> get assignedEquipment => _assignedEquipment;
|
||||||
|
List<String> get assignedContainers => _assignedContainers;
|
||||||
bool get formChanged => _formChanged;
|
bool get formChanged => _formChanged;
|
||||||
EventStatus get selectedStatus => _selectedStatus;
|
EventStatus get selectedStatus => _selectedStatus;
|
||||||
|
|
||||||
@@ -95,6 +99,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
addressController.text = event.address;
|
addressController.text = event.address;
|
||||||
_startDateTime = event.startDateTime;
|
_startDateTime = event.startDateTime;
|
||||||
_endDateTime = event.endDateTime;
|
_endDateTime = event.endDateTime;
|
||||||
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
||||||
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
||||||
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
||||||
_selectedUserIds = event.workforce.map((ref) => ref.id).toList();
|
_selectedUserIds = event.workforce.map((ref) => ref.id).toList();
|
||||||
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
||||||
@@ -206,6 +212,13 @@ class EventFormController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||||
|
_assignedEquipment = equipment;
|
||||||
|
_assignedContainers = containers;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> pickAndUploadFiles() async {
|
Future<void> pickAndUploadFiles() async {
|
||||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
|
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
|
||||||
if (result != null && result.files.isNotEmpty) {
|
if (result != null && result.files.isNotEmpty) {
|
||||||
@@ -297,6 +310,10 @@ class EventFormController extends ChangeNotifier {
|
|||||||
documents: finalDocuments,
|
documents: finalDocuments,
|
||||||
options: _selectedOptions,
|
options: _selectedOptions,
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
|
assignedEquipment: _assignedEquipment,
|
||||||
|
assignedContainers: _assignedContainers,
|
||||||
|
preparationStatus: existingEvent.preparationStatus,
|
||||||
|
returnStatus: existingEvent.returnStatus,
|
||||||
);
|
);
|
||||||
|
|
||||||
await EventFormService.updateEvent(updatedEvent);
|
await EventFormService.updateEvent(updatedEvent);
|
||||||
@@ -335,6 +352,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
documents: _uploadedFiles,
|
documents: _uploadedFiles,
|
||||||
options: _selectedOptions,
|
options: _selectedOptions,
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
|
assignedContainers: _assignedContainers,
|
||||||
|
assignedEquipment: _assignedEquipment,
|
||||||
);
|
);
|
||||||
|
|
||||||
final eventId = await EventFormService.createEvent(newEvent);
|
final eventId = await EventFormService.createEvent(newEvent);
|
||||||
|
|||||||
@@ -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_management_page.dart';
|
||||||
import 'package:em2rp/views/container_form_page.dart';
|
import 'package:em2rp/views/container_form_page.dart';
|
||||||
import 'package:em2rp/views/container_detail_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:em2rp/models/container_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -150,6 +151,12 @@ class MyApp extends StatelessWidget {
|
|||||||
child: ContainerDetailPage(container: container),
|
child: ContainerDetailPage(container: container),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
'/event_preparation': (context) {
|
||||||
|
final eventId = ModalRoute.of(context)!.settings.arguments as String;
|
||||||
|
return AuthGuard(
|
||||||
|
child: EventPreparationPage(eventId: eventId),
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||||
String? get customIconPath {
|
String? get customIconPath {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
@@ -228,6 +250,7 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension EquipmentStatusExtension on EquipmentStatus {
|
extension EquipmentStatusExtension on EquipmentStatus {
|
||||||
/// Retourne le label français du statut
|
/// Retourne le label français du statut
|
||||||
String get label {
|
String get label {
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ class EventModel {
|
|||||||
|
|
||||||
// Nouveaux champs pour la gestion du matériel
|
// Nouveaux champs pour la gestion du matériel
|
||||||
final List<EventEquipment> assignedEquipment;
|
final List<EventEquipment> assignedEquipment;
|
||||||
|
final List<String> assignedContainers; // IDs des conteneurs assignés
|
||||||
final PreparationStatus? preparationStatus;
|
final PreparationStatus? preparationStatus;
|
||||||
final ReturnStatus? returnStatus;
|
final ReturnStatus? returnStatus;
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@ class EventModel {
|
|||||||
this.options = const [],
|
this.options = const [],
|
||||||
this.status = EventStatus.waitingForApproval,
|
this.status = EventStatus.waitingForApproval,
|
||||||
this.assignedEquipment = const [],
|
this.assignedEquipment = const [],
|
||||||
|
this.assignedContainers = const [],
|
||||||
this.preparationStatus,
|
this.preparationStatus,
|
||||||
this.returnStatus,
|
this.returnStatus,
|
||||||
});
|
});
|
||||||
@@ -295,6 +297,18 @@ class EventModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gestion des conteneurs assignés
|
||||||
|
final assignedContainersRaw = map['assignedContainers'] ?? [];
|
||||||
|
final List<String> assignedContainers = [];
|
||||||
|
|
||||||
|
if (assignedContainersRaw is List) {
|
||||||
|
for (var e in assignedContainersRaw) {
|
||||||
|
if (e is String) {
|
||||||
|
assignedContainers.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return EventModel(
|
return EventModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: (map['Name'] ?? '').toString().trim(),
|
name: (map['Name'] ?? '').toString().trim(),
|
||||||
@@ -303,6 +317,7 @@ class EventModel {
|
|||||||
endDateTime: endDate,
|
endDateTime: endDate,
|
||||||
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
|
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
|
||||||
installationTime: _parseInt(map['InstallationTime'] ?? 0),
|
installationTime: _parseInt(map['InstallationTime'] ?? 0),
|
||||||
|
assignedContainers: assignedContainers,
|
||||||
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
|
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
|
||||||
eventTypeId: eventTypeId,
|
eventTypeId: eventTypeId,
|
||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
@@ -370,6 +385,7 @@ class EventModel {
|
|||||||
'options': options,
|
'options': options,
|
||||||
'status': eventStatusToString(status),
|
'status': eventStatusToString(status),
|
||||||
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
||||||
|
'assignedContainers': assignedContainers,
|
||||||
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
||||||
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
||||||
};
|
};
|
||||||
|
|||||||
185
em2rp/lib/services/event_availability_service.dart
Normal file
185
em2rp/lib/services/event_availability_service.dart
Normal file
@@ -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<List<AvailabilityConflict>> 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 = <AvailabilityConflict>[];
|
||||||
|
|
||||||
|
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<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({
|
||||||
|
required List<String> equipmentIds,
|
||||||
|
required Map<String, String> equipmentNames,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
final allConflicts = <String, List<AvailabilityConflict>>{};
|
||||||
|
|
||||||
|
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<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,10 @@ class EventPreparationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Valider tous les retours
|
/// Valider tous les retours
|
||||||
Future<void> validateAllReturn(String eventId) async {
|
Future<void> validateAllReturn(
|
||||||
|
String eventId, [
|
||||||
|
Map<String, int>? returnedQuantities,
|
||||||
|
]) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
final event = await _getEvent(eventId);
|
||||||
if (event == null) {
|
if (event == null) {
|
||||||
@@ -157,9 +160,12 @@ class EventPreparationService {
|
|||||||
|
|
||||||
// Marquer tous les équipements comme retournés
|
// Marquer tous les équipements comme retournés
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
final updatedEquipment = event.assignedEquipment.map((eq) {
|
||||||
|
final returnedQty = returnedQuantities?[eq.equipmentId] ??
|
||||||
|
eq.returnedQuantity ??
|
||||||
|
eq.quantity;
|
||||||
return eq.copyWith(
|
return eq.copyWith(
|
||||||
isReturned: true,
|
isReturned: true,
|
||||||
returnedQuantity: eq.returnedQuantity ?? eq.quantity,
|
returnedQuantity: returnedQty,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// 4. Événements associés
|
// 4. Événements associés
|
||||||
const EquipmentAssociatedEventsSection(),
|
EquipmentAssociatedEventsSection(equipment: widget.equipment),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 5-7. Prix, Historique des maintenances, Dates en layout responsive
|
// 5-7. Prix, Historique des maintenances, Dates en layout responsive
|
||||||
|
|||||||
@@ -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_basic_info_section.dart';
|
||||||
import 'package:em2rp/views/widgets/event_form/event_details_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_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/event_form/event_form_actions.dart';
|
||||||
import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart';
|
import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart';
|
||||||
|
|
||||||
@@ -221,6 +222,17 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
eventTypeRequired: controller.selectedEventTypeId == null,
|
eventTypeRequired: controller.selectedEventTypeId == null,
|
||||||
isMobile: isMobile,
|
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(
|
EventDetailsSection(
|
||||||
descriptionController: controller.descriptionController,
|
descriptionController: controller.descriptionController,
|
||||||
installationController: controller.installationController,
|
installationController: controller.installationController,
|
||||||
|
|||||||
385
em2rp/lib/views/event_preparation_page.dart
Normal file
385
em2rp/lib/views/event_preparation_page.dart
Normal file
@@ -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<EventPreparationPage> createState() => _EventPreparationPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventPreparationPageState extends State<EventPreparationPage> {
|
||||||
|
final EventPreparationService _preparationService = EventPreparationService();
|
||||||
|
EventModel? _event;
|
||||||
|
Map<String, EquipmentModel> _equipmentMap = {};
|
||||||
|
Map<String, int> _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<void> _loadEventAndEquipment() async {
|
||||||
|
try {
|
||||||
|
// Charger l'événement
|
||||||
|
final eventProvider = context.read<EventProvider>();
|
||||||
|
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<EquipmentProvider>();
|
||||||
|
final Map<String, EquipmentModel> 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<void> _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<void> _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<void> _validatePreparation() async {
|
||||||
|
if (_event == null) return;
|
||||||
|
|
||||||
|
// Vérifier quels équipements ne sont pas validés
|
||||||
|
final missingEquipment = <EquipmentModel>[];
|
||||||
|
final missingIds = <String>[];
|
||||||
|
|
||||||
|
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<String>(
|
||||||
|
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<LocalUserProvider>();
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,122 @@
|
|||||||
import 'package:flutter/material.dart';
|
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:em2rp/utils/colors.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
/// Widget pour afficher les événements associés
|
enum EventFilter {
|
||||||
class EquipmentAssociatedEventsSection extends StatelessWidget {
|
current, // Événements en cours (préparés mais pas encore retournés)
|
||||||
const EquipmentAssociatedEventsSection({super.key});
|
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<EquipmentAssociatedEventsSection> createState() =>
|
||||||
|
_EquipmentAssociatedEventsSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EquipmentAssociatedEventsSectionState
|
||||||
|
extends State<EquipmentAssociatedEventsSection> {
|
||||||
|
EventFilter _selectedFilter = EventFilter.current;
|
||||||
|
List<EventModel> _events = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -14,32 +127,302 @@ class EquipmentAssociatedEventsSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// En-tête avec filtre
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.event, color: AppColors.rouge),
|
const Icon(Icons.event, color: AppColors.rouge),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
'Événements associés',
|
'Événements associés',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
// Menu déroulant pour filtrer
|
||||||
|
PopupMenuButton<EventFilter>(
|
||||||
|
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 Divider(height: 24),
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(16.0),
|
// 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: Center(
|
||||||
child: Text(
|
child: Column(
|
||||||
'Fonctionnalité à implémenter',
|
children: [
|
||||||
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
173
em2rp/lib/views/widgets/event/equipment_checklist_item.dart
Normal file
173
em2rp/lib/views/widgets/event/equipment_checklist_item.dart
Normal file
@@ -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<bool> onValidate;
|
||||||
|
final bool isReturnMode;
|
||||||
|
final int? quantity;
|
||||||
|
final int? returnedQuantity;
|
||||||
|
final ValueChanged<int>? 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
288
em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart
Normal file
288
em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart
Normal file
@@ -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<String, List<AvailabilityConflict>> conflicts;
|
||||||
|
|
||||||
|
const EquipmentConflictDialog({
|
||||||
|
super.key,
|
||||||
|
required this.conflicts,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EquipmentConflictDialog> createState() => _EquipmentConflictDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
|
||||||
|
final Set<String> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1880
em2rp/lib/views/widgets/event/equipment_selection_dialog.dart
Normal file
1880
em2rp/lib/views/widgets/event/equipment_selection_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
185
em2rp/lib/views/widgets/event/missing_equipment_dialog.dart
Normal file
185
em2rp/lib/views/widgets/event/missing_equipment_dialog.dart
Normal file
@@ -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<EquipmentModel> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
226
em2rp/lib/views/widgets/event/preparation_success_dialog.dart
Normal file
226
em2rp/lib/views/widgets/event/preparation_success_dialog.dart
Normal file
@@ -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<PreparationSuccessDialog> createState() => _PreparationSuccessDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PreparationSuccessDialogState extends State<PreparationSuccessDialog>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _truckAnimation;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 2500),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Animation du camion qui part (translation)
|
||||||
|
_truckAnimation = Tween<double>(
|
||||||
|
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<double>(
|
||||||
|
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<double>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<EventEquipment> assignedEquipment;
|
||||||
|
final List<String> assignedContainers; // IDs des conteneurs
|
||||||
|
final DateTime? startDate;
|
||||||
|
final DateTime? endDate;
|
||||||
|
final Function(List<EventEquipment>, List<String>) 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<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
||||||
|
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
||||||
|
Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
|
Map<String, ContainerModel> _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<void> _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<EquipmentProvider>();
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
|
// 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<void> _openSelectionDialog() async {
|
||||||
|
if (widget.startDate == null || widget.endDate == null) {
|
||||||
|
return; // Ne devrait jamais arriver car le bouton est désactivé
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await showDialog<Map<String, SelectedItem>>(
|
||||||
|
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<void> _processSelection(Map<String, SelectedItem> selection) async {
|
||||||
|
// Séparer équipements et conteneurs
|
||||||
|
final newEquipment = <EventEquipment>[];
|
||||||
|
final newContainers = <String>[];
|
||||||
|
|
||||||
|
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<ContainerProvider>();
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
|
||||||
|
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 = <String, String>{};
|
||||||
|
|
||||||
|
// 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<String>(
|
||||||
|
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 = <String>[];
|
||||||
|
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<EventEquipment> _getStandaloneEquipment() {
|
||||||
|
// Collecter tous les IDs des équipements qui sont dans des conteneurs assignés
|
||||||
|
final Set<String> 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<EquipmentProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
return StreamBuilder<List<EquipmentModel>>(
|
||||||
|
stream: provider.equipmentStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allEquipment = snapshot.data ?? [];
|
||||||
|
final childEquipments = allEquipment
|
||||||
|
.where((eq) => container.equipmentIds.contains(eq.id))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (childEquipments.isEmpty) {
|
||||||
|
return 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -53,12 +53,25 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Méthode publique pour mettre à jour les options depuis l'extérieur
|
||||||
|
void _updateOptions(List<EventOption> options) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_allOptions = options;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showOptionPicker() async {
|
void _showOptionPicker() async {
|
||||||
final selected = await showDialog<Map<String, dynamic>>(
|
final selected = await showDialog<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => _OptionPickerDialog(
|
builder: (ctx) => _OptionPickerDialog(
|
||||||
allOptions: _allOptions,
|
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) {
|
if (selected != null) {
|
||||||
@@ -242,10 +255,12 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
|
|||||||
class _OptionPickerDialog extends StatefulWidget {
|
class _OptionPickerDialog extends StatefulWidget {
|
||||||
final List<EventOption> allOptions;
|
final List<EventOption> allOptions;
|
||||||
final String? eventType;
|
final String? eventType;
|
||||||
|
final Function(List<EventOption>)? onOptionsUpdated;
|
||||||
|
|
||||||
const _OptionPickerDialog({
|
const _OptionPickerDialog({
|
||||||
required this.allOptions,
|
required this.allOptions,
|
||||||
this.eventType,
|
this.eventType,
|
||||||
|
this.onOptionsUpdated,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -256,15 +271,36 @@ class _OptionPickerDialog extends StatefulWidget {
|
|||||||
class _OptionPickerDialogState extends State<_OptionPickerDialog> {
|
class _OptionPickerDialogState extends State<_OptionPickerDialog> {
|
||||||
String _search = '';
|
String _search = '';
|
||||||
bool _creating = false;
|
bool _creating = false;
|
||||||
|
late List<EventOption> _currentOptions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_currentOptions = widget.allOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Debug: Afficher les informations de filtrage
|
// Debug: Afficher les informations de filtrage
|
||||||
print('=== DEBUG OptionPickerDialog ===');
|
print('=== DEBUG OptionPickerDialog ===');
|
||||||
print('widget.eventType: ${widget.eventType}');
|
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('Option: ${opt.name}');
|
||||||
print(' opt.eventTypes: ${opt.eventTypes}');
|
print(' opt.eventTypes: ${opt.eventTypes}');
|
||||||
print(' widget.eventType: ${widget.eventType}');
|
print(' widget.eventType: ${widget.eventType}');
|
||||||
@@ -381,8 +417,9 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
|
|||||||
builder: (ctx) => _CreateOptionDialog(),
|
builder: (ctx) => _CreateOptionDialog(),
|
||||||
);
|
);
|
||||||
setState(() => _creating = false);
|
setState(() => _creating = false);
|
||||||
if (created == true) {
|
if (created == true && mounted) {
|
||||||
Navigator.pop(context);
|
// Recharger les options depuis Firestore
|
||||||
|
await _reloadOptions();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: _creating
|
child: _creating
|
||||||
@@ -473,7 +510,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
|
|||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.isEmpty) return 'Champ requis';
|
if (v == null || v.isEmpty) return 'Champ requis';
|
||||||
if (v.length > 16) return 'Maximum 16 caractères';
|
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 'Seuls les lettres, chiffres, _ et - sont autorisés';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -526,7 +563,12 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Types d\'événement associés :'),
|
const Text('Types d\'événement associés :'),
|
||||||
Wrap(
|
_loading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: _allEventTypes
|
children: _allEventTypes
|
||||||
.map((type) => FilterChip(
|
.map((type) => FilterChip(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/views/my_account_page.dart';
|
|||||||
import 'package:em2rp/views/user_management_page.dart';
|
import 'package:em2rp/views/user_management_page.dart';
|
||||||
import 'package:em2rp/views/data_management_page.dart';
|
import 'package:em2rp/views/data_management_page.dart';
|
||||||
import 'package:em2rp/views/equipment_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:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
||||||
import 'package:provider/provider.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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.16.0"
|
"@google-cloud/storage": "^7.16.0"
|
||||||
}
|
}
|
||||||
|
|||||||
71
em2rp/scripts/deploy.js
Normal file
71
em2rp/scripts/deploy.js
Normal file
@@ -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é!');
|
||||||
59
em2rp/scripts/increment_version.js
Normal file
59
em2rp/scripts/increment_version.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
87
em2rp/scripts/toggle_env.js
Normal file
87
em2rp/scripts/toggle_env.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
Reference in New Issue
Block a user