Merge branch 'feature/travel-cost-calculator' into main

This commit is contained in:
ElPoyo
2026-06-05 15:04:12 +02:00
61 changed files with 24723 additions and 8 deletions
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/depot_model.dart';
import 'package:em2rp/services/travel_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
class DepotManagement extends StatefulWidget {
const DepotManagement({super.key});
@override
State<DepotManagement> createState() => _DepotManagementState();
}
class _DepotManagementState extends State<DepotManagement> {
final _service = TravelService();
bool _isLoading = false;
void _showDepotDialog({DepotModel? depot}) {
final nameCtrl = TextEditingController(text: depot?.name ?? '');
final addressCtrl = TextEditingController(text: depot?.address ?? '');
final formKey = GlobalKey<FormState>();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(depot == null ? 'Ajouter un dépôt' : 'Modifier le dépôt'),
content: SizedBox(
width: 420,
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nom du dépôt *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.warehouse_outlined),
hintText: 'ex: Dépôt principal',
),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Requis' : null,
),
const SizedBox(height: 16),
AddressAutocompleteField(
controller: addressCtrl,
label: 'Adresse du dépôt *',
validator: (v) =>
v == null || v.trim().isEmpty ? 'Requis' : null,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () async {
if (!formKey.currentState!.validate()) return;
Navigator.pop(ctx);
setState(() => _isLoading = true);
try {
if (depot == null) {
await _service.addDepot(DepotModel(
id: '',
name: nameCtrl.text.trim(),
address: addressCtrl.text.trim(),
));
} else {
await _service.updateDepot(depot.copyWith(
name: nameCtrl.text.trim(),
address: addressCtrl.text.trim(),
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
},
child: Text(depot == null ? 'Ajouter' : 'Enregistrer'),
),
],
),
);
}
Future<void> _delete(DepotModel depot) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer le dépôt'),
content: Text('Supprimer "${depot.name}" ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
setState(() => _isLoading = true);
try {
await _service.deleteDepot(depot.id);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warehouse_outlined, color: AppColors.rouge, size: 28),
const SizedBox(width: 12),
Text(
'Dépôts',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Ajouter un dépôt'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () => _showDepotDialog(),
),
],
),
const SizedBox(height: 8),
Text(
'Définissez les adresses de départ pour le calcul des frais de déplacement.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 24),
if (_isLoading) const Center(child: CircularProgressIndicator()),
Expanded(
child: StreamBuilder<List<DepotModel>>(
stream: _service.watchDepots(),
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final depots = snap.data ?? [];
if (depots.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warehouse_outlined,
size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Text(
'Aucun dépôt configuré',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.grey[500]),
),
const SizedBox(height: 8),
const Text('Ajoutez un dépôt pour commencer.'),
],
),
);
}
return ListView.separated(
itemCount: depots.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final d = depots[i];
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.rouge.withValues(alpha: 0.1),
child: Icon(Icons.warehouse_outlined, color: AppColors.rouge),
),
title: Text(d.name,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(d.address),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Modifier',
onPressed: () => _showDepotDialog(depot: d),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _delete(d),
),
],
),
);
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/models/route_result_model.dart';
import 'package:em2rp/services/travel_service.dart';
import 'package:em2rp/utils/colors.dart';
class FuelPricesManagement extends StatefulWidget {
const FuelPricesManagement({super.key});
@override
State<FuelPricesManagement> createState() => _FuelPricesManagementState();
}
class _FuelPricesManagementState extends State<FuelPricesManagement> {
final _service = TravelService();
final _formKey = GlobalKey<FormState>();
final _dieselCtrl = TextEditingController();
final _essenceCtrl = TextEditingController();
final _electriqueCtrl = TextEditingController();
bool _isLoading = true;
bool _isSaving = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
final prices = await _service.getFuelPrices();
_dieselCtrl.text = prices.diesel.toStringAsFixed(3);
_essenceCtrl.text = prices.essence.toStringAsFixed(3);
_electriqueCtrl.text = prices.electricite.toStringAsFixed(3);
if (mounted) setState(() => _isLoading = false);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSaving = true);
try {
final prices = FuelPrices(
diesel: double.parse(_dieselCtrl.text.trim()),
essence: double.parse(_essenceCtrl.text.trim()),
electricite: double.parse(_electriqueCtrl.text.trim()),
);
await _service.saveFuelPrices(prices);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Prix mis à jour ✓'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
void dispose() {
_dieselCtrl.dispose();
_essenceCtrl.dispose();
_electriqueCtrl.dispose();
super.dispose();
}
Widget _buildPriceField({
required TextEditingController controller,
required String label,
required String unit,
required IconData icon,
required Color color,
}) {
return Row(
children: [
CircleAvatar(
backgroundColor: color.withValues(alpha: 0.1),
child: Icon(icon, color: color),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixText: unit,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,3}'))
],
validator: (v) {
if (v == null || v.isEmpty) return 'Requis';
final parsed = double.tryParse(v);
if (parsed == null || parsed <= 0) return 'Valeur invalide';
return null;
},
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.local_gas_station, color: AppColors.rouge, size: 28),
const SizedBox(width: 12),
Text(
'Prix des carburants',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
Text(
'Ces prix sont utilisés pour calculer automatiquement le coût en carburant des déplacements.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 32),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else
Form(
key: _formKey,
child: Column(
children: [
_buildPriceField(
controller: _dieselCtrl,
label: 'Prix du Diesel',
unit: '€/L',
icon: Icons.local_gas_station,
color: Colors.blue,
),
const SizedBox(height: 20),
_buildPriceField(
controller: _essenceCtrl,
label: 'Prix de l\'Essence',
unit: '€/L',
icon: Icons.local_gas_station,
color: Colors.orange,
),
const SizedBox(height: 20),
_buildPriceField(
controller: _electriqueCtrl,
label: 'Prix de l\'Électricité',
unit: '€/kWh',
icon: Icons.electric_bolt,
color: Colors.amber,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.save_outlined),
label: Text(_isSaving ? 'Enregistrement...' : 'Enregistrer les prix'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: _isSaving ? null : _save,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,381 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/models/vehicle_model.dart';
import 'package:em2rp/services/vehicle_service.dart';
import 'package:em2rp/utils/colors.dart';
class VehiclesManagement extends StatefulWidget {
const VehiclesManagement({super.key});
@override
State<VehiclesManagement> createState() => _VehiclesManagementState();
}
class _VehiclesManagementState extends State<VehiclesManagement> {
final _service = VehicleService();
bool _isLoading = false;
static const _fuelTypes = ['Diesel', 'Essence', 'Electrique'];
void _showVehicleDialog({VehicleModel? vehicle}) {
final formKey = GlobalKey<FormState>();
final nameCtrl =
TextEditingController(text: vehicle?.name ?? '');
final consoCtrl = TextEditingController(
text: vehicle?.consumptionPer100km.toString() ?? '');
final maintCtrl = TextEditingController(
text: vehicle?.maintenanceCostPerKm.toString() ?? '');
String fuelType = vehicle?.fuelType ?? 'Diesel';
int tollCategory = vehicle?.tollCategoryId ?? 2;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, setDlg) {
return AlertDialog(
title: Text(vehicle == null ? 'Ajouter un véhicule' : 'Modifier le véhicule'),
content: SizedBox(
width: 480,
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Nom
TextFormField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nom du véhicule *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.directions_car_outlined),
hintText: 'ex: Renault Master',
),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Requis' : null,
),
const SizedBox(height: 14),
// Type de carburant
DropdownButtonFormField<String>(
value: fuelType,
decoration: const InputDecoration(
labelText: 'Type de carburant *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.local_gas_station_outlined),
),
items: _fuelTypes
.map((f) => DropdownMenuItem(value: f, child: Text(f)))
.toList(),
onChanged: (v) => setDlg(() => fuelType = v!),
),
const SizedBox(height: 14),
// Consommation
TextFormField(
controller: consoCtrl,
decoration: InputDecoration(
labelText: fuelType == 'Electrique'
? 'Consommation (kWh/100km) *'
: 'Consommation (L/100km) *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.speed_outlined),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
validator: (v) {
if (v == null || v.isEmpty) return 'Requis';
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
const SizedBox(height: 14),
// Coût maintenance
TextFormField(
controller: maintCtrl,
decoration: const InputDecoration(
labelText: 'Coût maintenance (€/km) *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.build_outlined),
hintText: 'ex: 0.08',
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,3}'))
],
validator: (v) {
if (v == null || v.isEmpty) return 'Requis';
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
const SizedBox(height: 14),
// Catégorie péage
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Catégorie de péage : $tollCategory',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: List.generate(5, (i) {
final cat = i + 1;
return Expanded(
child: GestureDetector(
onTap: () => setDlg(() => tollCategory = cat),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: tollCategory == cat
? AppColors.rouge
: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'$cat',
style: TextStyle(
color: tollCategory == cat
? Colors.white
: Colors.black87,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}),
),
const SizedBox(height: 4),
Text(
'Classe 1 Véhicules légers\n'
'Classe 2 Véhicules intermédiaires\n'
'Classe 3 Poids lourds, autocars et autres véhicules à 2 essieux\n'
'Classe 4 - Poids lourds et autres véhicules à 3 essieux et plus\n'
'Classe 5 Motos, side-cars et trikes',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () async {
if (!formKey.currentState!.validate()) return;
Navigator.pop(ctx);
setState(() => _isLoading = true);
try {
final v = VehicleModel(
id: vehicle?.id ?? '',
name: nameCtrl.text.trim(),
fuelType: fuelType,
consumptionPer100km:
double.parse(consoCtrl.text.trim()),
maintenanceCostPerKm:
double.parse(maintCtrl.text.trim()),
tollCategoryId: tollCategory,
);
if (vehicle == null) {
await _service.addVehicle(v);
} else {
await _service.updateVehicle(v);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
},
child: Text(vehicle == null ? 'Ajouter' : 'Enregistrer'),
),
],
);
}),
);
}
Future<void> _delete(VehicleModel v) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer le véhicule'),
content: Text('Supprimer "${v.name}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
setState(() => _isLoading = true);
try {
await _service.deleteVehicle(v.id);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}
Icon _fuelIcon(String fuelType) {
switch (fuelType) {
case 'Electrique':
return const Icon(Icons.electric_bolt, color: Colors.amber);
case 'Essence':
return const Icon(Icons.local_gas_station, color: Colors.green);
default:
return const Icon(Icons.local_gas_station, color: Colors.orange);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.directions_car_outlined,
color: AppColors.rouge, size: 28),
const SizedBox(width: 12),
Text(
'Véhicules',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Ajouter un véhicule'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () => _showVehicleDialog(),
),
],
),
const SizedBox(height: 8),
Text(
'Paramétrez la flotte de véhicules pour le calcul automatique des frais de déplacement.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 24),
if (_isLoading) const LinearProgressIndicator(),
Expanded(
child: StreamBuilder<List<VehicleModel>>(
stream: _service.watchVehicles(),
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final vehicles = snap.data ?? [];
if (vehicles.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.directions_car_outlined,
size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Text(
'Aucun véhicule',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.grey[500]),
),
const SizedBox(height: 8),
const Text('Ajoutez des véhicules pour commencer.'),
],
),
);
}
return ListView.separated(
itemCount: vehicles.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final v = vehicles[i];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.grey[100],
child: _fuelIcon(v.fuelType),
),
title: Text(v.name,
style:
const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
'${v.consumptionPer100km} ${v.consumptionUnit} • Maint. ${v.maintenanceCostPerKm} €/km • Classe péage ${v.tollCategoryId}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Chip(
label: Text(v.fuelType),
backgroundColor: v.fuelType == 'Electrique'
? Colors.amber[50]
: v.fuelType == 'Essence'
? Colors.green[50]
: Colors.orange[50],
side: BorderSide.none,
),
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Modifier',
onPressed: () => _showVehicleDialog(vehicle: v),
),
IconButton(
icon: const Icon(Icons.delete_outline,
color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _delete(v),
),
],
),
);
},
);
},
),
),
],
),
);
}
}