Cette mise à jour majeure introduit un système de notifications robuste, centré sur la création d'alertes et l'envoi d'emails via des Cloud Functions. Elle inclut la gestion des préférences utilisateur, la création automatique d'alertes lors d'événements critiques et une nouvelle interface dédiée.
**Backend (Cloud Functions) :**
- **Nouveau service d'alerting (`createAlert`, `processEquipmentValidation`) :**
- `createAlert` : Nouvelle fonction pour créer une alerte. Elle détermine les utilisateurs à notifier (admins, workforce d'événement) et gère la persistance dans Firestore.
- `processEquipmentValidation` : Endpoint appelé lors de la validation du matériel (chargement/déchargement). Il analyse l'état de l'équipement (`LOST`, `MISSING`, `DAMAGED`) et crée automatiquement les alertes correspondantes.
- **Système d'envoi d'emails (`sendAlertEmail`, `sendDailyDigest`) :**
- `sendAlertEmail` : Cloud Function `onCall` pour envoyer un email d'alerte individuel. Elle respecte les préférences de notification de l'utilisateur (canal email, type d'alerte).
- `sendDailyDigest` : Tâche planifiée (tous les jours à 8h) qui envoie un email récapitulatif des alertes non lues des dernières 24 heures aux utilisateurs concernés.
- Ajout de templates HTML (`base-template`, `alert-individual`, `alert-digest`) avec `Handlebars` pour des emails riches.
- Configuration centralisée du SMTP via des variables d'environnement (`.env`).
- **Triggers Firestore (`onEventCreated`, `onEventUpdated`) :**
- Des triggers créent désormais des alertes d'information lorsqu'un événement est créé ou que de nouveaux membres sont ajoutés à la workforce.
- **Règles Firestore :**
- Mises à jour pour autoriser les utilisateurs authentifiés à créer et modifier leurs propres alertes (marquer comme lue, supprimer), tout en sécurisant les accès.
**Frontend (Flutter) :**
- **Nouvel `AlertService` et `EmailService` :**
- `AlertService` : Centralise la logique de création, lecture et gestion des alertes côté client en appelant les nouvelles Cloud Functions.
- `EmailService` : Service pour déclencher l'envoi d'emails via la fonction `sendAlertEmail`. Il contient la logique pour déterminer si une notification doit être immédiate (critique) ou différée (digest).
- **Nouvelle page de Notifications (`/alerts`) :**
- Interface dédiée pour lister toutes les alertes de l'utilisateur, avec des onglets pour filtrer par catégorie (Toutes, Événement, Maintenance, Équipement).
- Permet de marquer les alertes comme lues, de les supprimer et de tout marquer comme lu.
- **Intégration dans l'UI :**
- Ajout d'un badge de notification dans la `CustomAppBar` affichant le nombre d'alertes non lues en temps réel.
- Le `AutoLoginWrapper` gère désormais la redirection vers des routes profondes (ex: `/alerts`) depuis une URL.
- **Gestion des Préférences de Notification :**
- Ajout d'un widget `NotificationPreferencesWidget` dans la page "Mon Compte".
- Les utilisateurs peuvent désormais activer/désactiver les notifications par email, ainsi que filtrer par type d'alerte (événements, maintenance, etc.).
- Le `UserModel` et `LocalUserProvider` ont été étendus pour gérer ce nouveau modèle de préférences.
- **Création d'alertes contextuelles :**
- Le service `EventFormService` crée maintenant automatiquement une alerte lorsqu'un événement est créé ou modifié.
- La page de préparation d'événement (`EventPreparationPage`) appelle `processEquipmentValidation` à la fin de chaque étape pour une détection automatisée des anomalies.
**Dépendances et CI/CD :**
- Ajout des dépendances `cloud_functions` et `timeago` (Flutter), et `nodemailer`, `handlebars`, `dotenv` (Node.js).
- Ajout de scripts de déploiement PowerShell (`deploy_functions.ps1`, `deploy_firestore_rules.ps1`) pour simplifier les mises en production.
1021 lines
36 KiB
Dart
1021 lines
36 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:cloud_functions/cloud_functions.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/providers/event_provider.dart';
|
|
import 'package:em2rp/providers/local_user_provider.dart';
|
|
import 'package:em2rp/services/data_service.dart';
|
|
import 'package:em2rp/services/api_service.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
|
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
|
import 'package:em2rp/utils/colors.dart';
|
|
|
|
/// Type d'étape de préparation
|
|
enum PreparationStep {
|
|
preparation, // Préparation dépôt
|
|
loadingOutbound, // Chargement aller
|
|
unloadingReturn, // Chargement retour (déchargement)
|
|
return_, // Retour dépôt
|
|
}
|
|
|
|
/// Page de préparation ou de retour d'un événement
|
|
class EventPreparationPage extends StatefulWidget {
|
|
final EventModel initialEvent;
|
|
|
|
const EventPreparationPage({
|
|
super.key,
|
|
required this.initialEvent,
|
|
});
|
|
|
|
@override
|
|
State<EventPreparationPage> createState() => _EventPreparationPageState();
|
|
}
|
|
|
|
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late final DataService _dataService;
|
|
|
|
Map<String, EquipmentModel> _equipmentCache = {};
|
|
Map<String, ContainerModel> _containerCache = {};
|
|
Map<String, int> _returnedQuantities = {};
|
|
|
|
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
|
Map<String, bool> _localValidationState = {};
|
|
|
|
|
|
// NOUVEAU : Gestion des quantités par étape
|
|
Map<String, int> _quantitiesAtPreparation = {};
|
|
Map<String, int> _quantitiesAtLoading = {};
|
|
Map<String, int> _quantitiesAtUnloading = {};
|
|
Map<String, int> _quantitiesAtReturn = {};
|
|
|
|
bool _isLoading = true;
|
|
bool _isValidating = false;
|
|
bool _showSuccessAnimation = false;
|
|
bool _loadSimultaneously = false; // Checkbox "charger en même temps"
|
|
|
|
// Stockage de l'événement actuel
|
|
late EventModel _currentEvent;
|
|
|
|
// Détermine l'étape actuelle selon le statut de l'événement
|
|
PreparationStep get _currentStep {
|
|
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
|
final loading = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
|
|
final unloading = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
|
|
final returnStatus = _currentEvent.returnStatus ?? ReturnStatus.notStarted;
|
|
|
|
// Logique stricte : on avance étape par étape
|
|
// 1. Préparation dépôt
|
|
if (prep != PreparationStatus.completed) {
|
|
return PreparationStep.preparation;
|
|
}
|
|
|
|
// 2. Chargement aller (après préparation complète)
|
|
if (loading != LoadingStatus.completed) {
|
|
return PreparationStep.loadingOutbound;
|
|
}
|
|
|
|
// 3. Chargement retour (après chargement aller complet)
|
|
if (unloading != UnloadingStatus.completed) {
|
|
return PreparationStep.unloadingReturn;
|
|
}
|
|
|
|
// 4. Retour dépôt (après déchargement complet)
|
|
if (returnStatus != ReturnStatus.completed) {
|
|
return PreparationStep.return_;
|
|
}
|
|
|
|
// Tout est terminé, par défaut on retourne à la préparation
|
|
return PreparationStep.preparation;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_currentEvent = widget.initialEvent;
|
|
_dataService = DataService(FirebaseFunctionsApiService());
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 500),
|
|
);
|
|
|
|
// Vérification de sécurité et chargement après le premier frame
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_isCurrentStepCompleted()) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Cette étape est déjà terminée'),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
Navigator.of(context).pop();
|
|
return;
|
|
}
|
|
|
|
// Charger les équipements après le premier frame pour éviter setState pendant build
|
|
_loadEquipmentAndContainers();
|
|
});
|
|
}
|
|
|
|
/// Vérifie si l'étape actuelle est déjà complétée
|
|
bool _isCurrentStepCompleted() {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return (_currentEvent.preparationStatus ?? PreparationStatus.notStarted) == PreparationStatus.completed;
|
|
case PreparationStep.loadingOutbound:
|
|
return (_currentEvent.loadingStatus ?? LoadingStatus.notStarted) == LoadingStatus.completed;
|
|
case PreparationStep.unloadingReturn:
|
|
return (_currentEvent.unloadingStatus ?? UnloadingStatus.notStarted) == UnloadingStatus.completed;
|
|
case PreparationStep.return_:
|
|
return (_currentEvent.returnStatus ?? ReturnStatus.notStarted) == ReturnStatus.completed;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadEquipmentAndContainers() async {
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final equipmentProvider = context.read<EquipmentProvider>();
|
|
final containerProvider = context.read<ContainerProvider>();
|
|
|
|
// S'assurer que les équipements sont chargés
|
|
await equipmentProvider.ensureLoaded();
|
|
await containerProvider.ensureLoaded();
|
|
|
|
final equipment = await equipmentProvider.equipmentStream.first;
|
|
final containers = await containerProvider.containersStream.first;
|
|
|
|
for (var eq in _currentEvent.assignedEquipment) {
|
|
final equipmentItem = equipment.firstWhere(
|
|
(e) => e.id == eq.equipmentId,
|
|
orElse: () => 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;
|
|
|
|
// Initialiser l'état local de validation depuis l'événement
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
_localValidationState[eq.equipmentId] = eq.isPrepared;
|
|
break;
|
|
case PreparationStep.loadingOutbound:
|
|
_localValidationState[eq.equipmentId] = eq.isLoaded;
|
|
break;
|
|
case PreparationStep.unloadingReturn:
|
|
_localValidationState[eq.equipmentId] = eq.isUnloaded;
|
|
break;
|
|
case PreparationStep.return_:
|
|
_localValidationState[eq.equipmentId] = eq.isReturned;
|
|
break;
|
|
}
|
|
|
|
if ((_currentStep == PreparationStep.return_ ||
|
|
_currentStep == PreparationStep.unloadingReturn) &&
|
|
equipmentItem.hasQuantity) {
|
|
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
|
}
|
|
}
|
|
|
|
for (var containerId in _currentEvent.assignedContainers) {
|
|
final container = containers.firstWhere(
|
|
(c) => c.id == containerId,
|
|
orElse: () => ContainerModel(
|
|
id: containerId,
|
|
name: 'Conteneur inconnu',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
updatedAt: DateTime.now(),
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
_containerCache[containerId] = container;
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EventPreparationPage] Error', e);
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
/// Basculer l'état de validation d'un équipement (état local uniquement)
|
|
Future<void> _toggleEquipmentValidation(String equipmentId) async {
|
|
final currentState = _localValidationState[equipmentId] ?? false;
|
|
|
|
// Si on veut valider (passer de false à true) et que c'était manquant avant
|
|
if (!currentState && _wasMissingAtPreviousStep(equipmentId)) {
|
|
final confirmed = await _confirmValidationIfWasMissingBefore(equipmentId);
|
|
if (!confirmed) {
|
|
return; // Annulation, ne rien faire
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
_localValidationState[equipmentId] = !currentState;
|
|
});
|
|
}
|
|
|
|
/// Marquer TOUT comme validé et enregistrer (bouton "Tout confirmer")
|
|
Future<void> _validateAllAndConfirm() async {
|
|
// Marquer tout comme validé localement
|
|
setState(() {
|
|
for (var eq in _currentEvent.assignedEquipment) {
|
|
_localValidationState[eq.equipmentId] = true;
|
|
}
|
|
});
|
|
|
|
// Puis enregistrer
|
|
await _confirmCurrentState();
|
|
}
|
|
|
|
/// Enregistrer l'état actuel TEL QUEL (cochés = validés, non cochés = manquants)
|
|
Future<void> _confirmCurrentState() async {
|
|
setState(() => _isValidating = true);
|
|
|
|
try {
|
|
// Déterminer les manquants = équipements NON validés
|
|
final Map<String, bool> missingAtThisStep = {};
|
|
for (var eq in _currentEvent.assignedEquipment) {
|
|
final isValidated = _localValidationState[eq.equipmentId] ?? false;
|
|
missingAtThisStep[eq.equipmentId] = !isValidated; // Manquant si pas validé
|
|
}
|
|
|
|
// Préparer la liste des équipements avec leur nouvel état
|
|
final updatedEquipment = _currentEvent.assignedEquipment.map((eq) {
|
|
final isValidated = _localValidationState[eq.equipmentId] ?? false;
|
|
final isMissing = missingAtThisStep[eq.equipmentId] ?? false;
|
|
|
|
// Récupérer les quantités selon l'étape
|
|
final qtyAtPrep = _quantitiesAtPreparation[eq.equipmentId];
|
|
final qtyAtLoad = _quantitiesAtLoading[eq.equipmentId];
|
|
final qtyAtUnload = _quantitiesAtUnloading[eq.equipmentId];
|
|
final qtyAtRet = _quantitiesAtReturn[eq.equipmentId];
|
|
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
if (_loadSimultaneously) {
|
|
return eq.copyWith(
|
|
isPrepared: isValidated,
|
|
isLoaded: isValidated,
|
|
isMissingAtPreparation: isMissing,
|
|
isMissingAtLoading: isMissing, // Propager
|
|
quantityAtPreparation: qtyAtPrep,
|
|
quantityAtLoading: qtyAtPrep, // Même quantité
|
|
);
|
|
}
|
|
return eq.copyWith(
|
|
isPrepared: isValidated,
|
|
isMissingAtPreparation: isMissing,
|
|
quantityAtPreparation: qtyAtPrep,
|
|
);
|
|
|
|
case PreparationStep.loadingOutbound:
|
|
return eq.copyWith(
|
|
isLoaded: isValidated,
|
|
isMissingAtLoading: isMissing,
|
|
quantityAtLoading: qtyAtLoad,
|
|
);
|
|
|
|
case PreparationStep.unloadingReturn:
|
|
if (_loadSimultaneously) {
|
|
return eq.copyWith(
|
|
isUnloaded: isValidated,
|
|
isReturned: isValidated,
|
|
isMissingAtUnloading: isMissing,
|
|
isMissingAtReturn: isMissing, // Propager
|
|
quantityAtUnloading: qtyAtUnload,
|
|
quantityAtReturn: qtyAtRet ?? qtyAtUnload,
|
|
);
|
|
}
|
|
return eq.copyWith(
|
|
isUnloaded: isValidated,
|
|
isMissingAtUnloading: isMissing,
|
|
quantityAtUnloading: qtyAtUnload,
|
|
);
|
|
|
|
case PreparationStep.return_:
|
|
return eq.copyWith(
|
|
isReturned: isValidated,
|
|
isMissingAtReturn: isMissing,
|
|
quantityAtReturn: qtyAtRet,
|
|
);
|
|
}
|
|
}).toList();
|
|
|
|
// Mettre à jour Firestore selon l'étape
|
|
final updateData = <String, dynamic>{
|
|
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
};
|
|
|
|
// Ajouter les statuts selon l'étape et la checkbox
|
|
String validationType = 'CHECK';
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
|
|
validationType = 'CHECK_OUT';
|
|
if (_loadSimultaneously) {
|
|
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
|
validationType = 'LOADING';
|
|
}
|
|
break;
|
|
|
|
case PreparationStep.loadingOutbound:
|
|
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
|
validationType = 'LOADING';
|
|
break;
|
|
|
|
case PreparationStep.unloadingReturn:
|
|
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
|
|
validationType = 'UNLOADING';
|
|
if (_loadSimultaneously) {
|
|
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
|
validationType = 'CHECK_IN';
|
|
}
|
|
break;
|
|
|
|
case PreparationStep.return_:
|
|
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
|
validationType = 'CHECK_IN';
|
|
break;
|
|
}
|
|
|
|
// Sauvegarder dans Firestore via l'API
|
|
await _dataService.updateEventEquipment(
|
|
eventId: _currentEvent.id,
|
|
assignedEquipment: updatedEquipment.map((e) => e.toMap()).toList(),
|
|
preparationStatus: updateData['preparationStatus'],
|
|
loadingStatus: updateData['loadingStatus'],
|
|
unloadingStatus: updateData['unloadingStatus'],
|
|
returnStatus: updateData['returnStatus'],
|
|
);
|
|
|
|
// Mettre à jour les statuts des équipements si nécessaire
|
|
if (_currentStep == PreparationStep.preparation ||
|
|
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
|
|
await _updateEquipmentStatuses(updatedEquipment);
|
|
}
|
|
|
|
// NOUVEAU: Appeler la Cloud Function pour traiter la validation
|
|
// et créer les alertes automatiquement
|
|
try {
|
|
DebugLog.info('[EventPreparationPage] Appel processEquipmentValidation');
|
|
|
|
final equipmentList = updatedEquipment.map((eq) {
|
|
final equipment = _equipmentCache[eq.equipmentId];
|
|
return {
|
|
'equipmentId': eq.equipmentId,
|
|
'name': equipment?.name ?? 'Équipement inconnu',
|
|
'status': _determineEquipmentStatus(eq),
|
|
'quantity': _getQuantityForStep(eq),
|
|
'expectedQuantity': eq.quantity,
|
|
'isMissingAtPreparation': eq.isMissingAtPreparation,
|
|
'isMissingAtReturn': eq.isMissingAtReturn,
|
|
};
|
|
}).toList();
|
|
|
|
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
|
|
.httpsCallable('processEquipmentValidation')
|
|
.call({
|
|
'eventId': _currentEvent.id,
|
|
'equipmentList': equipmentList,
|
|
'validationType': validationType,
|
|
});
|
|
|
|
final alertsCreated = result.data['alertsCreated'] ?? 0;
|
|
if (alertsCreated > 0) {
|
|
DebugLog.info('[EventPreparationPage] $alertsCreated alertes créées automatiquement');
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EventPreparationPage] Erreur appel processEquipmentValidation', e);
|
|
// Ne pas bloquer la validation si les alertes échouent
|
|
}
|
|
|
|
// Recharger l'événement depuis le provider
|
|
final eventProvider = context.read<EventProvider>();
|
|
// Recharger la liste des événements pour rafraîchir les données
|
|
final userId = context.read<LocalUserProvider>().uid;
|
|
if (userId != null) {
|
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: true);
|
|
}
|
|
|
|
setState(() => _showSuccessAnimation = true);
|
|
_animationController.forward();
|
|
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(_getSuccessMessage()),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur : $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isValidating = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
|
|
for (var eq in equipment) {
|
|
try {
|
|
final equipmentData = _equipmentCache[eq.equipmentId];
|
|
if (equipmentData == null) continue;
|
|
|
|
// Déterminer le nouveau statut
|
|
EquipmentStatus newStatus;
|
|
if (eq.isReturned) {
|
|
newStatus = EquipmentStatus.available;
|
|
} else if (eq.isPrepared || eq.isLoaded) {
|
|
newStatus = EquipmentStatus.inUse;
|
|
} else {
|
|
continue; // Pas de changement
|
|
}
|
|
|
|
// Ne mettre à jour que les équipements non quantifiables
|
|
if (!equipmentData.hasQuantity) {
|
|
await _dataService.updateEquipmentStatusOnly(
|
|
equipmentId: eq.equipmentId,
|
|
status: equipmentStatusToString(newStatus),
|
|
);
|
|
}
|
|
|
|
// Gérer les stocks pour les consommables
|
|
if (equipmentData.hasQuantity && eq.isReturned && eq.quantityAtReturn != null) {
|
|
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
await _dataService.updateEquipmentStatusOnly(
|
|
equipmentId: eq.equipmentId,
|
|
availableQuantity: currentAvailable + eq.quantityAtReturn!,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// Erreur silencieuse pour ne pas bloquer le processus
|
|
}
|
|
}
|
|
}
|
|
|
|
String _getSuccessMessage() {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return _loadSimultaneously
|
|
? 'Préparation dépôt et chargement aller validés !'
|
|
: 'Préparation dépôt validée !';
|
|
case PreparationStep.loadingOutbound:
|
|
return 'Chargement aller validé !';
|
|
case PreparationStep.unloadingReturn:
|
|
return _loadSimultaneously
|
|
? 'Chargement retour et retour dépôt validés !'
|
|
: 'Chargement retour validé !';
|
|
case PreparationStep.return_:
|
|
return 'Retour dépôt validé !';
|
|
}
|
|
}
|
|
|
|
String _getStepTitle() {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return 'Préparation dépôt';
|
|
case PreparationStep.loadingOutbound:
|
|
return 'Chargement aller';
|
|
case PreparationStep.unloadingReturn:
|
|
return 'Chargement retour';
|
|
case PreparationStep.return_:
|
|
return 'Retour dépôt';
|
|
}
|
|
}
|
|
|
|
String _getValidateAllButtonText() {
|
|
if (_loadSimultaneously) {
|
|
return _currentStep == PreparationStep.preparation
|
|
? 'Tout confirmer comme préparé et chargé'
|
|
: 'Tout confirmer comme déchargé et retourné';
|
|
}
|
|
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return 'Tout confirmer comme préparé';
|
|
case PreparationStep.loadingOutbound:
|
|
return 'Tout confirmer comme chargé';
|
|
case PreparationStep.unloadingReturn:
|
|
return 'Tout confirmer comme déchargé';
|
|
case PreparationStep.return_:
|
|
return 'Tout confirmer comme retourné';
|
|
}
|
|
}
|
|
|
|
bool _isStepCompleted() {
|
|
return _currentEvent.assignedEquipment.every((eq) {
|
|
return _localValidationState[eq.equipmentId] ?? false;
|
|
});
|
|
}
|
|
|
|
double _getProgress() {
|
|
if (_currentEvent.assignedEquipment.isEmpty) return 0;
|
|
return _getValidatedCount() / _currentEvent.assignedEquipment.length;
|
|
}
|
|
|
|
int _getValidatedCount() {
|
|
return _currentEvent.assignedEquipment.where((eq) {
|
|
return _localValidationState[eq.equipmentId] ?? false;
|
|
}).length;
|
|
}
|
|
|
|
ChecklistStep _getChecklistStep() {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return ChecklistStep.preparation;
|
|
case PreparationStep.loadingOutbound:
|
|
return ChecklistStep.loading;
|
|
case PreparationStep.unloadingReturn:
|
|
return ChecklistStep.unloading;
|
|
case PreparationStep.return_:
|
|
return ChecklistStep.return_;
|
|
}
|
|
}
|
|
|
|
Future<void> _confirm() async {
|
|
// Vérifier s'il y a des équipements manquants (non cochés localement)
|
|
final missingEquipmentIds = _currentEvent.assignedEquipment
|
|
.where((eq) => !(_localValidationState[eq.equipmentId] ?? false))
|
|
.map((eq) => eq.equipmentId)
|
|
.toList();
|
|
|
|
if (missingEquipmentIds.isEmpty) {
|
|
// Tout est validé, confirmer directement
|
|
await _confirmCurrentState();
|
|
} else {
|
|
// Afficher le dialog des manquants
|
|
final missingEquipmentModels = missingEquipmentIds
|
|
.map((id) => _equipmentCache[id])
|
|
.whereType<EquipmentModel>()
|
|
.toList();
|
|
|
|
final missingEventEquipment = _currentEvent.assignedEquipment
|
|
.where((eq) => missingEquipmentIds.contains(eq.equipmentId))
|
|
.toList();
|
|
|
|
final action = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => MissingEquipmentDialog(
|
|
missingEquipment: missingEquipmentModels,
|
|
eventEquipment: missingEventEquipment,
|
|
isReturnMode: _currentStep == PreparationStep.return_ ||
|
|
_currentStep == PreparationStep.unloadingReturn,
|
|
),
|
|
);
|
|
|
|
if (action == 'confirm_anyway') {
|
|
// Confirmer malgré les manquants (enregistrer l'état actuel TEL QUEL)
|
|
await _confirmCurrentState();
|
|
} else if (action == 'mark_as_validated') {
|
|
// Marquer les manquants comme validés localement
|
|
for (var equipmentId in missingEquipmentIds) {
|
|
_localValidationState[equipmentId] = true;
|
|
}
|
|
setState(() {});
|
|
// Puis confirmer
|
|
await _confirmCurrentState();
|
|
}
|
|
// Si 'return_to_list', ne rien faire
|
|
}
|
|
}
|
|
|
|
/// Valider tous les enfants d'un container
|
|
void _validateAllContainerChildren(String containerId) {
|
|
final container = _containerCache[containerId];
|
|
if (container == null) return;
|
|
|
|
setState(() {
|
|
for (final equipmentId in container.equipmentIds) {
|
|
if (_equipmentCache.containsKey(equipmentId)) {
|
|
_localValidationState[equipmentId] = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Mettre à jour la quantité d'un équipement à l'étape actuelle
|
|
void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
|
|
setState(() {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
_quantitiesAtPreparation[equipmentId] = newQuantity;
|
|
break;
|
|
case PreparationStep.loadingOutbound:
|
|
_quantitiesAtLoading[equipmentId] = newQuantity;
|
|
break;
|
|
case PreparationStep.unloadingReturn:
|
|
_quantitiesAtUnloading[equipmentId] = newQuantity;
|
|
break;
|
|
case PreparationStep.return_:
|
|
_quantitiesAtReturn[equipmentId] = newQuantity;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Vérifier si un équipement était manquant à l'étape précédente
|
|
bool _wasMissingAtPreviousStep(String equipmentId) {
|
|
final eq = _currentEvent.assignedEquipment.firstWhere(
|
|
(e) => e.equipmentId == equipmentId,
|
|
orElse: () => EventEquipment(equipmentId: equipmentId),
|
|
);
|
|
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return false; // Pas d'étape avant
|
|
case PreparationStep.loadingOutbound:
|
|
return eq.isMissingAtPreparation;
|
|
case PreparationStep.unloadingReturn:
|
|
return eq.isMissingAtLoading;
|
|
case PreparationStep.return_:
|
|
return eq.isMissingAtUnloading;
|
|
}
|
|
}
|
|
|
|
/// Afficher pop-up de confirmation si l'équipement était manquant avant
|
|
Future<bool> _confirmValidationIfWasMissingBefore(String equipmentId) async {
|
|
if (!_wasMissingAtPreviousStep(equipmentId)) {
|
|
return true; // Pas de problème, continuer
|
|
}
|
|
|
|
final equipment = _equipmentCache[equipmentId];
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.warning_amber, color: Colors.orange),
|
|
SizedBox(width: 8),
|
|
Text('Confirmation'),
|
|
],
|
|
),
|
|
content: Text(
|
|
'L\'équipement "${equipment?.name ?? equipmentId}" était manquant à l\'étape précédente.\n\n'
|
|
'Êtes-vous sûr de le marquer comme présent maintenant ?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
child: const Text('Confirmer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
return result ?? false;
|
|
}
|
|
|
|
/// Détermine le statut d'un équipement selon l'étape actuelle
|
|
String _determineEquipmentStatus(EventEquipment eq) {
|
|
// Vérifier d'abord si l'équipement est perdu (LOST)
|
|
if (_shouldMarkAsLost(eq)) {
|
|
return 'LOST';
|
|
}
|
|
|
|
// Vérifier si manquant à l'étape actuelle
|
|
if (_isMissingAtCurrentStep(eq)) {
|
|
return 'MISSING';
|
|
}
|
|
|
|
// Vérifier les quantités
|
|
final currentQty = _getQuantityForStep(eq);
|
|
if (currentQty != null && currentQty < eq.quantity) {
|
|
return 'QUANTITY_MISMATCH';
|
|
}
|
|
|
|
return 'AVAILABLE';
|
|
}
|
|
|
|
/// Vérifie si un équipement doit être marqué comme LOST
|
|
bool _shouldMarkAsLost(EventEquipment eq) {
|
|
// Seulement aux étapes de retour
|
|
if (_currentStep != PreparationStep.return_ &&
|
|
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
|
|
return false;
|
|
}
|
|
|
|
// Si manquant maintenant mais PAS manquant à la préparation = LOST
|
|
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
|
|
}
|
|
|
|
/// Vérifie si un équipement est manquant à l'étape actuelle
|
|
bool _isMissingAtCurrentStep(EventEquipment eq) {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return eq.isMissingAtPreparation;
|
|
case PreparationStep.loadingOutbound:
|
|
return eq.isMissingAtLoading;
|
|
case PreparationStep.unloadingReturn:
|
|
return eq.isMissingAtUnloading;
|
|
case PreparationStep.return_:
|
|
return eq.isMissingAtReturn;
|
|
}
|
|
}
|
|
|
|
/// Récupère la quantité pour l'étape actuelle
|
|
int? _getQuantityForStep(EventEquipment eq) {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return eq.quantityAtPreparation;
|
|
case PreparationStep.loadingOutbound:
|
|
return eq.quantityAtLoading;
|
|
case PreparationStep.unloadingReturn:
|
|
return eq.quantityAtUnloading;
|
|
case PreparationStep.return_:
|
|
return eq.quantityAtReturn;
|
|
}
|
|
}
|
|
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final allValidated = _isStepCompleted();
|
|
final stepTitle = _getStepTitle();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(stepTitle),
|
|
backgroundColor: AppColors.bleuFonce,
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
_isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
color: Colors.grey.shade100,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
_currentEvent.name,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: LinearProgressIndicator(
|
|
value: _getProgress(),
|
|
backgroundColor: Colors.grey.shade300,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
allValidated ? Colors.green : AppColors.bleuFonce,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Checkbox "charger en même temps" (uniquement pour préparation ou chargement retour)
|
|
if (_currentStep == PreparationStep.preparation ||
|
|
_currentStep == PreparationStep.unloadingReturn)
|
|
CheckboxListTile(
|
|
value: _loadSimultaneously,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_loadSimultaneously = value ?? false;
|
|
});
|
|
},
|
|
title: Text(
|
|
_currentStep == PreparationStep.preparation
|
|
? 'Charger en même temps (chargement aller)'
|
|
: 'Confirmer le retour en même temps (retour dépôt)',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
ElevatedButton.icon(
|
|
onPressed: allValidated ? null : _validateAllAndConfirm,
|
|
icon: const Icon(Icons.check_circle_outline),
|
|
label: Text(
|
|
allValidated
|
|
? 'Tout est validé !'
|
|
: _getValidateAllButtonText(),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: allValidated ? Colors.green : AppColors.bleuFonce,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: _buildChecklistItems(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_showSuccessAnimation)
|
|
Center(
|
|
child: ScaleTransition(
|
|
scale: _animationController,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(32),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.green,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.check,
|
|
size: 64,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
bottomNavigationBar: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: ElevatedButton(
|
|
onPressed: _isValidating ? null : _confirm,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: allValidated ? Colors.green : AppColors.rouge,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: _isValidating
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: Text(
|
|
'Confirmer ${_getStepTitle().toLowerCase()}',
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit la liste des items de checklist en groupant par containers
|
|
List<Widget> _buildChecklistItems() {
|
|
final List<Widget> items = [];
|
|
|
|
// Set pour tracker les équipements déjà affichés dans un container
|
|
final Set<String> equipmentIdsInContainers = {};
|
|
|
|
// Map des EventEquipment par ID pour accès rapide
|
|
final Map<String, EventEquipment> eventEquipmentsMap = {
|
|
for (var eq in _currentEvent.assignedEquipment) eq.equipmentId: eq,
|
|
};
|
|
|
|
// 1. Afficher les containers avec leurs enfants
|
|
for (final containerId in _currentEvent.assignedContainers) {
|
|
final container = _containerCache[containerId];
|
|
if (container == null) continue;
|
|
|
|
// Récupérer les équipements enfants de ce container
|
|
final List<EquipmentModel> childEquipments = [];
|
|
for (final equipmentId in container.equipmentIds) {
|
|
final equipment = _equipmentCache[equipmentId];
|
|
if (equipment != null && eventEquipmentsMap.containsKey(equipmentId)) {
|
|
childEquipments.add(equipment);
|
|
equipmentIdsInContainers.add(equipmentId);
|
|
}
|
|
}
|
|
|
|
if (childEquipments.isEmpty) continue;
|
|
|
|
// Vérifier si tous les enfants sont validés
|
|
final allChildrenValidated = childEquipments.every(
|
|
(eq) => _localValidationState[eq.id] ?? false,
|
|
);
|
|
|
|
// Map des états de validation des enfants
|
|
final Map<String, bool> childValidationStates = {
|
|
for (var eq in childEquipments) eq.id: _localValidationState[eq.id] ?? false,
|
|
};
|
|
|
|
// Map des enfants manquants à l'étape précédente
|
|
final Map<String, bool> wasMissingBeforeMap = {
|
|
for (var eq in childEquipments) eq.id: _wasMissingAtPreviousStep(eq.id),
|
|
};
|
|
|
|
items.add(
|
|
ContainerChecklistItem(
|
|
container: container,
|
|
childEquipments: childEquipments,
|
|
eventEquipmentsMap: eventEquipmentsMap,
|
|
step: _getChecklistStep(),
|
|
isValidated: allChildrenValidated,
|
|
childValidationStates: childValidationStates,
|
|
onToggleContainer: () => _validateAllContainerChildren(containerId),
|
|
onToggleChild: (equipmentId) => _toggleEquipmentValidation(equipmentId),
|
|
onQuantityChanged: (equipmentId, qty) => _updateEquipmentQuantity(equipmentId, qty),
|
|
wasMissingBeforeMap: wasMissingBeforeMap,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2. Afficher les équipements standalone (pas dans un container)
|
|
for (final eventEquipment in _currentEvent.assignedEquipment) {
|
|
// Skip si déjà affiché dans un container
|
|
if (equipmentIdsInContainers.contains(eventEquipment.equipmentId)) {
|
|
continue;
|
|
}
|
|
|
|
final equipment = _equipmentCache[eventEquipment.equipmentId];
|
|
if (equipment == null) continue;
|
|
|
|
items.add(
|
|
EquipmentChecklistItem(
|
|
equipment: equipment,
|
|
eventEquipment: eventEquipment,
|
|
step: _getChecklistStep(),
|
|
isValidated: _localValidationState[equipment.id] ?? false,
|
|
onToggle: () => _toggleEquipmentValidation(equipment.id),
|
|
onQuantityChanged: (qty) => _updateEquipmentQuantity(equipment.id, qty),
|
|
isChild: false, // Équipement standalone (pas indenté)
|
|
wasMissingBefore: _wasMissingAtPreviousStep(equipment.id),
|
|
),
|
|
);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
}
|
|
|