1573 lines
57 KiB
Dart
1573 lines
57 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/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/smart_text_to_speech_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';
|
|
import 'package:em2rp/views/widgets/common/audio_diagnostic_button.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;
|
|
|
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
|
final Map<String, ContainerModel> _containerCache = {};
|
|
final Map<String, int> _returnedQuantities = {};
|
|
|
|
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
|
final Map<String, bool> _localValidationState = {};
|
|
|
|
// Gestion des quantités par étape
|
|
final Map<String, int> _quantitiesAtPreparation = {};
|
|
final Map<String, int> _quantitiesAtLoading = {};
|
|
final Map<String, int> _quantitiesAtUnloading = {};
|
|
final 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();
|
|
|
|
// 🆕 File d'attente pour traiter les scans séquentiellement
|
|
final List<String> _scanQueue = [];
|
|
bool _isProcessingQueue = false;
|
|
|
|
// 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),
|
|
);
|
|
|
|
// Initialiser le service de synthèse vocale hybride
|
|
SmartTextToSpeechService.initialize();
|
|
|
|
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
|
|
AudioFeedbackService.unlockAudio();
|
|
|
|
// 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();
|
|
SmartTextToSpeechService.stop();
|
|
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: 'europe-west9')
|
|
.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 uniquement cet événement depuis l'API pour obtenir les dernières données
|
|
try {
|
|
final result = await _dataService.getEventWithDetails(_currentEvent.id);
|
|
final eventData = result['event'] as Map<String, dynamic>;
|
|
final updatedEvent = EventModel.fromMap(eventData, eventData['id'] as String);
|
|
|
|
// Mettre à jour dans le cache
|
|
await eventProvider.updateEvent(updatedEvent);
|
|
} catch (e) {
|
|
DebugLog.error('[EventPreparationPage] Erreur lors du rechargement de l\'événement', e);
|
|
}
|
|
|
|
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');
|
|
|
|
// 🗣️ Annoncer le prochain item après un court délai
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
await _announceNextItem();
|
|
|
|
} else if (result.codeNotFoundInEvent) {
|
|
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
|
|
// 🔊 Son d'erreur
|
|
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
|
|
|
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;
|
|
|
|
// Ajouter le code à la file d'attente
|
|
_scanQueue.add(code.trim());
|
|
|
|
// Effacer le champ immédiatement pour permettre le prochain scan
|
|
_manualCodeController.clear();
|
|
|
|
// Maintenir le focus sur le champ pour permettre une saisie continue
|
|
_manualCodeFocusNode.requestFocus();
|
|
|
|
// Démarrer le traitement de la file si pas déjà en cours
|
|
if (!_isProcessingQueue) {
|
|
_processQueue();
|
|
}
|
|
}
|
|
|
|
/// Traite la file d'attente des scans un par un
|
|
Future<void> _processQueue() async {
|
|
if (_isProcessingQueue) return;
|
|
|
|
_isProcessingQueue = true;
|
|
|
|
while (_scanQueue.isNotEmpty) {
|
|
final code = _scanQueue.removeAt(0);
|
|
await _handleScannedCode(code);
|
|
}
|
|
|
|
_isProcessingQueue = false;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
/// Trouve le prochain item non validé à scanner
|
|
String? _findNextItemToScan() {
|
|
// Parcourir les items dans l'ordre et trouver le premier non validé
|
|
|
|
// 1. Parcourir les containers et leurs équipements
|
|
for (final containerId in _currentEvent.assignedContainers) {
|
|
final container = _containerCache[containerId];
|
|
if (container == null) continue;
|
|
|
|
// Vérifier si le container a des équipements non validés
|
|
bool hasUnvalidatedChild = false;
|
|
for (final equipmentId in container.equipmentIds) {
|
|
|
|
if (_currentEvent.assignedEquipment.any((e) => e.equipmentId == equipmentId)) {
|
|
final isValidated = _localValidationState[equipmentId] ?? false;
|
|
if (!isValidated) {
|
|
hasUnvalidatedChild = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Si le container a des items non validés, retourner le nom du container
|
|
if (hasUnvalidatedChild) {
|
|
return container.name;
|
|
}
|
|
}
|
|
|
|
// 2. Parcourir les équipements standalone (pas dans un container)
|
|
final Set<String> equipmentIdsInContainers = {};
|
|
for (final containerId in _currentEvent.assignedContainers) {
|
|
final container = _containerCache[containerId];
|
|
if (container != null) {
|
|
equipmentIdsInContainers.addAll(container.equipmentIds);
|
|
}
|
|
}
|
|
|
|
for (final eventEquipment in _currentEvent.assignedEquipment) {
|
|
if (equipmentIdsInContainers.contains(eventEquipment.equipmentId)) {
|
|
continue;
|
|
}
|
|
|
|
final isValidated = _localValidationState[eventEquipment.equipmentId] ?? false;
|
|
if (!isValidated) {
|
|
final equipment = _equipmentCache[eventEquipment.equipmentId];
|
|
return equipment?.name ?? 'Équipement ${eventEquipment.equipmentId}';
|
|
}
|
|
}
|
|
|
|
return null; // Tout est validé
|
|
}
|
|
|
|
/// Annonce vocalement le prochain item à scanner
|
|
Future<void> _announceNextItem() async {
|
|
final nextItem = _findNextItemToScan();
|
|
if (nextItem != null) {
|
|
await SmartTextToSpeechService.speak('Prochain item: $nextItem');
|
|
} else {
|
|
await SmartTextToSpeechService.speak('Tous les items sont validés');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final allValidated = _isStepCompleted();
|
|
final stepTitle = _getStepTitle();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(stepTitle),
|
|
backgroundColor: AppColors.bleuFonce,
|
|
actions: const [
|
|
AudioDiagnosticButton(),
|
|
],
|
|
),
|
|
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: [
|
|
// Nom de l'événement et barre de progression sur la même ligne
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_currentEvent.name,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: LinearProgressIndicator(
|
|
value: _getProgress(),
|
|
backgroundColor: Colors.grey.shade300,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
allValidated ? Colors.green : AppColors.bleuFonce,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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 avec bouton scanner
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _manualCodeController,
|
|
focusNode: _manualCodeFocusNode,
|
|
decoration: InputDecoration(
|
|
labelText: 'Saisie manuelle d\'un code',
|
|
hintText: '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: 12, vertical: 12),
|
|
),
|
|
onSubmitted: _handleManualCodeEntry,
|
|
onChanged: (value) => setState(() {}),
|
|
textInputAction: TextInputAction.done,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// IconButton pour scanner QR Code
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue[700],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: IconButton(
|
|
onPressed: _openQRScanner,
|
|
icon: const Icon(Icons.qr_code_scanner, color: Colors.white),
|
|
iconSize: 28,
|
|
tooltip: 'Scanner QR Code',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
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: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
// Afficher 2 colonnes si la largeur le permet (> 600px)
|
|
final useColumns = constraints.maxWidth > 600;
|
|
final items = _buildChecklistItems();
|
|
|
|
if (useColumns && items.length > 1) {
|
|
// Diviser en 2 colonnes
|
|
final mid = (items.length / 2).ceil();
|
|
final leftItems = items.sublist(0, mid);
|
|
final rightItems = items.sublist(mid);
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: leftItems,
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: rightItems,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
} else {
|
|
// Une seule colonne
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: items,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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;
|
|
}
|
|
}
|
|
|