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
@@ -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)))]),
);
}