feat: calculateur de frais de déplacement - backend et modèles Flutter

- Cloud Function travel.js : autocomplete Google Places + calcul itinéraires
  via Google Routes API avec péages Ulys /legs (precision=6) + /rate
- Modèles : VehicleModel, DepotModel, RouteResultModel + FuelPrices
- Services : VehicleService, TravelService (Firestore CRUD + API calls)
- Gestion des données : 3 nouveaux onglets (Dépôts, Véhicules, Prix carburants)
- Autocomplétion adresse dans le formulaire événement
- Dialog calcul frais : config + carte flutter_map OSM + sélection itinéraire
- Injection option FRAIS_KM dans l'événement à la sélection
- flutter_map 7.0.2 + latlong2 0.9.1 ajoutés
- npm: csv-parser + @mapbox/polyline installés dans functions
This commit is contained in:
ElPoyo
2026-06-04 14:28:22 +02:00
parent bc93f3fa9a
commit e14b333a67
22 changed files with 3940 additions and 24 deletions
@@ -0,0 +1,377 @@
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(
'1: motos • 2: VP/VUL ≤3.5t • 3: camions 2 essieux • 4: camions 3 essieux • 5: camions 4+ essieux',
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.orange);
default:
return const Icon(Icons.local_gas_station, color: Colors.blue);
}
}
@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 • Cat. 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.orange[50]
: Colors.blue[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),
),
],
),
);
},
);
},
),
),
],
),
);
}
}