Merge branch 'feature/travel-cost-calculator' into main
This commit is contained in:
@@ -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)))]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user