feat: Ajout de la gestion des maintenances et intégration de la synthèse vocale
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<MaintenanceModel> _maintenances = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
// Getters
|
||||
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
|
||||
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
||||
@@ -24,9 +49,9 @@ class MaintenanceProvider extends ChangeNotifier {
|
||||
Future<void> 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<void> updateMaintenance(String id, Map<String, dynamic> 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<void> 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<MaintenanceModel> get overdueMaintances {
|
||||
List<MaintenanceModel> get overdueMaintenances {
|
||||
return _maintenances.where((m) => m.isOverdue).toList();
|
||||
}
|
||||
|
||||
@@ -102,5 +127,12 @@ class MaintenanceProvider extends ChangeNotifier {
|
||||
List<MaintenanceModel> get upcomingMaintenances {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> 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<void> 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<void> playFullFeedback({bool isSuccess = true}) async {
|
||||
await playHapticFeedback();
|
||||
if (isSuccess) {
|
||||
await playSuccessBeep();
|
||||
} else {
|
||||
await playErrorBeep();
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
static Future<void> dispose() async {
|
||||
await _player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
104
em2rp/lib/services/text_to_speech_service.dart
Normal file
104
em2rp/lib/services/text_to_speech_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<EquipmentDetailPage> {
|
||||
maintenances: _maintenances,
|
||||
isLoading: _isLoadingMaintenances,
|
||||
hasManagePermission: hasManagePermission,
|
||||
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -175,6 +177,7 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
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<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() {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/services/qr_code_processing_service.dart';
|
||||
import 'package:em2rp/services/audio_feedback_service.dart';
|
||||
import 'package:em2rp/services/text_to_speech_service.dart';
|
||||
import 'package:em2rp/services/equipment_service.dart';
|
||||
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
||||
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
||||
@@ -115,6 +116,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
// Initialiser le service de synthèse vocale
|
||||
TextToSpeechService.initialize();
|
||||
|
||||
// Vérification de sécurité et chargement après le premier frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_isCurrentStepCompleted()) {
|
||||
@@ -152,6 +156,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
_animationController.dispose();
|
||||
_manualCodeController.dispose();
|
||||
_manualCodeFocusNode.dispose();
|
||||
TextToSpeechService.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -651,8 +656,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
// Feedback visuel
|
||||
_showSuccessFeedback(result.message ?? 'Code traité avec succès');
|
||||
|
||||
// 🗣️ Annoncer le prochain item après un court délai
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await _announceNextItem();
|
||||
|
||||
} else if (result.codeNotFoundInEvent) {
|
||||
// 🔍 Code non trouvé dans l'événement → proposer de l'ajouter
|
||||
// 🔊 Son d'erreur
|
||||
await AudioFeedbackService.playFullFeedback(isSuccess: false);
|
||||
|
||||
await _handleCodeNotFoundInEvent(code.trim());
|
||||
|
||||
} else {
|
||||
@@ -1116,6 +1128,67 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
}
|
||||
}
|
||||
|
||||
/// Trouve le prochain item non validé à scanner
|
||||
String? _findNextItemToScan() {
|
||||
// Parcourir les items dans l'ordre et trouver le premier non validé
|
||||
|
||||
// 1. Parcourir les containers et leurs équipements
|
||||
for (final containerId in _currentEvent.assignedContainers) {
|
||||
final container = _containerCache[containerId];
|
||||
if (container == null) continue;
|
||||
|
||||
// Vérifier si le container a des équipements non validés
|
||||
bool hasUnvalidatedChild = false;
|
||||
for (final equipmentId in container.equipmentIds) {
|
||||
|
||||
if (_currentEvent.assignedEquipment.any((e) => e.equipmentId == equipmentId)) {
|
||||
final isValidated = _localValidationState[equipmentId] ?? false;
|
||||
if (!isValidated) {
|
||||
hasUnvalidatedChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si le container a des items non validés, retourner le nom du container
|
||||
if (hasUnvalidatedChild) {
|
||||
return container.name;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parcourir les équipements standalone (pas dans un container)
|
||||
final Set<String> equipmentIdsInContainers = {};
|
||||
for (final containerId in _currentEvent.assignedContainers) {
|
||||
final container = _containerCache[containerId];
|
||||
if (container != null) {
|
||||
equipmentIdsInContainers.addAll(container.equipmentIds);
|
||||
}
|
||||
}
|
||||
|
||||
for (final eventEquipment in _currentEvent.assignedEquipment) {
|
||||
if (equipmentIdsInContainers.contains(eventEquipment.equipmentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final isValidated = _localValidationState[eventEquipment.equipmentId] ?? false;
|
||||
if (!isValidated) {
|
||||
final equipment = _equipmentCache[eventEquipment.equipmentId];
|
||||
return equipment?.name ?? 'Équipement ${eventEquipment.equipmentId}';
|
||||
}
|
||||
}
|
||||
|
||||
return null; // Tout est validé
|
||||
}
|
||||
|
||||
/// Annonce vocalement le prochain item à scanner
|
||||
Future<void> _announceNextItem() async {
|
||||
final nextItem = _findNextItemToScan();
|
||||
if (nextItem != null) {
|
||||
await TextToSpeechService.speak('Prochain item: $nextItem');
|
||||
} else {
|
||||
await TextToSpeechService.speak('Tous les items sont validés');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1139,31 +1212,42 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_currentEvent.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Nom de l'événement et barre de progression sur la même ligne
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: _getProgress(),
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
allValidated ? Colors.green : AppColors.bleuFonce,
|
||||
child: Text(
|
||||
_currentEvent.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: _getProgress(),
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
allValidated ? Colors.green : AppColors.bleuFonce,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1193,48 +1277,56 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// 🆕 Champ de saisie manuelle de code
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _manualCodeController,
|
||||
focusNode: _manualCodeFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Saisie manuelle d\'un code',
|
||||
hintText: 'Entrez un ID d\'équipement ou container',
|
||||
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
|
||||
suffixIcon: _manualCodeController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_manualCodeController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onSubmitted: _handleManualCodeEntry,
|
||||
onChanged: (value) => setState(() {}),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
|
||||
// 🆕 Bouton Scanner QR Code
|
||||
// Champ de saisie manuelle avec bouton scanner
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openQRScanner,
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('Scanner QR Code'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue[700],
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _manualCodeController,
|
||||
focusNode: _manualCodeFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Saisie manuelle d\'un code',
|
||||
hintText: 'ID d\'équipement ou container',
|
||||
prefixIcon: const Icon(Icons.keyboard, color: AppColors.bleuFonce),
|
||||
suffixIcon: _manualCodeController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_manualCodeController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.bleuFonce, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
),
|
||||
onSubmitted: _handleManualCodeEntry,
|
||||
onChanged: (value) => setState(() {}),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// IconButton pour scanner QR Code
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[700],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _openQRScanner,
|
||||
icon: const Icon(Icons.qr_code_scanner, color: Colors.white),
|
||||
iconSize: 28,
|
||||
tooltip: 'Scanner QR Code',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
@@ -1255,9 +1347,44 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: _buildChecklistItems(),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Afficher 2 colonnes si la largeur le permet (> 600px)
|
||||
final useColumns = constraints.maxWidth > 600;
|
||||
final items = _buildChecklistItems();
|
||||
|
||||
if (useColumns && items.length > 1) {
|
||||
// Diviser en 2 colonnes
|
||||
final mid = (items.length / 2).ceil();
|
||||
final leftItems = items.sublist(0, mid);
|
||||
final rightItems = items.sublist(mid);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: leftItems,
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: rightItems,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Une seule colonne
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
619
em2rp/lib/views/maintenance_form_page.dart
Normal file
619
em2rp/lib/views/maintenance_form_page.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
627
em2rp/lib/views/maintenance_management_page.dart
Normal file
627
em2rp/lib/views/maintenance_management_page.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ class EquipmentMaintenanceHistorySection extends StatelessWidget {
|
||||
final List<MaintenanceModel> 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user