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,111 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:em2rp/models/route_result_model.dart';
import '../../../utils/polyline_utils.dart';
/// Affiche 1 ou 2 itinéraires sur une carte OpenStreetMap.
/// Route TOLL = bleu, Route TOLL_FREE = vert.
class RouteMapWidget extends StatelessWidget {
final List<RouteResult> routes;
final RouteResult? selectedRoute;
const RouteMapWidget({
super.key,
required this.routes,
this.selectedRoute,
});
List<LatLng> _decode(String encoded) {
final pts = safeDecodePolyline(encoded);
// DEBUG: afficher dans la console du navigateur
// ignore: avoid_print
print('[MAP DEBUG] encoded length=${encoded.length}, decoded ${pts.length} points');
if (pts.isNotEmpty) {
// ignore: avoid_print
print('[MAP DEBUG] first=${pts.first.latitude},${pts.first.longitude} last=${pts.last.latitude},${pts.last.longitude}');
}
return pts;
}
LatLngBounds? _computeBounds(List<List<LatLng>> allPoints) {
final flat = allPoints.expand((e) => e).toList();
if (flat.isEmpty) return null;
return LatLngBounds.fromPoints(flat);
}
@override
Widget build(BuildContext context) {
final allPolylines = <Polyline>[];
final allPointGroups = <List<LatLng>>[];
for (final route in routes) {
final pts = _decode(route.encodedPolyline);
if (pts.isEmpty) continue;
allPointGroups.add(pts);
final isSelected =
selectedRoute == null || selectedRoute!.routeType == route.routeType;
final isToll = route.routeType == 'TOLL';
allPolylines.add(Polyline(
points: pts,
strokeWidth: isSelected ? 5.0 : 3.0,
color: isToll
? (isSelected
? const Color(0xFF1565C0)
: const Color(0xFF1565C0).withValues(alpha: 0.4))
: (isSelected
? const Color(0xFF2E7D32)
: const Color(0xFF2E7D32).withValues(alpha: 0.4)),
));
}
final bounds = _computeBounds(allPointGroups);
final mapController = MapController();
// Marqueurs de départ / arrivée
final markers = <Marker>[];
for (final group in allPointGroups) {
if (group.isEmpty) continue;
// Départ
markers.add(Marker(
point: group.first,
width: 32,
height: 32,
child: const Icon(Icons.circle, color: Colors.green, size: 20),
));
// Arrivée
markers.add(Marker(
point: group.last,
width: 32,
height: 32,
child: const Icon(Icons.location_pin, color: Colors.red, size: 32),
));
}
return FlutterMap(
mapController: mapController,
options: MapOptions(
initialCameraFit: bounds != null
? CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(32),
)
: const CameraFit.coordinates(
coordinates: [LatLng(46.2276, 2.2137)], padding: EdgeInsets.all(32)),
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.em2events.em2rp',
),
PolylineLayer(polylines: allPolylines),
MarkerLayer(markers: markers),
],
);
}
}
@@ -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),
),
],
),
);
},
);
},
),
),
],
),
);
}
}
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
import 'package:em2rp/views/widgets/event_form/travel_cost_dialog.dart';
class EventDetailsSection extends StatefulWidget {
final TextEditingController descriptionController;
@@ -11,6 +13,9 @@ class EventDetailsSection extends StatefulWidget {
final TextEditingController contactPhoneController;
final bool isMobile;
final VoidCallback onAnyFieldChanged;
/// Callback appelé quand l'utilisateur sélectionne un itinéraire
/// avec le prix total calculé (pour ajouter l'option FRAIS_KM)
final void Function(double price)? onTravelCostSelected;
const EventDetailsSection({
super.key,
@@ -23,6 +28,7 @@ class EventDetailsSection extends StatefulWidget {
required this.contactPhoneController,
required this.isMobile,
required this.onAnyFieldChanged,
this.onTravelCostSelected,
});
@override
@@ -88,15 +94,44 @@ class _EventDetailsSectionState extends State<EventDetailsSection> {
const SizedBox(height: 20),
TextFormField(
AddressAutocompleteField(
controller: widget.addressController,
decoration: const InputDecoration(
labelText: 'Adresse*',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
label: 'Adresse*',
validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null,
onChanged: (_) => widget.onAnyFieldChanged(),
onSelected: (_) => widget.onAnyFieldChanged(),
),
const SizedBox(height: 12),
// Bouton calcul frais déplacement
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(Icons.route_outlined),
label: const Text('Calculer les frais de déplacement'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.blueGrey[700],
side: BorderSide(color: Colors.blueGrey[300]!),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () async {
final address = widget.addressController.text.trim();
if (address.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez d\'abord renseigner l\'adresse de l\'événement.'),
backgroundColor: Colors.orange,
),
);
return;
}
final price = await showTravelCostDialog(
context: context,
eventAddress: address,
);
if (price != null && widget.onTravelCostSelected != null) {
widget.onTravelCostSelected!(price);
}
},
),
),
],
);
@@ -0,0 +1,659 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/models/vehicle_model.dart';
import 'package:em2rp/models/depot_model.dart';
import 'package:em2rp/models/route_result_model.dart';
import 'package:em2rp/services/vehicle_service.dart';
import 'package:em2rp/services/travel_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/common/route_map_widget.dart';
/// Dialog complète de calcul des frais de déplacement.
/// Retourne le prix calculé si l'utilisateur sélectionne un itinéraire,
/// null sinon.
Future<double?> showTravelCostDialog({
required BuildContext context,
required String eventAddress,
}) {
return showDialog<double>(
context: context,
barrierDismissible: false,
builder: (ctx) => TravelCostDialog(eventAddress: eventAddress),
);
}
class TravelCostDialog extends StatefulWidget {
final String eventAddress;
const TravelCostDialog({super.key, required this.eventAddress});
@override
State<TravelCostDialog> createState() => _TravelCostDialogState();
}
class _TravelCostDialogState extends State<TravelCostDialog> {
final _vehicleService = VehicleService();
final _travelService = TravelService();
// Données chargées
List<VehicleModel> _vehicles = [];
List<DepotModel> _depots = [];
FuelPrices _fuelPrices = const FuelPrices();
// Sélections
VehicleModel? _selectedVehicle;
DepotModel? _selectedDepot;
int _nbTechnicians = 2;
double _hourlyRate = 25.0;
bool _applyFreeZone = false;
// Résultats
List<RouteResult> _routes = [];
RouteResult? _selectedRoute;
// État
bool _isLoadingData = true;
bool _isCalculating = false;
String? _error;
final _hourlyCtrl = TextEditingController(text: '25');
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_hourlyCtrl.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() => _isLoadingData = true);
try {
final results = await Future.wait([
_vehicleService.getVehicles(),
_travelService.getDepots(),
_travelService.getFuelPrices(),
]);
final vehicles = results[0] as List<VehicleModel>;
final depots = results[1] as List<DepotModel>;
final prices = results[2] as FuelPrices;
if (mounted) {
setState(() {
_vehicles = vehicles;
_depots = depots;
_fuelPrices = prices;
_selectedVehicle = vehicles.isNotEmpty ? vehicles.first : null;
_selectedDepot = depots.isNotEmpty ? depots.first : null;
_isLoadingData = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Erreur lors du chargement: $e';
_isLoadingData = false;
});
}
}
}
Future<void> _calculate() async {
if (_selectedVehicle == null || _selectedDepot == null) return;
setState(() {
_isCalculating = true;
_error = null;
_routes = [];
_selectedRoute = null;
});
try {
final routes = await _travelService.computeRoutes(
origin: _selectedDepot!.address,
destination: widget.eventAddress,
vehicleTollCategory: _selectedVehicle!.tollCategoryId,
);
if (mounted) {
setState(() {
_routes = routes;
_isCalculating = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Erreur de calcul: $e';
_isCalculating = false;
});
}
}
}
void _selectRoute(RouteResult route) {
final total = route.totalCost(
consumptionPer100km: _selectedVehicle!.consumptionPer100km,
fuelPricePerLiter: _fuelPrices.priceForFuelType(_selectedVehicle!.fuelType),
maintenanceCostPerKm: _selectedVehicle!.maintenanceCostPerKm,
nbTechnicians: _nbTechnicians,
hourlyRate: _hourlyRate,
applyFreeZone: _applyFreeZone,
);
Navigator.of(context).pop(total);
}
String _formatDuration(int seconds) {
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
if (h == 0) return '${m}min';
return '${h}h${m.toString().padLeft(2, '0')}';
}
String _formatDistance(int meters) {
final km = meters / 1000.0;
return '${km.toStringAsFixed(0)} km';
}
@override
Widget build(BuildContext context) {
final screenW = MediaQuery.of(context).size.width;
final isWide = screenW > 900;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isWide ? 900 : 600,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
Flexible(child: _buildBody()),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
const Icon(Icons.route, color: Colors.white, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Calculer les frais de déplacement',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
widget.eventAddress,
style: const TextStyle(color: Colors.white70, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(null),
),
],
),
);
}
Widget _buildBody() {
if (_isLoadingData) {
return const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
);
}
if (_routes.isEmpty) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: _buildConfigPanel(),
);
}
// Résultats
final screenW = MediaQuery.of(context).size.width;
final isWide = screenW > 900;
if (isWide) {
return Row(
children: [
Expanded(
flex: 3,
child: RouteMapWidget(routes: _routes, selectedRoute: _selectedRoute),
),
Expanded(
flex: 2,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _buildResultsPanel(),
),
),
],
);
} else {
return SingleChildScrollView(
child: Column(
children: [
SizedBox(
height: 260,
child: RouteMapWidget(routes: _routes, selectedRoute: _selectedRoute),
),
Padding(
padding: const EdgeInsets.all(16),
child: _buildResultsPanel(),
),
],
),
);
}
}
Widget _buildConfigPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_error != null)
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red[200]!),
),
child: Row(children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red))),
]),
),
// Dépôt de départ
_sectionTitle('Dépôt de départ'),
if (_depots.isEmpty)
_emptyHint(
'Aucun dépôt configuré. Ajoutez-en un dans Gestion des données → Dépôts.')
else
DropdownButtonFormField<DepotModel>(
value: _selectedDepot,
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.warehouse_outlined),
),
items: _depots
.map((d) => DropdownMenuItem(
value: d,
child: Text(
'${d.name}${d.address}',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (v) => setState(() => _selectedDepot = v),
),
const SizedBox(height: 20),
// Véhicule
_sectionTitle('Véhicule'),
if (_vehicles.isEmpty)
_emptyHint(
'Aucun véhicule configuré. Ajoutez-en un dans Gestion des données → Véhicules.')
else
DropdownButtonFormField<VehicleModel>(
value: _selectedVehicle,
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.directions_car_outlined),
),
items: _vehicles
.map((v) => DropdownMenuItem(
value: v,
child: Text(
'${v.name}${v.consumptionPer100km} ${v.consumptionUnit} | Cat. péage ${v.tollCategoryId}',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (v) => setState(() => _selectedVehicle = v),
),
const SizedBox(height: 20),
// Techniciens + taux horaire
_sectionTitle('Main-d\'œuvre'),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Nb techniciens',
style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: _nbTechnicians > 1
? () => setState(() => _nbTechnicians--)
: null,
),
Text(
'$_nbTechnicians',
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => setState(() => _nbTechnicians++),
),
],
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _hourlyCtrl,
decoration: const InputDecoration(
labelText: 'Taux horaire',
border: OutlineInputBorder(),
suffixText: '€/h',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (v) {
final parsed = double.tryParse(v);
if (parsed != null) setState(() => _hourlyRate = parsed);
},
),
),
],
),
const SizedBox(height: 16),
// Zone de gratuité
CheckboxListTile(
value: _applyFreeZone,
onChanged: (v) => setState(() => _applyFreeZone = v ?? false),
activeColor: AppColors.rouge,
title: const Text('Appliquer la zone de gratuité'),
subtitle:
const Text('Déduit 20 km (carburant + maintenance) et 20 min (main-d\'œuvre)'),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 24),
// Bouton Calculer
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: _isCalculating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.calculate_outlined),
label: Text(_isCalculating ? 'Calcul en cours...' : 'Calculer le trajet'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: const TextStyle(fontSize: 16),
),
onPressed: (_isCalculating ||
_selectedVehicle == null ||
_selectedDepot == null)
? null
: _calculate,
),
),
],
);
}
Widget _buildResultsPanel() {
final vehicle = _selectedVehicle!;
final fuelPrice = _fuelPrices.priceForFuelType(vehicle.fuelType);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_sectionTitle('Itinéraires'),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Recalculer'),
onPressed: () => setState(() {
_routes = [];
_selectedRoute = null;
}),
),
],
),
// Légende
Row(children: [
_legendDot(const Color(0xFF1565C0)),
const SizedBox(width: 4),
const Text('Avec péage', style: TextStyle(fontSize: 12)),
const SizedBox(width: 16),
_legendDot(const Color(0xFF2E7D32)),
const SizedBox(width: 4),
const Text('Sans péage', style: TextStyle(fontSize: 12)),
]),
const SizedBox(height: 12),
..._routes.map((route) {
final total = route.totalCost(
consumptionPer100km: vehicle.consumptionPer100km,
fuelPricePerLiter: fuelPrice,
maintenanceCostPerKm: vehicle.maintenanceCostPerKm,
nbTechnicians: _nbTechnicians,
hourlyRate: _hourlyRate,
applyFreeZone: _applyFreeZone,
);
final fuel = route.fuelCost(
consumptionPer100km: vehicle.consumptionPer100km,
fuelPricePerLiter: fuelPrice,
freeZoneKm: _applyFreeZone ? 20 : 0,
);
final maint = route.maintenanceCost(
costPerKm: vehicle.maintenanceCostPerKm,
freeZoneKm: _applyFreeZone ? 20 : 0,
);
final labor = route.laborCost(
nbTechnicians: _nbTechnicians,
hourlyRate: _hourlyRate,
freeZoneMinutes: _applyFreeZone ? 20 : 0,
);
final isToll = route.routeType == 'TOLL';
final color = isToll
? const Color(0xFF1565C0)
: const Color(0xFF2E7D32);
return _buildRouteCard(
route: route,
color: color,
label: isToll ? '🚗 Route la plus rapide' : '🛣️ Route sans péage',
total: total,
fuel: fuel,
maint: maint,
labor: labor,
);
}),
],
);
}
Widget _buildRouteCard({
required RouteResult route,
required Color color,
required String label,
required double total,
required double fuel,
required double maint,
required double labor,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
// En-tête
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: const BorderRadius.vertical(top: Radius.circular(10)),
),
child: Row(
children: [
Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
fontSize: 14,
),
),
const Spacer(),
Text(
_formatDistance(route.distanceMeters),
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
Text(
_formatDuration(route.durationSeconds),
style: TextStyle(color: color),
),
],
),
),
// Détail des coûts
Padding(
padding: const EdgeInsets.all(14),
child: Column(
children: [
_costRow('⛽ Carburant', fuel),
_costRow('🔧 Maintenance', maint),
_costRow('👷 Main-d\'œuvre (×$_nbTechnicians)', labor),
if (route.tollCost > 0) _costRow('🛣️ Péage', route.tollCost),
const Divider(),
_costRow('Total', total, bold: true, large: true),
],
),
),
// Bouton Sélectionner
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
),
onPressed: () => _selectRoute(route),
child: Text(
'Sélectionner — ${total.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
],
),
);
}
Widget _costRow(String label, double amount,
{bool bold = false, bool large = false}) {
final style = TextStyle(
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
fontSize: large ? 15 : 13,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: style),
Text('${amount.toStringAsFixed(2)}',
style: style.copyWith(
color: bold ? AppColors.rouge : Colors.black87)),
],
),
);
}
Widget _legendDot(Color color) => Container(
width: 14,
height: 14,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
Widget _sectionTitle(String title) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
);
Widget _emptyHint(String msg) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange[200]!),
),
child:
Row(children: [const Icon(Icons.info_outline, color: Colors.orange), const SizedBox(width: 8), Expanded(child: Text(msg, style: const TextStyle(fontSize: 12)))]),
);
}
@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:em2rp/services/travel_service.dart';
/// Champ texte avec autocomplétion d'adresses via Google Places.
/// Remplace le TextFormField standard pour le champ adresse.
class AddressAutocompleteField extends StatefulWidget {
final TextEditingController controller;
final String label;
final String? Function(String?)? validator;
final void Function(String)? onSelected;
final InputDecoration? decoration;
const AddressAutocompleteField({
super.key,
required this.controller,
this.label = 'Adresse',
this.validator,
this.onSelected,
this.decoration,
});
@override
State<AddressAutocompleteField> createState() =>
_AddressAutocompleteFieldState();
}
class _AddressAutocompleteFieldState extends State<AddressAutocompleteField> {
final _service = TravelService();
final _focusNode = FocusNode();
final _overlayKey = GlobalKey();
List<String> _suggestions = [];
Timer? _debounce;
OverlayEntry? _overlayEntry;
final _layerLink = LayerLink();
@override
void initState() {
super.initState();
widget.controller.addListener(_onTextChanged);
_focusNode.addListener(() {
if (!_focusNode.hasFocus) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted && !_focusNode.hasFocus) {
_removeOverlay();
}
});
}
});
}
void _onTextChanged() {
_debounce?.cancel();
final text = widget.controller.text;
if (text.length < 3) {
_removeOverlay();
return;
}
_debounce = Timer(const Duration(milliseconds: 400), () async {
final results = await _service.autocompleteAddress(text);
if (mounted) {
setState(() => _suggestions = results);
if (results.isNotEmpty && _focusNode.hasFocus) {
_showSuggestions();
} else {
_removeOverlay();
}
}
});
}
void _showSuggestions() {
_removeOverlay();
final overlay = Overlay.of(context);
_overlayEntry = OverlayEntry(
builder: (ctx) => Positioned(
width: _getFieldWidth(),
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0, 56),
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _suggestions.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, indent: 16),
itemBuilder: (ctx, i) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (_) {
widget.controller.text = _suggestions[i];
widget.controller.selection = TextSelection.fromPosition(
TextPosition(offset: _suggestions[i].length),
);
widget.onSelected?.call(_suggestions[i]);
_removeOverlay();
_focusNode.unfocus();
},
child: ListTile(
dense: true,
leading: const Icon(Icons.location_on_outlined, size: 18),
title: Text(
_suggestions[i],
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
);
},
),
),
),
),
),
);
overlay.insert(_overlayEntry!);
}
double _getFieldWidth() {
final rb = context.findRenderObject() as RenderBox?;
return rb?.size.width ?? 300;
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
if (mounted) setState(() {});
}
@override
void dispose() {
_debounce?.cancel();
widget.controller.removeListener(_onTextChanged);
_focusNode.dispose();
_removeOverlay();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
key: _overlayKey,
controller: widget.controller,
focusNode: _focusNode,
decoration: widget.decoration ??
InputDecoration(
labelText: widget.label,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.location_on_outlined),
suffixIcon: widget.controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
widget.controller.clear();
_removeOverlay();
},
)
: null,
),
validator: widget.validator,
onChanged: (_) {},
),
);
}
}