feat: Ajout de la gestion des maintenances et intégration de la synthèse vocale
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user