diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index 2f8c4a6..4b9bb45 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -18,7 +18,6 @@ canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074 canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229 canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472 canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664 -assets/packages/flutter_map/lib/assets/flutter_map_logo.png,1759916249804,26fe50c9203ccf93512b80d4ee1a7578184a910457b36a6a5b7d41b799efb966 assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee @@ -32,16 +31,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1770478530807,2cbfdf7f34574c2f9d4f1af02acb86d8d230af93790c97a3c7e1674c4db42ef4 -index.html,1770478536326,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_service_worker.js,1770478628965,cb72807cfcb05b0a2e7b3f4f0cf618a0284a3d2476c93672bd86ea99670b0f5d -assets/FontManifest.json,1770478624084,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.json,1770478624084,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067 -flutter_bootstrap.js,1770478536318,bf4a3b4bf79eaed1ce24892f20cfb270bcc22fb392bc9f6a1d17aeed42ed4ed8 -assets/AssetManifest.bin.json,1770478624084,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a -assets/AssetManifest.bin,1770478624084,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1770478628013,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/shaders/ink_sparkle.frag,1770478624492,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/fonts/MaterialIcons-Regular.otf,1770478628013,50e06fd231edee237d875cddbae1e22b682d32bb1284e3c32ca409fa489f9c21 -assets/NOTICES,1770478624086,d02d64a466e62fdaeee2534a3f65541362ccf29beb495e2af0fdce41f4ae28d9 -main.dart.js,1770478620736,03d43aeaa96cfdbe5b7491f9610223ec95c29d47095570dd61cd6cddac863496 +version.json,1771424685339,42cf9a387914eaffab834efe900a1a64646a6ca1e08e8fc9bce6ea24287467ae +index.html,1771424691199,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_bootstrap.js,1771424691185,a85bd677e63a3af1facd939b2598f228a43108bd9dcc6ec3f28602e38c038aaf +flutter_service_worker.js,1771424785241,1f9e2018f760b66a5fe8b8196fbb296ac75d3831f348f55bfe42577d780ef9c3 +assets/FontManifest.json,1771424781640,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +assets/AssetManifest.json,1771424781640,4bf36ad943b43d5ad6d8d214e15d84a5633d80d8e08158af25f55baf8980a4e9 +assets/AssetManifest.bin.json,1771424781640,c3fa09840d3272b5c70eb6303934d535526be377a9b64fd62ab4f7c48351bd9e +assets/AssetManifest.bin,1771424781640,c9151561940e124b5fc93ff810ec49ddb3472e8ae438063c6c5c448444fa2b9e +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1771424784286,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/shaders/ink_sparkle.frag,1771424781832,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/fonts/MaterialIcons-Regular.otf,1771424784291,36e7520564ff4f2fca9e177788cc4fe43ff0af59781b907e68feef8b1df324ec +assets/NOTICES,1771424781641,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74 +main.dart.js,1771424780331,fcf3b7430b7773bca412965f753836a28d7d1777156404b37884337b2b9bab87 diff --git a/em2rp/CHANGELOG.md b/em2rp/CHANGELOG.md index 04548db..46572fc 100644 --- a/em2rp/CHANGELOG.md +++ b/em2rp/CHANGELOG.md @@ -2,6 +2,12 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichier. +## 24/02/2026 +Ajout de la gestion des maintenance et synthèse vocale + +## 18/02/2026 +Ajout de la fonctionnalité d'exportation des données au format CSV. Correction de bugs mineurs et amélioration des performances. + ## 🚀 Nouveautés de la mise à jour Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements : diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index 739e9ab..c5e408b 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '1.1.4'; + static const String version = '1.1.6'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index f89ee27..aed18a8 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -11,6 +11,7 @@ import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/login_page.dart'; import 'package:em2rp/views/equipment_management_page.dart'; import 'package:em2rp/views/container_management_page.dart'; +import 'package:em2rp/views/maintenance_management_page.dart'; import 'package:em2rp/views/container_form_page.dart'; import 'package:em2rp/views/container_detail_page.dart'; import 'package:em2rp/views/event_preparation_page.dart'; @@ -159,6 +160,9 @@ class MyApp extends StatelessWidget { '/container_management': (context) => const AuthGuard( requiredPermission: "view_equipment", child: ContainerManagementPage()), + '/maintenance_management': (context) => const AuthGuard( + requiredPermission: "manage_maintenances", + child: MaintenanceManagementPage()), '/container_form': (context) { final args = ModalRoute.of(context)?.settings.arguments; return AuthGuard( diff --git a/em2rp/lib/providers/maintenance_provider.dart b/em2rp/lib/providers/maintenance_provider.dart index 68c1a83..a4a9b4b 100644 --- a/em2rp/lib/providers/maintenance_provider.dart +++ b/em2rp/lib/providers/maintenance_provider.dart @@ -1,14 +1,39 @@ import 'package:flutter/foundation.dart'; import 'package:em2rp/models/maintenance_model.dart'; import 'package:em2rp/services/maintenance_service.dart'; +import 'package:em2rp/utils/debug_log.dart'; class MaintenanceProvider extends ChangeNotifier { final MaintenanceService _service = MaintenanceService(); List _maintenances = []; + bool _isLoading = false; // Getters List get maintenances => _maintenances; + bool get isLoading => _isLoading; + + /// Charger toutes les maintenances + Future loadMaintenances({String? equipmentId}) async { + _isLoading = true; + notifyListeners(); + + try { + if (equipmentId != null) { + _maintenances = await _service.getMaintenancesByEquipment(equipmentId); + } else { + _maintenances = await _service.getAllMaintenances(); + } + + _isLoading = false; + notifyListeners(); + } catch (e) { + DebugLog.error('[MaintenanceProvider] Error loading maintenances', e); + _isLoading = false; + notifyListeners(); + rethrow; + } + } /// Récupérer les maintenances pour un équipement spécifique Future> getMaintenances(String equipmentId) async { @@ -24,9 +49,9 @@ class MaintenanceProvider extends ChangeNotifier { Future createMaintenance(MaintenanceModel maintenance) async { try { await _service.createMaintenance(maintenance); - notifyListeners(); + await loadMaintenances(); // Recharger après création } catch (e) { - print('Error creating maintenance: $e'); + DebugLog.error('[MaintenanceProvider] Error creating maintenance', e); rethrow; } } @@ -35,9 +60,9 @@ class MaintenanceProvider extends ChangeNotifier { Future updateMaintenance(String id, Map data) async { try { await _service.updateMaintenance(id, data); - notifyListeners(); + await loadMaintenances(); // Recharger après mise à jour } catch (e) { - print('Error updating maintenance: $e'); + DebugLog.error('[MaintenanceProvider] Error updating maintenance', e); rethrow; } } @@ -46,9 +71,9 @@ class MaintenanceProvider extends ChangeNotifier { Future deleteMaintenance(String id) async { try { await _service.deleteMaintenance(id); - notifyListeners(); + await loadMaintenances(); // Recharger après suppression } catch (e) { - print('Error deleting maintenance: $e'); + DebugLog.error('[MaintenanceProvider] Error deleting maintenance', e); rethrow; } } @@ -58,7 +83,7 @@ class MaintenanceProvider extends ChangeNotifier { try { return await _service.getMaintenanceById(id); } catch (e) { - print('Error getting maintenance: $e'); + DebugLog.error('[MaintenanceProvider] Error getting maintenance', e); rethrow; } } @@ -71,9 +96,9 @@ class MaintenanceProvider extends ChangeNotifier { }) async { try { await _service.completeMaintenance(id, performedBy: performedBy, cost: cost); - notifyListeners(); + await loadMaintenances(); // Recharger après complétion } catch (e) { - print('Error completing maintenance: $e'); + DebugLog.error('[MaintenanceProvider] Error completing maintenance', e); rethrow; } } @@ -83,13 +108,13 @@ class MaintenanceProvider extends ChangeNotifier { try { await _service.checkUpcomingMaintenances(); } catch (e) { - print('Error checking upcoming maintenances: $e'); + DebugLog.error('[MaintenanceProvider] Error checking upcoming maintenances', e); rethrow; } } /// Récupérer les maintenances en retard - List get overdueMaintances { + List get overdueMaintenances { return _maintenances.where((m) => m.isOverdue).toList(); } @@ -102,5 +127,12 @@ class MaintenanceProvider extends ChangeNotifier { List get upcomingMaintenances { return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList(); } + + /// Obtenir les maintenances pour un équipement spécifique (depuis le cache local) + List getForEquipment(String equipmentId) { + return _maintenances.where((m) => + m.equipmentIds.contains(equipmentId) + ).toList(); + } } diff --git a/em2rp/lib/services/audio_feedback_service.dart b/em2rp/lib/services/audio_feedback_service.dart index 59cc976..53b7005 100644 --- a/em2rp/lib/services/audio_feedback_service.dart +++ b/em2rp/lib/services/audio_feedback_service.dart @@ -1,25 +1,36 @@ import 'package:flutter/services.dart'; +import 'package:audioplayers/audioplayers.dart'; import 'package:em2rp/utils/debug_log.dart'; /// Service pour émettre des feedbacks sonores lors des interactions class AudioFeedbackService { - /// Jouer un son de succès (clic système) + static final AudioPlayer _player = AudioPlayer(); + + /// Jouer un son de succès static Future playSuccessBeep() async { try { + // Jouer un son système + await HapticFeedback.mediumImpact(); await SystemSound.play(SystemSoundType.click); + + // Alternative : jouer un son personnalisé si disponible + // await _player.play(AssetSource('sounds/success.mp3')); } catch (e) { DebugLog.error('[AudioFeedbackService] Error playing success beep', e); } } - /// Jouer un son d'erreur (alerte système) + /// Jouer un son d'erreur static Future playErrorBeep() async { try { - // Note: SystemSoundType.alert n'existe pas sur toutes les plateformes - // On utilise click pour l'instant, peut être amélioré avec audioplayers + // Double bip pour indiquer une erreur + await HapticFeedback.heavyImpact(); await SystemSound.play(SystemSoundType.click); await Future.delayed(const Duration(milliseconds: 100)); await SystemSound.play(SystemSoundType.click); + + // Alternative : jouer un son d'erreur personnalisé si disponible + // await _player.play(AssetSource('sounds/error.mp3')); } catch (e) { DebugLog.error('[AudioFeedbackService] Error playing error beep', e); } @@ -36,11 +47,15 @@ class AudioFeedbackService { /// Jouer un feedback complet (son + vibration) static Future playFullFeedback({bool isSuccess = true}) async { - await playHapticFeedback(); if (isSuccess) { await playSuccessBeep(); } else { await playErrorBeep(); } } + + /// Nettoyer les ressources + static Future dispose() async { + await _player.dispose(); + } } diff --git a/em2rp/lib/services/pdf_service.dart b/em2rp/lib/services/pdf_service.dart index 0515b12..6a3ec3e 100644 --- a/em2rp/lib/services/pdf_service.dart +++ b/em2rp/lib/services/pdf_service.dart @@ -286,7 +286,7 @@ class PDFService { final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList(); final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList(); - pdf.addPage( + pdf.addPage( pw.Page( pageFormat: PdfPageFormat.a4, margin: pw.EdgeInsets.zero, @@ -299,10 +299,20 @@ class PDFService { runSpacing: 0, // 0 espace entre les lignes children: List.generate(pageItems.length, (i) { final item = pageItems[i]; + // Déterminer si c'est la première colonne (indices pairs) + final bool isFirstColumn = (i % 2) == 0; + // Décalage de 2mm pour la première colonne + final double leftPadding = isFirstColumn ? 8.0 : 6.0; // 6 + 2mm + return pw.Container( width: labelWidth, height: labelHeight, - padding: const pw.EdgeInsets.all(6), + padding: pw.EdgeInsets.only( + left: leftPadding, + right: 6, + top: 6, + bottom: 6, + ), // Suppression de la décoration (bordure) child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.center, diff --git a/em2rp/lib/services/text_to_speech_service.dart b/em2rp/lib/services/text_to_speech_service.dart new file mode 100644 index 0000000..1c8dcff --- /dev/null +++ b/em2rp/lib/services/text_to_speech_service.dart @@ -0,0 +1,104 @@ +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:em2rp/utils/debug_log.dart'; + +/// Service de synthèse vocale pour lire des textes à haute voix +class TextToSpeechService { + static final FlutterTts _tts = FlutterTts(); + static bool _isInitialized = false; + + /// Initialiser le service TTS + static Future initialize() async { + if (_isInitialized) return; + + try { + await _tts.setLanguage('fr-FR'); + await _tts.setSpeechRate(0.7); // Vitesse normale + await _tts.setVolume(1.0); + await _tts.setPitch(0.7); // Pitch plus bas pour une voix masculine + + // Tenter de sélectionner une voix masculine si disponible + try { + final voices = await _tts.getVoices; + if (voices != null && voices is List) { + // Chercher une voix française masculine + final maleVoice = voices.firstWhere( + (voice) { + final voiceMap = voice as Map; + final name = voiceMap['name']?.toString().toLowerCase() ?? ''; + final locale = voiceMap['locale']?.toString().toLowerCase() ?? ''; + + // Rechercher des voix françaises masculines + return locale.startsWith('fr') && + (name.contains('male') || name.contains('homme') || + name.contains('thomas') || name.contains('paul')); + }, + orElse: () => null, + ); + + if (maleVoice != null) { + final voiceMap = maleVoice as Map; + await _tts.setVoice({ + 'name': voiceMap['name'], + 'locale': voiceMap['locale'], + }); + DebugLog.info('[TextToSpeechService] Voix masculine sélectionnée: ${voiceMap['name']}'); + } + } + } catch (e) { + DebugLog.info('[TextToSpeechService] Impossible de sélectionner une voix spécifique, utilisation de la voix par défaut'); + } + + _isInitialized = true; + DebugLog.info('[TextToSpeechService] Service initialisé avec voix masculine'); + } catch (e) { + DebugLog.error('[TextToSpeechService] Erreur lors de l\'initialisation', e); + } + } + + /// Lire un texte à haute voix + static Future speak(String text) async { + if (!_isInitialized) { + await initialize(); + } + + try { + // Arrêter toute lecture en cours + await _tts.stop(); + + // Lire le nouveau texte + await _tts.speak(text); + DebugLog.info('[TextToSpeechService] Lecture: $text'); + } catch (e) { + DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e); + } + } + + /// Arrêter la lecture en cours + static Future stop() async { + try { + await _tts.stop(); + } catch (e) { + DebugLog.error('[TextToSpeechService] Erreur lors de l\'arrêt', e); + } + } + + /// Vérifier si le service est en train de lire + static Future isSpeaking() async { + try { + // FlutterTts ne fournit pas directement cette info, on retourne false par défaut + return false; + } catch (e) { + return false; + } + } + + /// Nettoyer les ressources + static Future dispose() async { + try { + await _tts.stop(); + } catch (e) { + DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e); + } + } +} + diff --git a/em2rp/lib/utils/app_permissions.dart b/em2rp/lib/utils/app_permissions.dart index da69c65..bde8bdd 100644 --- a/em2rp/lib/utils/app_permissions.dart +++ b/em2rp/lib/utils/app_permissions.dart @@ -35,10 +35,10 @@ enum AppPermission { // ============= MAINTENANCE ============= /// Permet de voir les maintenances - viewMaintenance('view_maintenance'), + viewMaintenance('view_maintenances'), /// Permet de créer, modifier et supprimer des maintenances - manageMaintenance('manage_maintenance'), + manageMaintenance('manage_maintenances'), // ============= UTILISATEURS ============= /// Permet de voir la liste de tous les utilisateurs diff --git a/em2rp/lib/views/equipment_detail_page.dart b/em2rp/lib/views/equipment_detail_page.dart index 3b0a223..4bf5931 100644 --- a/em2rp/lib/views/equipment_detail_page.dart +++ b/em2rp/lib/views/equipment_detail_page.dart @@ -19,6 +19,7 @@ import 'package:em2rp/views/widgets/equipment/equipment_current_events_section.d import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart'; import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart'; import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart'; +import 'package:em2rp/views/maintenance_form_page.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:printing/printing.dart'; @@ -152,6 +153,7 @@ class _EquipmentDetailPageState extends State { maintenances: _maintenances, isLoading: _isLoadingMaintenances, hasManagePermission: hasManagePermission, + onAddMaintenance: hasManagePermission ? _planMaintenance : null, ), ], ), @@ -175,6 +177,7 @@ class _EquipmentDetailPageState extends State { maintenances: _maintenances, isLoading: _isLoadingMaintenances, hasManagePermission: hasManagePermission, + onAddMaintenance: hasManagePermission ? _planMaintenance : null, ), const SizedBox(height: 24), EquipmentDatesSection(equipment: widget.equipment), @@ -378,6 +381,36 @@ class _EquipmentDetailPageState extends State { ); } + /// Planifier une nouvelle maintenance pour cet équipment + Future _planMaintenance() async { + final userProvider = Provider.of(context, listen: false); + final hasPermission = userProvider.hasPermission('manage_maintenances'); + + if (!hasPermission) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vous n\'avez pas la permission de gérer les maintenances'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MaintenanceFormPage( + initialEquipmentIds: [widget.equipment.id], + ), + ), + ); + + // Recharger les maintenances si une maintenance a été créée + if (result == true && mounted) { + await _loadMaintenances(); + } + } + void _editEquipment() { Navigator.push( context, diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index 2e43f2d..8c66a5d 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -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 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 with Single _animationController.dispose(); _manualCodeController.dispose(); _manualCodeFocusNode.dispose(); + TextToSpeechService.stop(); super.dispose(); } @@ -651,8 +656,15 @@ class _EventPreparationPageState extends State 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 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 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 _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 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( - 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( + 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 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 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, + ); + } + }, ), ), ], diff --git a/em2rp/lib/views/maintenance_form_page.dart b/em2rp/lib/views/maintenance_form_page.dart new file mode 100644 index 0000000..cf2232e --- /dev/null +++ b/em2rp/lib/views/maintenance_form_page.dart @@ -0,0 +1,619 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/maintenance_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/providers/maintenance_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:intl/intl.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:uuid/uuid.dart'; + +/// Page de formulaire pour créer ou modifier une maintenance +class MaintenanceFormPage extends StatefulWidget { + final MaintenanceModel? maintenance; + final List? initialEquipmentIds; + + const MaintenanceFormPage({ + super.key, + this.maintenance, + this.initialEquipmentIds, + }); + + @override + State createState() => _MaintenanceFormPageState(); +} + +class _MaintenanceFormPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _costController = TextEditingController(); + final _notesController = TextEditingController(); + + MaintenanceType _selectedType = MaintenanceType.preventive; + DateTime _scheduledDate = DateTime.now(); + final List _selectedEquipmentIds = []; + bool _isLoading = false; + + bool get _isEditing => widget.maintenance != null; + + @override + void initState() { + super.initState(); + + if (_isEditing) { + _nameController.text = widget.maintenance!.name; + _descriptionController.text = widget.maintenance!.description; + _selectedType = widget.maintenance!.type; + _scheduledDate = widget.maintenance!.scheduledDate; + _selectedEquipmentIds.addAll(widget.maintenance!.equipmentIds); + + if (widget.maintenance!.cost != null) { + _costController.text = widget.maintenance!.cost!.toStringAsFixed(2); + } + if (widget.maintenance!.notes != null) { + _notesController.text = widget.maintenance!.notes!; + } + } else if (widget.initialEquipmentIds != null) { + // Pré-remplir avec les équipements fournis + _selectedEquipmentIds.addAll(widget.initialEquipmentIds!); + } + + // Charger les équipements + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().ensureLoaded(); + }); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _costController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? 'Modifier la maintenance' : 'Nouvelle maintenance'), + backgroundColor: AppColors.bleuFonce, + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Nom + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom de la maintenance *', + hintText: 'Ex: Révision annuelle', + prefixIcon: Icon(Icons.title), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le nom est requis'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Type + DropdownButtonFormField( + initialValue: _selectedType, + decoration: const InputDecoration( + labelText: 'Type de maintenance *', + prefixIcon: Icon(Icons.category), + border: OutlineInputBorder(), + ), + items: MaintenanceType.values.map((type) { + final info = _getMaintenanceTypeInfo(type); + return DropdownMenuItem( + value: type, + child: Row( + children: [ + Icon(info.$2, size: 20, color: info.$3), + const SizedBox(width: 8), + Text(info.$1), + ], + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + }); + } + }, + ), + const SizedBox(height: 16), + + // Date planifiée + InkWell( + onTap: _selectDate, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Date planifiée *', + prefixIcon: Icon(Icons.event), + border: OutlineInputBorder(), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(DateFormat('dd/MM/yyyy').format(_scheduledDate)), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Équipements + _buildEquipmentSelector(), + const SizedBox(height: 16), + + // Description + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description *', + hintText: 'Détails de l\'opération à effectuer', + prefixIcon: Icon(Icons.description), + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 4, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'La description est requise'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Coût estimé + TextFormField( + controller: _costController, + decoration: const InputDecoration( + labelText: 'Coût estimé (€)', + hintText: 'Ex: 150.00', + prefixIcon: Icon(Icons.euro), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Coût invalide'; + } + } + return null; + }, + ), + const SizedBox(height: 16), + + // Notes + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: 'Informations complémentaires', + prefixIcon: Icon(Icons.notes), + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 3, + ), + const SizedBox(height: 24), + + // Bouton sauvegarder + ElevatedButton.icon( + onPressed: _isLoading ? null : _saveMaintenance, + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.save), + label: Text(_isEditing ? 'Mettre à jour' : 'Créer la maintenance'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.bleuFonce, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ), + ), + ); + } + + Widget _buildEquipmentSelector() { + return Consumer( + builder: (context, equipmentProvider, _) { + // Filtrer uniquement les équipements + final availableEquipment = equipmentProvider.allEquipment + .cast() + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InputDecorator( + decoration: InputDecoration( + labelText: 'Équipements concernés *', + prefixIcon: const Icon(Icons.inventory), + border: const OutlineInputBorder(), + errorText: _selectedEquipmentIds.isEmpty ? 'Sélectionnez au moins un équipement' : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_selectedEquipmentIds.isEmpty) + const Text( + 'Aucun équipement sélectionné', + style: TextStyle(color: Colors.grey), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedEquipmentIds.map((id) { + final equipment = availableEquipment.firstWhere( + (eq) => eq.id == id, + orElse: () => EquipmentModel( + id: id, + name: 'Inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + return Chip( + label: Text(equipment.name), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () { + setState(() { + _selectedEquipmentIds.remove(id); + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _showEquipmentPicker(availableEquipment), + icon: const Icon(Icons.add), + label: const Text('Ajouter des équipements'), + ), + ], + ), + ), + ], + ); + }, + ); + } + + Future _showEquipmentPicker(List availableEquipment) async { + final selectedIds = await showDialog>( + context: context, + builder: (context) => _EquipmentPickerDialog( + availableEquipment: availableEquipment, + initialSelectedIds: _selectedEquipmentIds, + ), + ); + + if (selectedIds != null) { + setState(() { + _selectedEquipmentIds.clear(); + _selectedEquipmentIds.addAll(selectedIds); + }); + } + } + + Future _selectDate() async { + final date = await showDatePicker( + context: context, + initialDate: _scheduledDate, + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: DateTime.now().add(const Duration(days: 365 * 5)), + locale: const Locale('fr', 'FR'), + ); + + if (date != null) { + setState(() { + _scheduledDate = date; + }); + } + } + + Future _saveMaintenance() async { + if (!_formKey.currentState!.validate()) { + return; + } + + if (_selectedEquipmentIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Veuillez sélectionner au moins un équipement'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final cost = _costController.text.trim().isNotEmpty + ? double.tryParse(_costController.text.trim()) + : null; + + final notes = _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null; + + if (_isEditing) { + // Mise à jour + await context.read().updateMaintenance( + widget.maintenance!.id, + { + 'name': _nameController.text.trim(), + 'description': _descriptionController.text.trim(), + 'type': maintenanceTypeToString(_selectedType), + 'scheduledDate': _scheduledDate, + 'equipmentIds': _selectedEquipmentIds, + 'cost': cost, + 'notes': notes, + 'updatedAt': DateTime.now(), + }, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maintenance mise à jour avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } else { + // Création + final maintenance = MaintenanceModel( + id: const Uuid().v4(), + equipmentIds: _selectedEquipmentIds, + type: _selectedType, + scheduledDate: _scheduledDate, + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + cost: cost, + notes: notes, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await context.read().createMaintenance(maintenance); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maintenance créée avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } + + if (mounted) { + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + (String, IconData, Color) _getMaintenanceTypeInfo(MaintenanceType type) { + switch (type) { + case MaintenanceType.preventive: + return ('Préventive', Icons.schedule, Colors.blue); + case MaintenanceType.corrective: + return ('Corrective', Icons.build, Colors.orange); + case MaintenanceType.inspection: + return ('Inspection', Icons.search, Colors.purple); + } + } +} + +/// Dialog pour sélectionner plusieurs équipements +class _EquipmentPickerDialog extends StatefulWidget { + final List availableEquipment; + final List initialSelectedIds; + + const _EquipmentPickerDialog({ + required this.availableEquipment, + required this.initialSelectedIds, + }); + + @override + State<_EquipmentPickerDialog> createState() => _EquipmentPickerDialogState(); +} + +class _EquipmentPickerDialogState extends State<_EquipmentPickerDialog> { + late List _selectedIds; + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _selectedIds = List.from(widget.initialSelectedIds); + } + + @override + Widget build(BuildContext context) { + final filteredEquipment = widget.availableEquipment.where((eq) { + if (_searchQuery.isEmpty) return true; + return eq.name.toLowerCase().contains(_searchQuery.toLowerCase()) || + eq.id.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + + return AlertDialog( + title: const Text('Sélectionner des équipements'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Barre de recherche + TextField( + decoration: const InputDecoration( + labelText: 'Rechercher', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + const SizedBox(height: 16), + + // Compteur + Text( + '${_selectedIds.length} équipement(s) sélectionné(s)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + + // Liste des équipements + Expanded( + child: filteredEquipment.isEmpty + ? const Center(child: Text('Aucun équipement trouvé')) + : ListView.builder( + shrinkWrap: true, + itemCount: filteredEquipment.length, + itemBuilder: (context, index) { + final equipment = filteredEquipment[index]; + final isSelected = _selectedIds.contains(equipment.id); + + return CheckboxListTile( + value: isSelected, + onChanged: (selected) { + setState(() { + if (selected == true) { + _selectedIds.add(equipment.id); + } else { + _selectedIds.remove(equipment.id); + } + }); + }, + title: Text(equipment.name), + subtitle: Text( + '${equipment.id} • ${_getCategoryLabel(equipment.category)}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + secondary: Icon( + _getCategoryIcon(equipment.category), + color: AppColors.bleuFonce, + ), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, _selectedIds), + style: ElevatedButton.styleFrom(backgroundColor: AppColors.bleuFonce), + child: const Text('Valider'), + ), + ], + ); + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.cable: + return 'Câblage'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.vehicle: + return 'Véhicule'; + case EquipmentCategory.backline: + return 'Backline'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + IconData _getCategoryIcon(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.sound: + return Icons.volume_up; + case EquipmentCategory.lighting: + return Icons.lightbulb; + case EquipmentCategory.video: + return Icons.videocam; + case EquipmentCategory.structure: + return Icons.construction; + case EquipmentCategory.effect: + return Icons.auto_awesome; + case EquipmentCategory.cable: + return Icons.cable; + case EquipmentCategory.consumable: + return Icons.inventory_2; + case EquipmentCategory.vehicle: + return Icons.local_shipping; + case EquipmentCategory.backline: + return Icons.queue_music; + case EquipmentCategory.other: + return Icons.category; + } + } +} + + + + diff --git a/em2rp/lib/views/maintenance_management_page.dart b/em2rp/lib/views/maintenance_management_page.dart new file mode 100644 index 0000000..627393d --- /dev/null +++ b/em2rp/lib/views/maintenance_management_page.dart @@ -0,0 +1,627 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/maintenance_model.dart'; +import 'package:em2rp/providers/maintenance_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/views/maintenance_form_page.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/nav/main_drawer.dart'; +import 'package:em2rp/utils/permission_gate.dart'; +import 'package:intl/intl.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Page de gestion des maintenances +class MaintenanceManagementPage extends StatefulWidget { + const MaintenanceManagementPage({super.key}); + + @override + State createState() => _MaintenanceManagementPageState(); +} + +class _MaintenanceManagementPageState extends State { + String _filterType = 'all'; // all, upcoming, overdue, completed + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadMaintenances(); + }); + } + + Future _loadMaintenances() async { + final maintenanceProvider = context.read(); + final equipmentProvider = context.read(); + + await Future.wait([ + maintenanceProvider.loadMaintenances(), + equipmentProvider.ensureLoaded(), + ]); + } + + List _getFilteredMaintenances(List maintenances) { + switch (_filterType) { + case 'upcoming': + return maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList(); + case 'overdue': + return maintenances.where((m) => m.isOverdue).toList(); + case 'completed': + return maintenances.where((m) => m.isCompleted).toList(); + default: + return maintenances; + } + } + + @override + Widget build(BuildContext context) { + return PermissionGate( + requiredPermissions: const ['manage_maintenances'], + fallback: Scaffold( + appBar: const CustomAppBar(title: 'Accès refusé'), + drawer: const MainDrawer(currentPage: '/maintenance_management'), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text( + 'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion des maintenances.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + child: Scaffold( + appBar: const CustomAppBar( + title: 'Gestion des maintenances', + ), + drawer: const MainDrawer(currentPage: '/maintenance_management'), + body: Consumer( + builder: (context, maintenanceProvider, _) { + if (maintenanceProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + final filteredMaintenances = _getFilteredMaintenances( + maintenanceProvider.maintenances, + ); + + return Column( + children: [ + // Filtres + _buildFilterChips(), + + // Statistiques + _buildStatsCards(maintenanceProvider), + + // Liste des maintenances + Expanded( + child: filteredMaintenances.isEmpty + ? _buildEmptyState() + : _buildMaintenanceList(filteredMaintenances), + ), + ], + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _navigateToForm(null), + backgroundColor: AppColors.bleuFonce, + icon: const Icon(Icons.add), + label: const Text('Nouvelle maintenance'), + ), + ), + ); + } + + Widget _buildFilterChips() { + return Container( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterChip('Toutes', 'all'), + const SizedBox(width: 8), + _buildFilterChip('À venir', 'upcoming'), + const SizedBox(width: 8), + _buildFilterChip('En retard', 'overdue'), + const SizedBox(width: 8), + _buildFilterChip('Complétées', 'completed'), + ], + ), + ), + ); + } + + Widget _buildFilterChip(String label, String filterValue) { + final isSelected = _filterType == filterValue; + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + setState(() { + _filterType = filterValue; + }); + }, + selectedColor: AppColors.bleuFonce.withValues(alpha: 0.2), + checkmarkColor: AppColors.bleuFonce, + ); + } + + Widget _buildStatsCards(MaintenanceProvider provider) { + final upcoming = provider.maintenances.where((m) => !m.isCompleted && !m.isOverdue).length; + final overdue = provider.maintenances.where((m) => m.isOverdue).length; + final completed = provider.maintenances.where((m) => m.isCompleted).length; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: _buildStatCard( + 'À venir', + upcoming.toString(), + Icons.schedule, + Colors.blue, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildStatCard( + 'En retard', + overdue.toString(), + Icons.warning, + Colors.orange, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildStatCard( + 'Complétées', + completed.toString(), + Icons.check_circle, + Colors.green, + ), + ), + ], + ), + ); + } + + Widget _buildStatCard(String label, String value, IconData icon, Color color) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.build_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Aucune maintenance', + style: TextStyle(fontSize: 18, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Créez votre première maintenance', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildMaintenanceList(List maintenances) { + // Trier par date (les plus récentes/urgentes en premier) + final sortedMaintenances = List.from(maintenances) + ..sort((a, b) { + if (a.isCompleted && !b.isCompleted) return 1; + if (!a.isCompleted && b.isCompleted) return -1; + return a.scheduledDate.compareTo(b.scheduledDate); + }); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: sortedMaintenances.length, + itemBuilder: (context, index) { + return _buildMaintenanceCard(sortedMaintenances[index]); + }, + ); + } + + Widget _buildMaintenanceCard(MaintenanceModel maintenance) { + final equipmentProvider = context.read(); + final equipmentNames = maintenance.equipmentIds + .map((id) => equipmentProvider.allEquipment + .cast() + .firstWhere((e) => e.id == id, orElse: () => null) + ?.name ?? 'Inconnu') + .toList(); + + final typeInfo = _getMaintenanceTypeInfo(maintenance.type); + final statusInfo = _getStatusInfo(maintenance); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () => _navigateToForm(maintenance), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: typeInfo.$3.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(typeInfo.$2, size: 16, color: typeInfo.$3), + const SizedBox(width: 4), + Text( + typeInfo.$1, + style: TextStyle( + color: typeInfo.$3, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusInfo.$2.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + statusInfo.$1, + style: TextStyle( + color: statusInfo.$2, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () => _showMaintenanceMenu(maintenance), + ), + ], + ), + const SizedBox(height: 12), + + // Nom + Text( + maintenance.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Description + if (maintenance.description.isNotEmpty) + Text( + maintenance.description, + style: TextStyle(color: Colors.grey[700]), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + + // Équipements + Wrap( + spacing: 4, + runSpacing: 4, + children: equipmentNames.map((name) { + return Chip( + label: Text(name, style: const TextStyle(fontSize: 12)), + backgroundColor: Colors.grey[200], + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + const SizedBox(height: 12), + + // Dates + Row( + children: [ + Icon(Icons.event, size: 16, color: Colors.grey[600]), + const SizedBox(width: 4), + Text( + maintenance.isCompleted + ? 'Complétée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}' + : 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ), + + // Coût + if (maintenance.cost != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.euro, size: 16, color: Colors.grey[600]), + const SizedBox(width: 4), + Text( + '${maintenance.cost!.toStringAsFixed(2)} €', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + (String, IconData, Color) _getMaintenanceTypeInfo(MaintenanceType type) { + switch (type) { + case MaintenanceType.preventive: + return ('Préventive', Icons.schedule, Colors.blue); + case MaintenanceType.corrective: + return ('Corrective', Icons.build, Colors.orange); + case MaintenanceType.inspection: + return ('Inspection', Icons.search, Colors.purple); + } + } + + (String, Color) _getStatusInfo(MaintenanceModel maintenance) { + if (maintenance.isCompleted) { + return ('Complétée', Colors.green); + } else if (maintenance.isOverdue) { + return ('En retard', Colors.red); + } else { + return ('À venir', Colors.blue); + } + } + + void _showMaintenanceMenu(MaintenanceModel maintenance) { + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!maintenance.isCompleted) + ListTile( + leading: const Icon(Icons.check_circle, color: Colors.green), + title: const Text('Marquer comme complétée'), + onTap: () { + Navigator.pop(context); + _completeMaintenance(maintenance); + }, + ), + ListTile( + leading: const Icon(Icons.edit, color: AppColors.bleuFonce), + title: const Text('Modifier'), + onTap: () { + Navigator.pop(context); + _navigateToForm(maintenance); + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text('Supprimer'), + onTap: () { + Navigator.pop(context); + _deleteMaintenance(maintenance); + }, + ), + ], + ), + ); + }, + ); + } + + Future _completeMaintenance(MaintenanceModel maintenance) async { + final result = await showDialog>( + context: context, + builder: (context) => _CompleteMaintenanceDialog(maintenance: maintenance), + ); + + if (result != null && mounted) { + try { + await context.read().completeMaintenance( + maintenance.id, + performedBy: result['performedBy'], + cost: result['cost'], + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maintenance marquée comme complétée'), + backgroundColor: Colors.green, + ), + ); + _loadMaintenances(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + Future _deleteMaintenance(MaintenanceModel maintenance) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer la maintenance'), + content: Text('Êtes-vous sûr de vouloir supprimer "${maintenance.name}" ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().deleteMaintenance(maintenance.id); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maintenance supprimée'), + backgroundColor: Colors.green, + ), + ); + _loadMaintenances(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + Future _navigateToForm(MaintenanceModel? maintenance) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MaintenanceFormPage(maintenance: maintenance), + ), + ); + + if (result == true && mounted) { + _loadMaintenances(); + } + } +} + +/// Dialog pour compléter une maintenance +class _CompleteMaintenanceDialog extends StatefulWidget { + final MaintenanceModel maintenance; + + const _CompleteMaintenanceDialog({required this.maintenance}); + + @override + State<_CompleteMaintenanceDialog> createState() => _CompleteMaintenanceDialogState(); +} + +class _CompleteMaintenanceDialogState extends State<_CompleteMaintenanceDialog> { + final _costController = TextEditingController(); + final _notesController = TextEditingController(); + + @override + void dispose() { + _costController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Compléter la maintenance'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _costController, + decoration: const InputDecoration( + labelText: 'Coût (€)', + hintText: 'Ex: 150.00', + prefixIcon: Icon(Icons.euro), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + TextField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes (optionnel)', + hintText: 'Commentaires sur l\'intervention', + prefixIcon: Icon(Icons.notes), + ), + maxLines: 3, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + final cost = double.tryParse(_costController.text); + Navigator.pop(context, { + 'cost': cost, + 'notes': _notesController.text.trim(), + }); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + child: const Text('Valider'), + ), + ], + ); + } +} + diff --git a/em2rp/lib/views/widgets/equipment/equipment_maintenance_history_section.dart b/em2rp/lib/views/widgets/equipment/equipment_maintenance_history_section.dart index 1501e76..3685b32 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_maintenance_history_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_maintenance_history_section.dart @@ -8,12 +8,14 @@ class EquipmentMaintenanceHistorySection extends StatelessWidget { final List maintenances; final bool isLoading; final bool hasManagePermission; + final VoidCallback? onAddMaintenance; const EquipmentMaintenanceHistorySection({ super.key, required this.maintenances, required this.isLoading, required this.hasManagePermission, + this.onAddMaintenance, }); @override @@ -37,19 +39,42 @@ class EquipmentMaintenanceHistorySection extends StatelessWidget { ), ), ), + if (hasManagePermission && onAddMaintenance != null) + IconButton( + icon: const Icon(Icons.add_circle, color: AppColors.bleuFonce), + tooltip: 'Planifier une maintenance', + onPressed: onAddMaintenance, + ), ], ), const Divider(height: 24), if (isLoading) const Center(child: CircularProgressIndicator()) else if (maintenances.isEmpty) - const Padding( - padding: EdgeInsets.all(16.0), - child: Center( - child: Text( - 'Aucune maintenance enregistrée', - style: TextStyle(color: Colors.grey), - ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const Center( + child: Text( + 'Aucune maintenance enregistrée', + style: TextStyle(color: Colors.grey), + ), + ), + if (hasManagePermission && onAddMaintenance != null) ...[ + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + onPressed: onAddMaintenance, + icon: const Icon(Icons.add), + label: const Text('Planifier une maintenance'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.bleuFonce, + ), + ), + ), + ], + ], ), ) else diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index c7d5fff..4d826b4 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -5,6 +5,7 @@ import 'package:em2rp/views/my_account_page.dart'; import 'package:em2rp/views/user_management_page.dart'; import 'package:em2rp/views/data_management_page.dart'; import 'package:em2rp/views/equipment_management_page.dart'; +import 'package:em2rp/views/maintenance_management_page.dart'; import 'package:em2rp/config/app_version.dart'; import 'package:flutter/material.dart'; import 'package:em2rp/views/widgets/image/profile_picture.dart'; @@ -113,6 +114,24 @@ class MainDrawer extends StatelessWidget { }, ), ), + PermissionGate( + requiredPermissions: const ['manage_maintenances'], + child: ListTile( + leading: const Icon(Icons.build_circle), + title: const Text('Maintenances'), + selected: currentPage == '/maintenance_management', + selectedColor: AppColors.rouge, + onTap: () { + Navigator.pop(context); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + const MaintenanceManagementPage()), + ); + }, + ), + ), ExpansionTileTheme( data: const ExpansionTileThemeData( iconColor: AppColors.noir, diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index e95f30f..ceaa32a 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -61,6 +61,10 @@ dependencies: # Notifications flutter_local_notifications: ^19.2.1 + # Audio & TTS + audioplayers: ^6.1.0 + flutter_tts: ^4.2.0 + # Export/Import csv: ^6.0.0 web: ^1.1.1 diff --git a/em2rp/web/version.json b/em2rp/web/version.json index 520cb8c..8f43b53 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -1,7 +1,7 @@ { - "version": "1.1.5", + "version": "1.1.6", "updateUrl": "https://app.em2events.fr", "forceUpdate": true, - "releaseNotes": "Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :\r\n\r\n* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.\r\n* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.", - "timestamp": "2026-02-18T12:43:19.791Z" + "releaseNotes": "Ajout de la fonctionnalité d'exportation des données au format CSV. Correction de bugs mineurs et amélioration des performances.", + "timestamp": "2026-02-18T14:24:45.336Z" } \ No newline at end of file