feat: Ajout de la gestion des maintenances et intégration de la synthèse vocale

This commit is contained in:
ElPoyo
2026-02-24 13:39:44 +01:00
parent 506225ac62
commit 890449d5e3
17 changed files with 1731 additions and 107 deletions

View File

@@ -11,6 +11,7 @@ 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/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';
@@ -115,6 +116,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
duration: const Duration(milliseconds: 500),
);
// Initialiser le service de synthèse vocale
TextToSpeechService.initialize();
// Vérification de sécurité et chargement après le premier frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isCurrentStepCompleted()) {
@@ -152,6 +156,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
_animationController.dispose();
_manualCodeController.dispose();
_manualCodeFocusNode.dispose();
TextToSpeechService.stop();
super.dispose();
}
@@ -651,8 +656,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// 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 {
@@ -1116,6 +1128,67 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
}
}
/// 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 TextToSpeechService.speak('Prochain item: $nextItem');
} else {
await TextToSpeechService.speak('Tous les items sont validés');
}
}
@override
Widget build(BuildContext context) {
@@ -1139,31 +1212,42 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_currentEvent.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Nom de l'événement et barre de progression sur la même ligne
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: _getProgress(),
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
allValidated ? Colors.green : AppColors.bleuFonce,
child: Text(
_currentEvent.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
Text(
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
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,
),
),
],
),
),
],
@@ -1193,48 +1277,56 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
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
// Champ de saisie manuelle avec bouton scanner
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),
),
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),
@@ -1255,9 +1347,44 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: _buildChecklistItems(),
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,
);
}
},
),
),
],