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

@@ -18,7 +18,6 @@ canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229 canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472 canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664 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/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee 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/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1770478530807,2cbfdf7f34574c2f9d4f1af02acb86d8d230af93790c97a3c7e1674c4db42ef4 version.json,1771424685339,42cf9a387914eaffab834efe900a1a64646a6ca1e08e8fc9bce6ea24287467ae
index.html,1770478536326,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 index.html,1771424691199,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1770478628965,cb72807cfcb05b0a2e7b3f4f0cf618a0284a3d2476c93672bd86ea99670b0f5d flutter_bootstrap.js,1771424691185,a85bd677e63a3af1facd939b2598f228a43108bd9dcc6ec3f28602e38c038aaf
assets/FontManifest.json,1770478624084,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 flutter_service_worker.js,1771424785241,1f9e2018f760b66a5fe8b8196fbb296ac75d3831f348f55bfe42577d780ef9c3
assets/AssetManifest.json,1770478624084,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067 assets/FontManifest.json,1771424781640,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
flutter_bootstrap.js,1770478536318,bf4a3b4bf79eaed1ce24892f20cfb270bcc22fb392bc9f6a1d17aeed42ed4ed8 assets/AssetManifest.json,1771424781640,4bf36ad943b43d5ad6d8d214e15d84a5633d80d8e08158af25f55baf8980a4e9
assets/AssetManifest.bin.json,1770478624084,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a assets/AssetManifest.bin.json,1771424781640,c3fa09840d3272b5c70eb6303934d535526be377a9b64fd62ab4f7c48351bd9e
assets/AssetManifest.bin,1770478624084,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3 assets/AssetManifest.bin,1771424781640,c9151561940e124b5fc93ff810ec49ddb3472e8ae438063c6c5c448444fa2b9e
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1770478628013,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1771424784286,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1770478624492,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 assets/shaders/ink_sparkle.frag,1771424781832,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1770478628013,50e06fd231edee237d875cddbae1e22b682d32bb1284e3c32ca409fa489f9c21 assets/fonts/MaterialIcons-Regular.otf,1771424784291,36e7520564ff4f2fca9e177788cc4fe43ff0af59781b907e68feef8b1df324ec
assets/NOTICES,1770478624086,d02d64a466e62fdaeee2534a3f65541362ccf29beb495e2af0fdce41f4ae28d9 assets/NOTICES,1771424781641,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74
main.dart.js,1770478620736,03d43aeaa96cfdbe5b7491f9610223ec95c29d47095570dd61cd6cddac863496 main.dart.js,1771424780331,fcf3b7430b7773bca412965f753836a28d7d1777156404b37884337b2b9bab87

View File

@@ -2,6 +2,12 @@
Toutes les modifications notables de ce projet seront documentées dans ce fichier. 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 ## 🚀 Nouveautés de la mise à jour
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements : Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :

View File

@@ -1,6 +1,6 @@
/// Configuration de la version de l'application /// Configuration de la version de l'application
class AppVersion { class AppVersion {
static const String version = '1.1.4'; static const String version = '1.1.6';
/// Retourne la version complète de l'application /// Retourne la version complète de l'application
static String get fullVersion => 'v$version'; static String get fullVersion => 'v$version';

View File

@@ -11,6 +11,7 @@ import 'package:em2rp/views/calendar_page.dart';
import 'package:em2rp/views/login_page.dart'; import 'package:em2rp/views/login_page.dart';
import 'package:em2rp/views/equipment_management_page.dart'; import 'package:em2rp/views/equipment_management_page.dart';
import 'package:em2rp/views/container_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_form_page.dart';
import 'package:em2rp/views/container_detail_page.dart'; import 'package:em2rp/views/container_detail_page.dart';
import 'package:em2rp/views/event_preparation_page.dart'; import 'package:em2rp/views/event_preparation_page.dart';
@@ -159,6 +160,9 @@ class MyApp extends StatelessWidget {
'/container_management': (context) => const AuthGuard( '/container_management': (context) => const AuthGuard(
requiredPermission: "view_equipment", requiredPermission: "view_equipment",
child: ContainerManagementPage()), child: ContainerManagementPage()),
'/maintenance_management': (context) => const AuthGuard(
requiredPermission: "manage_maintenances",
child: MaintenanceManagementPage()),
'/container_form': (context) { '/container_form': (context) {
final args = ModalRoute.of(context)?.settings.arguments; final args = ModalRoute.of(context)?.settings.arguments;
return AuthGuard( return AuthGuard(

View File

@@ -1,14 +1,39 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:em2rp/models/maintenance_model.dart'; import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/services/maintenance_service.dart'; import 'package:em2rp/services/maintenance_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class MaintenanceProvider extends ChangeNotifier { class MaintenanceProvider extends ChangeNotifier {
final MaintenanceService _service = MaintenanceService(); final MaintenanceService _service = MaintenanceService();
List<MaintenanceModel> _maintenances = []; List<MaintenanceModel> _maintenances = [];
bool _isLoading = false;
// Getters // Getters
List<MaintenanceModel> get maintenances => _maintenances; List<MaintenanceModel> get maintenances => _maintenances;
bool get isLoading => _isLoading;
/// Charger toutes les maintenances
Future<void> 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 /// Récupérer les maintenances pour un équipement spécifique
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async { Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
@@ -24,9 +49,9 @@ class MaintenanceProvider extends ChangeNotifier {
Future<void> createMaintenance(MaintenanceModel maintenance) async { Future<void> createMaintenance(MaintenanceModel maintenance) async {
try { try {
await _service.createMaintenance(maintenance); await _service.createMaintenance(maintenance);
notifyListeners(); await loadMaintenances(); // Recharger après création
} catch (e) { } catch (e) {
print('Error creating maintenance: $e'); DebugLog.error('[MaintenanceProvider] Error creating maintenance', e);
rethrow; rethrow;
} }
} }
@@ -35,9 +60,9 @@ class MaintenanceProvider extends ChangeNotifier {
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async { Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
try { try {
await _service.updateMaintenance(id, data); await _service.updateMaintenance(id, data);
notifyListeners(); await loadMaintenances(); // Recharger après mise à jour
} catch (e) { } catch (e) {
print('Error updating maintenance: $e'); DebugLog.error('[MaintenanceProvider] Error updating maintenance', e);
rethrow; rethrow;
} }
} }
@@ -46,9 +71,9 @@ class MaintenanceProvider extends ChangeNotifier {
Future<void> deleteMaintenance(String id) async { Future<void> deleteMaintenance(String id) async {
try { try {
await _service.deleteMaintenance(id); await _service.deleteMaintenance(id);
notifyListeners(); await loadMaintenances(); // Recharger après suppression
} catch (e) { } catch (e) {
print('Error deleting maintenance: $e'); DebugLog.error('[MaintenanceProvider] Error deleting maintenance', e);
rethrow; rethrow;
} }
} }
@@ -58,7 +83,7 @@ class MaintenanceProvider extends ChangeNotifier {
try { try {
return await _service.getMaintenanceById(id); return await _service.getMaintenanceById(id);
} catch (e) { } catch (e) {
print('Error getting maintenance: $e'); DebugLog.error('[MaintenanceProvider] Error getting maintenance', e);
rethrow; rethrow;
} }
} }
@@ -71,9 +96,9 @@ class MaintenanceProvider extends ChangeNotifier {
}) async { }) async {
try { try {
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost); await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
notifyListeners(); await loadMaintenances(); // Recharger après complétion
} catch (e) { } catch (e) {
print('Error completing maintenance: $e'); DebugLog.error('[MaintenanceProvider] Error completing maintenance', e);
rethrow; rethrow;
} }
} }
@@ -83,13 +108,13 @@ class MaintenanceProvider extends ChangeNotifier {
try { try {
await _service.checkUpcomingMaintenances(); await _service.checkUpcomingMaintenances();
} catch (e) { } catch (e) {
print('Error checking upcoming maintenances: $e'); DebugLog.error('[MaintenanceProvider] Error checking upcoming maintenances', e);
rethrow; rethrow;
} }
} }
/// Récupérer les maintenances en retard /// Récupérer les maintenances en retard
List<MaintenanceModel> get overdueMaintances { List<MaintenanceModel> get overdueMaintenances {
return _maintenances.where((m) => m.isOverdue).toList(); return _maintenances.where((m) => m.isOverdue).toList();
} }
@@ -102,5 +127,12 @@ class MaintenanceProvider extends ChangeNotifier {
List<MaintenanceModel> get upcomingMaintenances { List<MaintenanceModel> get upcomingMaintenances {
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList(); return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
} }
/// Obtenir les maintenances pour un équipement spécifique (depuis le cache local)
List<MaintenanceModel> getForEquipment(String equipmentId) {
return _maintenances.where((m) =>
m.equipmentIds.contains(equipmentId)
).toList();
}
} }

View File

@@ -1,25 +1,36 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
/// Service pour émettre des feedbacks sonores lors des interactions /// Service pour émettre des feedbacks sonores lors des interactions
class AudioFeedbackService { class AudioFeedbackService {
/// Jouer un son de succès (clic système) static final AudioPlayer _player = AudioPlayer();
/// Jouer un son de succès
static Future<void> playSuccessBeep() async { static Future<void> playSuccessBeep() async {
try { try {
// Jouer un son système
await HapticFeedback.mediumImpact();
await SystemSound.play(SystemSoundType.click); await SystemSound.play(SystemSoundType.click);
// Alternative : jouer un son personnalisé si disponible
// await _player.play(AssetSource('sounds/success.mp3'));
} catch (e) { } catch (e) {
DebugLog.error('[AudioFeedbackService] Error playing success beep', e); DebugLog.error('[AudioFeedbackService] Error playing success beep', e);
} }
} }
/// Jouer un son d'erreur (alerte système) /// Jouer un son d'erreur
static Future<void> playErrorBeep() async { static Future<void> playErrorBeep() async {
try { try {
// Note: SystemSoundType.alert n'existe pas sur toutes les plateformes // Double bip pour indiquer une erreur
// On utilise click pour l'instant, peut être amélioré avec audioplayers await HapticFeedback.heavyImpact();
await SystemSound.play(SystemSoundType.click); await SystemSound.play(SystemSoundType.click);
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
await SystemSound.play(SystemSoundType.click); await SystemSound.play(SystemSoundType.click);
// Alternative : jouer un son d'erreur personnalisé si disponible
// await _player.play(AssetSource('sounds/error.mp3'));
} catch (e) { } catch (e) {
DebugLog.error('[AudioFeedbackService] Error playing error beep', e); DebugLog.error('[AudioFeedbackService] Error playing error beep', e);
} }
@@ -36,11 +47,15 @@ class AudioFeedbackService {
/// Jouer un feedback complet (son + vibration) /// Jouer un feedback complet (son + vibration)
static Future<void> playFullFeedback({bool isSuccess = true}) async { static Future<void> playFullFeedback({bool isSuccess = true}) async {
await playHapticFeedback();
if (isSuccess) { if (isSuccess) {
await playSuccessBeep(); await playSuccessBeep();
} else { } else {
await playErrorBeep(); await playErrorBeep();
} }
} }
/// Nettoyer les ressources
static Future<void> dispose() async {
await _player.dispose();
}
} }

View File

@@ -286,7 +286,7 @@ class PDFService {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList(); final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList(); final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
pdf.addPage( pdf.addPage(
pw.Page( pw.Page(
pageFormat: PdfPageFormat.a4, pageFormat: PdfPageFormat.a4,
margin: pw.EdgeInsets.zero, margin: pw.EdgeInsets.zero,
@@ -299,10 +299,20 @@ class PDFService {
runSpacing: 0, // 0 espace entre les lignes runSpacing: 0, // 0 espace entre les lignes
children: List.generate(pageItems.length, (i) { children: List.generate(pageItems.length, (i) {
final item = pageItems[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( return pw.Container(
width: labelWidth, width: labelWidth,
height: labelHeight, 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) // Suppression de la décoration (bordure)
child: pw.Row( child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center, crossAxisAlignment: pw.CrossAxisAlignment.center,

View File

@@ -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<void> 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<void> 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<void> 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<bool> 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<void> dispose() async {
try {
await _tts.stop();
} catch (e) {
DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e);
}
}
}

View File

@@ -35,10 +35,10 @@ enum AppPermission {
// ============= MAINTENANCE ============= // ============= MAINTENANCE =============
/// Permet de voir les maintenances /// Permet de voir les maintenances
viewMaintenance('view_maintenance'), viewMaintenance('view_maintenances'),
/// Permet de créer, modifier et supprimer des maintenances /// Permet de créer, modifier et supprimer des maintenances
manageMaintenance('manage_maintenance'), manageMaintenance('manage_maintenances'),
// ============= UTILISATEURS ============= // ============= UTILISATEURS =============
/// Permet de voir la liste de tous les utilisateurs /// Permet de voir la liste de tous les utilisateurs

View File

@@ -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_price_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_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/widgets/equipment/equipment_dates_section.dart';
import 'package:em2rp/views/maintenance_form_page.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
@@ -152,6 +153,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
maintenances: _maintenances, maintenances: _maintenances,
isLoading: _isLoadingMaintenances, isLoading: _isLoadingMaintenances,
hasManagePermission: hasManagePermission, hasManagePermission: hasManagePermission,
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
), ),
], ],
), ),
@@ -175,6 +177,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
maintenances: _maintenances, maintenances: _maintenances,
isLoading: _isLoadingMaintenances, isLoading: _isLoadingMaintenances,
hasManagePermission: hasManagePermission, hasManagePermission: hasManagePermission,
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
EquipmentDatesSection(equipment: widget.equipment), EquipmentDatesSection(equipment: widget.equipment),
@@ -378,6 +381,36 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
); );
} }
/// Planifier une nouvelle maintenance pour cet équipment
Future<void> _planMaintenance() async {
final userProvider = Provider.of<LocalUserProvider>(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<bool>(
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() { void _editEquipment() {
Navigator.push( Navigator.push(
context, context,

View File

@@ -11,6 +11,7 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/qr_code_processing_service.dart'; import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_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/services/equipment_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; 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/equipment/container_checklist_item.dart';
@@ -115,6 +116,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
duration: const Duration(milliseconds: 500), 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 // Vérification de sécurité et chargement après le premier frame
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isCurrentStepCompleted()) { if (_isCurrentStepCompleted()) {
@@ -152,6 +156,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
_animationController.dispose(); _animationController.dispose();
_manualCodeController.dispose(); _manualCodeController.dispose();
_manualCodeFocusNode.dispose(); _manualCodeFocusNode.dispose();
TextToSpeechService.stop();
super.dispose(); super.dispose();
} }
@@ -651,8 +656,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Feedback visuel // Feedback visuel
_showSuccessFeedback(result.message ?? 'Code traité avec succès'); _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) { } else if (result.codeNotFoundInEvent) {
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter // 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
// 🔊 Son d'erreur
await AudioFeedbackService.playFullFeedback(isSuccess: false);
await _handleCodeNotFoundInEvent(code.trim()); await _handleCodeNotFoundInEvent(code.trim());
} else { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1139,31 +1212,42 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( // Nom de l'événement et barre de progression sur la même ligne
_currentEvent.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: LinearProgressIndicator( child: Text(
value: _getProgress(), _currentEvent.name,
backgroundColor: Colors.grey.shade300, style: const TextStyle(
valueColor: AlwaysStoppedAnimation<Color>( fontSize: 18,
allValidated ? Colors.green : AppColors.bleuFonce, fontWeight: FontWeight.bold,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Expanded(
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}', child: Row(
style: const TextStyle( children: [
fontWeight: FontWeight.bold, Expanded(
fontSize: 16, 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, contentPadding: EdgeInsets.zero,
), ),
// 🆕 Champ de saisie manuelle de code // Champ de saisie manuelle avec bouton scanner
const SizedBox(height: 16),
TextField(
controller: _manualCodeController,
focusNode: _manualCodeFocusNode,
decoration: InputDecoration(
labelText: 'Saisie manuelle d\'un code',
hintText: 'Entrez un ID d\'équipement ou container',
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
suffixIcon: _manualCodeController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_manualCodeController.clear();
setState(() {});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onSubmitted: _handleManualCodeEntry,
onChanged: (value) => setState(() {}),
textInputAction: TextInputAction.done,
),
// 🆕 Bouton Scanner QR Code
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton.icon( Row(
onPressed: _openQRScanner, children: [
icon: const Icon(Icons.qr_code_scanner), Expanded(
label: const Text('Scanner QR Code'), child: TextField(
style: ElevatedButton.styleFrom( controller: _manualCodeController,
backgroundColor: Colors.blue[700], focusNode: _manualCodeFocusNode,
padding: const EdgeInsets.symmetric(vertical: 12), 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), const SizedBox(height: 8),
@@ -1255,9 +1347,44 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
), ),
), ),
Expanded( Expanded(
child: ListView( child: LayoutBuilder(
padding: const EdgeInsets.all(16), builder: (context, constraints) {
children: _buildChecklistItems(), // 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,
);
}
},
), ),
), ),
], ],

View File

@@ -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<String>? initialEquipmentIds;
const MaintenanceFormPage({
super.key,
this.maintenance,
this.initialEquipmentIds,
});
@override
State<MaintenanceFormPage> createState() => _MaintenanceFormPageState();
}
class _MaintenanceFormPageState extends State<MaintenanceFormPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _costController = TextEditingController();
final _notesController = TextEditingController();
MaintenanceType _selectedType = MaintenanceType.preventive;
DateTime _scheduledDate = DateTime.now();
final List<String> _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<EquipmentProvider>().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<MaintenanceType>(
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<EquipmentProvider>(
builder: (context, equipmentProvider, _) {
// Filtrer uniquement les équipements
final availableEquipment = equipmentProvider.allEquipment
.cast<EquipmentModel>()
.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<void> _showEquipmentPicker(List<EquipmentModel> availableEquipment) async {
final selectedIds = await showDialog<List<String>>(
context: context,
builder: (context) => _EquipmentPickerDialog(
availableEquipment: availableEquipment,
initialSelectedIds: _selectedEquipmentIds,
),
);
if (selectedIds != null) {
setState(() {
_selectedEquipmentIds.clear();
_selectedEquipmentIds.addAll(selectedIds);
});
}
}
Future<void> _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<void> _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<MaintenanceProvider>().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<MaintenanceProvider>().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<EquipmentModel> availableEquipment;
final List<String> initialSelectedIds;
const _EquipmentPickerDialog({
required this.availableEquipment,
required this.initialSelectedIds,
});
@override
State<_EquipmentPickerDialog> createState() => _EquipmentPickerDialogState();
}
class _EquipmentPickerDialogState extends State<_EquipmentPickerDialog> {
late List<String> _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;
}
}
}

View File

@@ -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<MaintenanceManagementPage> createState() => _MaintenanceManagementPageState();
}
class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
String _filterType = 'all'; // all, upcoming, overdue, completed
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadMaintenances();
});
}
Future<void> _loadMaintenances() async {
final maintenanceProvider = context.read<MaintenanceProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
await Future.wait([
maintenanceProvider.loadMaintenances(),
equipmentProvider.ensureLoaded(),
]);
}
List<MaintenanceModel> _getFilteredMaintenances(List<MaintenanceModel> 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<MaintenanceProvider>(
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<MaintenanceModel> maintenances) {
// Trier par date (les plus récentes/urgentes en premier)
final sortedMaintenances = List<MaintenanceModel>.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<EquipmentProvider>();
final equipmentNames = maintenance.equipmentIds
.map((id) => equipmentProvider.allEquipment
.cast<dynamic>()
.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<void> _completeMaintenance(MaintenanceModel maintenance) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => _CompleteMaintenanceDialog(maintenance: maintenance),
);
if (result != null && mounted) {
try {
await context.read<MaintenanceProvider>().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<void> _deleteMaintenance(MaintenanceModel maintenance) async {
final confirm = await showDialog<bool>(
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<MaintenanceProvider>().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<void> _navigateToForm(MaintenanceModel? maintenance) async {
final result = await Navigator.push<bool>(
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'),
),
],
);
}
}

View File

@@ -8,12 +8,14 @@ class EquipmentMaintenanceHistorySection extends StatelessWidget {
final List<MaintenanceModel> maintenances; final List<MaintenanceModel> maintenances;
final bool isLoading; final bool isLoading;
final bool hasManagePermission; final bool hasManagePermission;
final VoidCallback? onAddMaintenance;
const EquipmentMaintenanceHistorySection({ const EquipmentMaintenanceHistorySection({
super.key, super.key,
required this.maintenances, required this.maintenances,
required this.isLoading, required this.isLoading,
required this.hasManagePermission, required this.hasManagePermission,
this.onAddMaintenance,
}); });
@override @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), const Divider(height: 24),
if (isLoading) if (isLoading)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (maintenances.isEmpty) else if (maintenances.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Center( child: Column(
child: Text( children: [
'Aucune maintenance enregistrée', const Center(
style: TextStyle(color: Colors.grey), 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 else

View File

@@ -5,6 +5,7 @@ import 'package:em2rp/views/my_account_page.dart';
import 'package:em2rp/views/user_management_page.dart'; import 'package:em2rp/views/user_management_page.dart';
import 'package:em2rp/views/data_management_page.dart'; import 'package:em2rp/views/data_management_page.dart';
import 'package:em2rp/views/equipment_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:em2rp/config/app_version.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/image/profile_picture.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( ExpansionTileTheme(
data: const ExpansionTileThemeData( data: const ExpansionTileThemeData(
iconColor: AppColors.noir, iconColor: AppColors.noir,

View File

@@ -61,6 +61,10 @@ dependencies:
# Notifications # Notifications
flutter_local_notifications: ^19.2.1 flutter_local_notifications: ^19.2.1
# Audio & TTS
audioplayers: ^6.1.0
flutter_tts: ^4.2.0
# Export/Import # Export/Import
csv: ^6.0.0 csv: ^6.0.0
web: ^1.1.1 web: ^1.1.1

View File

@@ -1,7 +1,7 @@
{ {
"version": "1.1.5", "version": "1.1.6",
"updateUrl": "https://app.em2events.fr", "updateUrl": "https://app.em2events.fr",
"forceUpdate": true, "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.", "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-18T12:43:19.791Z" "timestamp": "2026-02-18T14:24:45.336Z"
} }