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

@@ -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,

View File

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

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 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

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/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,