Cette mise à jour majeure introduit une fonctionnalité de scan et de saisie manuelle de codes QR directement depuis la page de préparation d'un événement. Ce système accélère et fiabilise le processus de validation des équipements et des containers pour chaque étape (préparation, chargement, etc.), tout en ajoutant des retours sonores, haptiques et visuels pour une expérience utilisateur améliorée.
**Fonctionnalités et améliorations principales :**
- **Scan et saisie manuelle en préparation d'événement :**
- Ajout d'un champ de "Saisie manuelle" et d'un bouton "Scanner QR Code" sur la page de préparation (`EventPreparationPage`).
- Le scanner peut fonctionner en mode "multi-scan", permettant de valider plusieurs éléments à la suite sans fermer la caméra.
- Le système gère à la fois les équipements individuels et les containers (qui valident automatiquement tout leur contenu).
- **Logique de traitement intelligente (`QRCodeProcessingService`) :**
- Un nouveau service centralise la logique de traitement des codes.
- Pour les équipements quantitatifs, chaque scan incrémente la quantité jusqu'à atteindre la cible requise pour l'étape en cours.
- Pour les équipements non quantitatifs, le premier scan valide l'élément.
- Les scans multiples d'un élément déjà validé ou dont la quantité est atteinte génèrent une erreur.
- **Ajout dynamique d'équipements :**
- Si un code scanné n'est pas assigné à l'événement, une boîte de dialogue propose de rechercher l'équipement ou le container dans la base de données et de l'ajouter à l'événement en cours.
- **Feedbacks utilisateur :**
- Création d'un `AudioFeedbackService` pour fournir des retours sonores (succès/erreur) et haptiques (vibration) lors de chaque scan.
- Des `Snackbars` claires (vertes pour succès, orange pour erreur) informent l'utilisateur du résultat de chaque action.
- **Optimisation du chargement des données :**
- Nouvel endpoint backend `getEventWithDetails` qui charge un événement et toutes ses dépendances (équipements, containers et leurs enfants) en un seul appel, optimisant drastiquement les temps de chargement des pages de préparation et de modification d'événement.
- Le frontend (`EventPreparationPage`, `EventAssignedEquipmentSection`) utilise ce nouvel endpoint, éliminant les chargements multiples et fiabilisant l'affichage des données.
**Refactorisation et corrections :**
- **Structure du code :**
- La logique de traitement des codes est extraite dans le `QRCodeProcessingService`.
- Création de widgets dédiés (`CodeNotFoundDialog`, `AddEquipmentToEventDialog`) pour gérer les nouveaux flux utilisateurs.
- **Fiabilisation de l'état :**
- Mise à jour optimiste de l'UI lors du changement de statut d'un événement (`EventStatusButton`) pour une meilleure réactivité.
- Correction d'un bug dans la suppression d'un container d'un événement, qui pouvait retirer des équipements partagés avec d'autres containers.
- Correction d'un bug lors de l'ajout d'un container à un événement, qui n'ajoutait pas automatiquement ses équipements enfants.
- **Optimisations des performances UI :**
- Amélioration de la fluidité du défilement infini sur la page de gestion des équipements grâce à `RepaintBoundary` et à une gestion optimisée du chargement.
**Déploiement et version :**
- **Scripts de déploiement :** Ajout d'un script PowerShell (`deploy_hosting.ps1`) et amélioration du script Node.js pour automatiser et fiabiliser les déploiements sur Firebase Hosting.
- **Configuration CORS :** Les en-têtes CORS sont désormais configurés pour `version.json`, assurant le bon fonctionnement du mécanisme de mise à jour de l'application.
- **Version de l'application :** Incrémentée à `1.0.6`.
1407 lines
50 KiB
Dart
1407 lines
50 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:cloud_functions/cloud_functions.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/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/services/qr_code_processing_service.dart';
|
|
import 'package:em2rp/services/audio_feedback_service.dart';
|
|
import 'package:em2rp/services/equipment_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/views/widgets/common/qr_code_scanner_dialog.dart';
|
|
import 'package:em2rp/views/widgets/event_preparation/code_not_found_dialog.dart';
|
|
import 'package:em2rp/views/widgets/event_preparation/add_equipment_to_event_dialog.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;
|
|
late final QRCodeProcessingService _qrCodeService;
|
|
|
|
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 = {};
|
|
|
|
// 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;
|
|
|
|
// 🆕 Pour la saisie manuelle de codes
|
|
final TextEditingController _manualCodeController = TextEditingController();
|
|
final FocusNode _manualCodeFocusNode = FocusNode();
|
|
|
|
// 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());
|
|
_qrCodeService = QRCodeProcessingService();
|
|
_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();
|
|
_manualCodeController.dispose();
|
|
_manualCodeFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadEquipmentAndContainers() async {
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
// 🔧 FIX: Utiliser getEventWithDetails pour charger toutes les données d'un coup
|
|
DebugLog.info('[EventPreparationPage] Loading event with details: ${_currentEvent.id}');
|
|
|
|
final result = await _dataService.getEventWithDetails(_currentEvent.id);
|
|
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
|
|
final containersMap = result['containers'] as Map<String, dynamic>;
|
|
|
|
DebugLog.info('[EventPreparationPage] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
|
|
|
|
// Remplir les caches
|
|
_equipmentCache.clear();
|
|
_containerCache.clear();
|
|
|
|
// Remplir le cache d'équipements
|
|
equipmentsMap.forEach((id, data) {
|
|
try {
|
|
final equipment = EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
|
_equipmentCache[id] = equipment;
|
|
} catch (e) {
|
|
DebugLog.error('[EventPreparationPage] Error parsing equipment $id', e);
|
|
}
|
|
});
|
|
|
|
// Remplir le cache de containers
|
|
containersMap.forEach((id, data) {
|
|
try {
|
|
final container = ContainerModel.fromMap(data as Map<String, dynamic>, id);
|
|
_containerCache[id] = container;
|
|
} catch (e) {
|
|
DebugLog.error('[EventPreparationPage] Error parsing container $id', e);
|
|
}
|
|
});
|
|
|
|
// Initialiser les états de validation et quantités pour chaque équipement assigné
|
|
for (var eq in _currentEvent.assignedEquipment) {
|
|
final equipmentItem = _equipmentCache[eq.equipmentId];
|
|
|
|
// S'assurer que l'équipement est dans le cache (même si inconnu)
|
|
if (equipmentItem == null) {
|
|
_equipmentCache[eq.equipmentId] = EquipmentModel(
|
|
id: eq.equipmentId,
|
|
name: 'Équipement inconnu',
|
|
category: EquipmentCategory.other,
|
|
status: EquipmentStatus.available,
|
|
maintenanceIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
}
|
|
|
|
// 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 ?? false)) {
|
|
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
|
}
|
|
}
|
|
|
|
// S'assurer que les containers assignés sont dans le cache (même si inconnus)
|
|
for (var containerId in _currentEvent.assignedContainers) {
|
|
if (!_containerCache.containsKey(containerId)) {
|
|
_containerCache[containerId] = ContainerModel(
|
|
id: containerId,
|
|
name: 'Conteneur inconnu',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
updatedAt: DateTime.now(),
|
|
createdAt: DateTime.now(),
|
|
);
|
|
}
|
|
}
|
|
} 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_;
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 🆕 NOUVELLES MÉTHODES POUR LE SCAN QR ET LA SAISIE MANUELLE
|
|
// ========================================================================
|
|
|
|
/// Ouvrir le scanner QR en mode multi-scan
|
|
Future<void> _openQRScanner() async {
|
|
await showDialog(
|
|
context: context,
|
|
builder: (context) => QRCodeScannerDialog(
|
|
multiScanMode: true,
|
|
onCodeScanned: _handleScannedCode,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Traiter un code (scanné ou saisi manuellement)
|
|
Future<void> _handleScannedCode(String code) async {
|
|
final result = await _qrCodeService.processCode(
|
|
code: code.trim(),
|
|
event: _currentEvent,
|
|
step: _currentStep,
|
|
equipmentCache: _equipmentCache,
|
|
containerCache: _containerCache,
|
|
validationState: _localValidationState,
|
|
currentQuantities: _getCurrentQuantitiesMap(),
|
|
);
|
|
|
|
if (result.success) {
|
|
// ✅ Succès : mettre à jour l'état
|
|
setState(() {
|
|
if (result.updatedValidationState != null) {
|
|
_localValidationState.addAll(result.updatedValidationState!);
|
|
}
|
|
if (result.updatedQuantities != null) {
|
|
_updateQuantitiesMap(result.updatedQuantities!);
|
|
}
|
|
});
|
|
|
|
// 🔊 Jouer le feedback sonore et haptique
|
|
await AudioFeedbackService.playFullFeedback(isSuccess: true);
|
|
|
|
// Feedback visuel
|
|
_showSuccessFeedback(result.message ?? 'Code traité avec succès');
|
|
|
|
} else if (result.codeNotFoundInEvent) {
|
|
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
|
|
await _handleCodeNotFoundInEvent(code.trim());
|
|
|
|
} else {
|
|
// ❌ Erreur (ex: quantité déjà atteinte, déjà coché)
|
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
|
_showErrorFeedback(result.message ?? 'Erreur lors du traitement');
|
|
}
|
|
}
|
|
|
|
/// Gérer un code non trouvé dans l'événement
|
|
Future<void> _handleCodeNotFoundInEvent(String code) async {
|
|
// Afficher le dialog de confirmation
|
|
final shouldSearch = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => CodeNotFoundDialog(scannedCode: code),
|
|
);
|
|
|
|
if (shouldSearch != true) return;
|
|
|
|
// Afficher le dialog de chargement
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const AddEquipmentToEventDialog(
|
|
state: AddEquipmentState.loading,
|
|
),
|
|
);
|
|
|
|
try {
|
|
// Identifier le type selon le préfixe
|
|
final isContainer = code.startsWith('BOX_');
|
|
|
|
if (isContainer) {
|
|
await _addContainerToEvent(code);
|
|
} else {
|
|
await _addEquipmentToEvent(code);
|
|
}
|
|
|
|
// 🔊 Bip de succès
|
|
await AudioFeedbackService.playFullFeedback(isSuccess: true);
|
|
|
|
} catch (e) {
|
|
DebugLog.error('[EventPreparationPage] Error adding item to event', e);
|
|
|
|
// Fermer le dialog de chargement et afficher l'erreur
|
|
if (mounted) Navigator.of(context).pop();
|
|
|
|
await showDialog(
|
|
context: context,
|
|
builder: (context) => AddEquipmentToEventDialog(
|
|
state: AddEquipmentState.error,
|
|
errorMessage: e.toString(),
|
|
),
|
|
);
|
|
|
|
// 🔊 Bip d'erreur
|
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
|
}
|
|
}
|
|
|
|
/// Ajouter un équipement à l'événement
|
|
Future<void> _addEquipmentToEvent(String equipmentId) async {
|
|
// Rechercher l'équipement dans la base de données
|
|
final equipmentProvider = context.read<EquipmentProvider>();
|
|
await equipmentProvider.ensureLoaded();
|
|
|
|
// Chercher d'abord dans le cache
|
|
EquipmentModel? equipment = equipmentProvider.allEquipment
|
|
.cast<EquipmentModel?>()
|
|
.firstWhere(
|
|
(eq) => eq?.id == equipmentId,
|
|
orElse: () => null,
|
|
);
|
|
|
|
// Si pas dans le cache, charger depuis Firestore
|
|
if (equipment == null) {
|
|
final equipmentService = EquipmentService();
|
|
equipment = await equipmentService.getEquipmentById(equipmentId);
|
|
}
|
|
|
|
if (equipment == null) {
|
|
throw Exception('Équipement non trouvé dans la base de données');
|
|
}
|
|
|
|
// Ajouter l'équipement à l'événement
|
|
final newEventEquipment = EventEquipment(
|
|
equipmentId: equipmentId,
|
|
quantity: 1,
|
|
);
|
|
|
|
final updatedEquipment = List<EventEquipment>.from(_currentEvent.assignedEquipment)
|
|
..add(newEventEquipment);
|
|
|
|
await _dataService.updateEvent(_currentEvent.id, {
|
|
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
});
|
|
|
|
// Mettre à jour l'état local
|
|
setState(() {
|
|
_currentEvent = _currentEvent.copyWith(
|
|
assignedEquipment: updatedEquipment,
|
|
);
|
|
_equipmentCache[equipmentId] = equipment!;
|
|
_localValidationState[equipmentId] = false;
|
|
});
|
|
|
|
// Fermer le dialog de chargement et afficher le succès
|
|
if (mounted) Navigator.of(context).pop();
|
|
|
|
await showDialog(
|
|
context: context,
|
|
builder: (context) => AddEquipmentToEventDialog(
|
|
state: AddEquipmentState.success,
|
|
itemName: equipment!.name,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Ajouter un container à l'événement
|
|
Future<void> _addContainerToEvent(String containerId) async {
|
|
// Rechercher le container dans la base de données
|
|
final containerProvider = context.read<ContainerProvider>();
|
|
await containerProvider.ensureLoaded();
|
|
|
|
final container = await containerProvider.getContainerById(containerId);
|
|
|
|
if (container == null) {
|
|
throw Exception('Container non trouvé dans la base de données');
|
|
}
|
|
|
|
// Ajouter le container à l'événement
|
|
final updatedContainers = List<String>.from(_currentEvent.assignedContainers)
|
|
..add(containerId);
|
|
|
|
await _dataService.updateEvent(_currentEvent.id, {
|
|
'assignedContainers': updatedContainers,
|
|
});
|
|
|
|
// Mettre à jour l'état local
|
|
setState(() {
|
|
_currentEvent = _currentEvent.copyWith(
|
|
assignedContainers: updatedContainers,
|
|
);
|
|
_containerCache[containerId] = container;
|
|
});
|
|
|
|
// Fermer le dialog de chargement et afficher le succès
|
|
if (mounted) Navigator.of(context).pop();
|
|
|
|
await showDialog(
|
|
context: context,
|
|
builder: (context) => AddEquipmentToEventDialog(
|
|
state: AddEquipmentState.success,
|
|
itemName: 'Container ${container.name}',
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Traiter la saisie manuelle d'un code
|
|
Future<void> _handleManualCodeEntry(String code) async {
|
|
if (code.trim().isEmpty) return;
|
|
|
|
await _handleScannedCode(code.trim());
|
|
|
|
// Effacer le champ après traitement
|
|
_manualCodeController.clear();
|
|
}
|
|
|
|
/// Obtenir les quantités actuelles selon l'étape
|
|
Map<String, int> _getCurrentQuantitiesMap() {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return _quantitiesAtPreparation;
|
|
case PreparationStep.loadingOutbound:
|
|
return _quantitiesAtLoading;
|
|
case PreparationStep.unloadingReturn:
|
|
return _quantitiesAtUnloading;
|
|
case PreparationStep.return_:
|
|
return _quantitiesAtReturn;
|
|
}
|
|
}
|
|
|
|
/// Mettre à jour les quantités selon l'étape
|
|
void _updateQuantitiesMap(Map<String, int> quantities) {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
_quantitiesAtPreparation.addAll(quantities);
|
|
break;
|
|
case PreparationStep.loadingOutbound:
|
|
_quantitiesAtLoading.addAll(quantities);
|
|
break;
|
|
case PreparationStep.unloadingReturn:
|
|
_quantitiesAtUnloading.addAll(quantities);
|
|
break;
|
|
case PreparationStep.return_:
|
|
_quantitiesAtReturn.addAll(quantities);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
|
|
int _getTargetQuantity(EventEquipment eventEquipment) {
|
|
switch (_currentStep) {
|
|
case PreparationStep.preparation:
|
|
return eventEquipment.quantity; // Quantité initiale
|
|
case PreparationStep.loadingOutbound:
|
|
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
|
case PreparationStep.unloadingReturn:
|
|
return eventEquipment.quantityAtLoading ??
|
|
eventEquipment.quantityAtPreparation ??
|
|
eventEquipment.quantity;
|
|
case PreparationStep.return_:
|
|
return eventEquipment.quantityAtUnloading ??
|
|
eventEquipment.quantityAtLoading ??
|
|
eventEquipment.quantityAtPreparation ??
|
|
eventEquipment.quantity;
|
|
}
|
|
}
|
|
|
|
/// Afficher un message de succès
|
|
void _showSuccessFeedback(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.check_circle, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(message)),
|
|
],
|
|
),
|
|
backgroundColor: Colors.green,
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Afficher un message d'erreur
|
|
void _showErrorFeedback(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(message)),
|
|
],
|
|
),
|
|
backgroundColor: Colors.orange,
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// FIN DES NOUVELLES MÉTHODES
|
|
// ========================================================================
|
|
|
|
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,
|
|
),
|
|
|
|
// 🆕 Champ de saisie manuelle de code
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _manualCodeController,
|
|
focusNode: _manualCodeFocusNode,
|
|
decoration: InputDecoration(
|
|
labelText: 'Saisie manuelle d\'un code',
|
|
hintText: 'Entrez un ID d\'équipement ou container',
|
|
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
|
|
suffixIcon: _manualCodeController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_manualCodeController.clear();
|
|
setState(() {});
|
|
},
|
|
)
|
|
: null,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
onSubmitted: _handleManualCodeEntry,
|
|
onChanged: (value) => setState(() {}),
|
|
textInputAction: TextInputAction.done,
|
|
),
|
|
|
|
// 🆕 Bouton Scanner QR Code
|
|
const SizedBox(height: 12),
|
|
ElevatedButton.icon(
|
|
onPressed: _openQRScanner,
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
label: const Text('Scanner QR Code'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.blue[700],
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
),
|
|
|
|
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;
|
|
}
|
|
}
|
|
|