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:
@@ -256,6 +256,20 @@ class EventFormController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Ajoute ou met à jour l'option FRAIS_KM avec le prix calculé.
|
||||
/// L'option est au format attendu par Firestore : { id: "FRAIS_KM", price: <valeur> }
|
||||
void addTravelCostOption(double price) {
|
||||
// Retirer l'éventuelle option FRAIS_KM existante
|
||||
_selectedOptions.removeWhere((opt) => opt['id'] == 'FRAIS_KM');
|
||||
// Ajouter la nouvelle
|
||||
_selectedOptions.add({
|
||||
'id': 'FRAIS_KM',
|
||||
'price': double.parse(price.toStringAsFixed(2)),
|
||||
});
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||
_assignedEquipment = equipment;
|
||||
_assignedContainers = containers;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class DepotModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const DepotModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory DepotModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
return DepotModel(
|
||||
id: id,
|
||||
name: (map['name'] ?? '').toString(),
|
||||
address: (map['address'] ?? '').toString(),
|
||||
createdAt: map['createdAt'] is Timestamp
|
||||
? (map['createdAt'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
factory DepotModel.fromFirestore(DocumentSnapshot doc) {
|
||||
return DepotModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'address': address,
|
||||
'createdAt': createdAt != null
|
||||
? Timestamp.fromDate(createdAt!)
|
||||
: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
DepotModel copyWith({String? id, String? name, String? address}) {
|
||||
return DepotModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/// Résultat d'un itinéraire calculé par Google Maps + Ulys.
|
||||
class RouteResult {
|
||||
/// 'TOLL' ou 'TOLL_FREE'
|
||||
final String routeType;
|
||||
final int distanceMeters;
|
||||
final int durationSeconds;
|
||||
final String encodedPolyline;
|
||||
final double tollCost;
|
||||
|
||||
const RouteResult({
|
||||
required this.routeType,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
required this.encodedPolyline,
|
||||
required this.tollCost,
|
||||
});
|
||||
|
||||
factory RouteResult.fromMap(Map<String, dynamic> map) {
|
||||
return RouteResult(
|
||||
routeType: (map['routeType'] ?? 'TOLL').toString(),
|
||||
distanceMeters: _parseInt(map['distanceMeters'] ?? 0),
|
||||
durationSeconds: _parseInt(map['durationSeconds'] ?? 0),
|
||||
encodedPolyline: (map['encodedPolyline'] ?? '').toString(),
|
||||
tollCost: _parseDouble(map['tollCost'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isTollFree => routeType == 'TOLL_FREE';
|
||||
|
||||
double get distanceKm => distanceMeters / 1000.0;
|
||||
double get durationMinutes => durationSeconds / 60.0;
|
||||
double get durationHours => durationSeconds / 3600.0;
|
||||
|
||||
/// Calcule le coût carburant.
|
||||
/// [consumptionPer100km] : L/100km (ou kWh/100km si électrique)
|
||||
/// [fuelPricePerLiter] : €/L ou €/kWh
|
||||
/// [freeZoneKm] : km gratuits à déduire (zone de gratuité)
|
||||
double fuelCost({
|
||||
required double consumptionPer100km,
|
||||
required double fuelPricePerLiter,
|
||||
double freeZoneKm = 0,
|
||||
}) {
|
||||
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||
return (effectiveKm / 100.0) * consumptionPer100km * fuelPricePerLiter;
|
||||
}
|
||||
|
||||
/// Calcule le coût de maintenance.
|
||||
double maintenanceCost({
|
||||
required double costPerKm,
|
||||
double freeZoneKm = 0,
|
||||
}) {
|
||||
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||
return effectiveKm * costPerKm;
|
||||
}
|
||||
|
||||
/// Calcule le coût de main-d'œuvre (techniciens).
|
||||
/// [freeZoneMinutes] : minutes gratuites à déduire (zone de gratuité)
|
||||
double laborCost({
|
||||
required int nbTechnicians,
|
||||
required double hourlyRate,
|
||||
double freeZoneMinutes = 0,
|
||||
}) {
|
||||
final effectiveMinutes =
|
||||
(durationMinutes - freeZoneMinutes).clamp(0, double.infinity);
|
||||
return (effectiveMinutes / 60.0) * nbTechnicians * hourlyRate;
|
||||
}
|
||||
|
||||
/// Calcule le coût total pour un aller simple.
|
||||
double totalCost({
|
||||
required double consumptionPer100km,
|
||||
required double fuelPricePerLiter,
|
||||
required double maintenanceCostPerKm,
|
||||
required int nbTechnicians,
|
||||
required double hourlyRate,
|
||||
bool applyFreeZone = false,
|
||||
}) {
|
||||
const freeKm = 20.0;
|
||||
const freeMinutes = 20.0;
|
||||
|
||||
return fuelCost(
|
||||
consumptionPer100km: consumptionPer100km,
|
||||
fuelPricePerLiter: fuelPricePerLiter,
|
||||
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||
) +
|
||||
maintenanceCost(
|
||||
costPerKm: maintenanceCostPerKm,
|
||||
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||
) +
|
||||
laborCost(
|
||||
nbTechnicians: nbTechnicians,
|
||||
hourlyRate: hourlyRate,
|
||||
freeZoneMinutes: applyFreeZone ? freeMinutes : 0,
|
||||
) +
|
||||
tollCost;
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prix des carburants (stocké dans Firestore app_config/fuel_prices)
|
||||
class FuelPrices {
|
||||
final double diesel; // €/L
|
||||
final double essence; // €/L
|
||||
final double electricite; // €/kWh
|
||||
|
||||
const FuelPrices({
|
||||
this.diesel = 1.60,
|
||||
this.essence = 1.75,
|
||||
this.electricite = 0.22,
|
||||
});
|
||||
|
||||
factory FuelPrices.fromMap(Map<String, dynamic> map) {
|
||||
return FuelPrices(
|
||||
diesel: _parseDouble(map['diesel'] ?? 1.60),
|
||||
essence: _parseDouble(map['essence'] ?? 1.75),
|
||||
electricite: _parseDouble(map['electricite'] ?? 0.22),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'diesel': diesel,
|
||||
'essence': essence,
|
||||
'electricite': electricite,
|
||||
};
|
||||
|
||||
double priceForFuelType(String fuelType) {
|
||||
switch (fuelType.toLowerCase()) {
|
||||
case 'diesel':
|
||||
return diesel;
|
||||
case 'essence':
|
||||
return essence;
|
||||
case 'electrique':
|
||||
case 'électrique':
|
||||
return electricite;
|
||||
default:
|
||||
return diesel;
|
||||
}
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class VehicleModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final double consumptionPer100km; // L/100km (ou kWh/100km si électrique)
|
||||
final String fuelType; // 'Diesel', 'Essence', 'Electrique'
|
||||
final double maintenanceCostPerKm; // €/km
|
||||
final int tollCategoryId; // 1 à 5 (catégorie Ulys)
|
||||
final DateTime? createdAt;
|
||||
|
||||
const VehicleModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.consumptionPer100km,
|
||||
required this.fuelType,
|
||||
required this.maintenanceCostPerKm,
|
||||
required this.tollCategoryId,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory VehicleModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
return VehicleModel(
|
||||
id: id,
|
||||
name: (map['name'] ?? '').toString(),
|
||||
consumptionPer100km: _parseDouble(map['consumptionPer100km'] ?? 0),
|
||||
fuelType: (map['fuelType'] ?? 'Diesel').toString(),
|
||||
maintenanceCostPerKm: _parseDouble(map['maintenanceCostPerKm'] ?? 0),
|
||||
tollCategoryId: _parseInt(map['tollCategoryId'] ?? 2),
|
||||
createdAt: map['createdAt'] is Timestamp
|
||||
? (map['createdAt'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
factory VehicleModel.fromFirestore(DocumentSnapshot doc) {
|
||||
return VehicleModel.fromMap(
|
||||
doc.data() as Map<String, dynamic>,
|
||||
doc.id,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'consumptionPer100km': consumptionPer100km,
|
||||
'fuelType': fuelType,
|
||||
'maintenanceCostPerKm': maintenanceCostPerKm,
|
||||
'tollCategoryId': tollCategoryId,
|
||||
'createdAt': createdAt != null
|
||||
? Timestamp.fromDate(createdAt!)
|
||||
: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
VehicleModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? consumptionPer100km,
|
||||
String? fuelType,
|
||||
double? maintenanceCostPerKm,
|
||||
int? tollCategoryId,
|
||||
}) {
|
||||
return VehicleModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
consumptionPer100km: consumptionPer100km ?? this.consumptionPer100km,
|
||||
fuelType: fuelType ?? this.fuelType,
|
||||
maintenanceCostPerKm: maintenanceCostPerKm ?? this.maintenanceCostPerKm,
|
||||
tollCategoryId: tollCategoryId ?? this.tollCategoryId,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Label lisible pour l'unité de consommation
|
||||
String get consumptionUnit {
|
||||
if (fuelType == 'Electrique') return 'kWh/100km';
|
||||
return 'L/100km';
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:em2rp/config/api_config.dart';
|
||||
import 'package:em2rp/models/depot_model.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
class TravelService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
|
||||
// ─── Auth token ───────────────────────────────────────────
|
||||
Future<String?> _getToken() async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
return await user?.getIdToken();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers() async {
|
||||
final token = await _getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Autocomplétion d'adresses ────────────────────────────
|
||||
Future<List<String>> autocompleteAddress(String query) async {
|
||||
if (query.trim().length < 3) return [];
|
||||
try {
|
||||
final headers = await _headers();
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsAutocomplete');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'data': {'query': query}}),
|
||||
);
|
||||
if (response.statusCode != 200) return [];
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final predictions = data['predictions'] as List<dynamic>? ?? [];
|
||||
return predictions
|
||||
.map((p) => (p['description'] ?? '').toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[Travel] autocompleteAddress error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Calcul des itinéraires ───────────────────────────────
|
||||
Future<List<RouteResult>> computeRoutes({
|
||||
required String origin,
|
||||
required String destination,
|
||||
int vehicleTollCategory = 2,
|
||||
}) async {
|
||||
try {
|
||||
final headers = await _headers();
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsComputeRoute');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'data': {
|
||||
'origin': origin,
|
||||
'destination': destination,
|
||||
'vehicleTollCategory': vehicleTollCategory,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body);
|
||||
throw Exception('googleMapsComputeRoute: ${err['error']}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final routes = data['routes'] as List<dynamic>? ?? [];
|
||||
return routes
|
||||
.map((r) => RouteResult.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[Travel] computeRoutes error', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prix des carburants ───────────────────────────────────
|
||||
Future<FuelPrices> getFuelPrices() async {
|
||||
try {
|
||||
final doc = await _db.collection('app_config').doc('fuel_prices').get();
|
||||
if (!doc.exists) return const FuelPrices();
|
||||
return FuelPrices.fromMap(doc.data()!);
|
||||
} catch (e) {
|
||||
return const FuelPrices();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFuelPrices(FuelPrices prices) async {
|
||||
await _db.collection('app_config').doc('fuel_prices').set(prices.toMap());
|
||||
}
|
||||
|
||||
// ─── Dépôts ───────────────────────────────────────────────
|
||||
Future<List<DepotModel>> getDepots() async {
|
||||
final snap = await _db.collection('depots').orderBy('name').get();
|
||||
return snap.docs.map((d) => DepotModel.fromFirestore(d)).toList();
|
||||
}
|
||||
|
||||
Stream<List<DepotModel>> watchDepots() {
|
||||
return _db
|
||||
.collection('depots')
|
||||
.orderBy('name')
|
||||
.snapshots()
|
||||
.map((s) => s.docs.map((d) => DepotModel.fromFirestore(d)).toList());
|
||||
}
|
||||
|
||||
Future<String> addDepot(DepotModel depot) async {
|
||||
final ref = await _db.collection('depots').add(depot.toMap());
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
Future<void> updateDepot(DepotModel depot) async {
|
||||
final map = depot.toMap();
|
||||
map.remove('createdAt');
|
||||
await _db.collection('depots').doc(depot.id).update(map);
|
||||
}
|
||||
|
||||
Future<void> deleteDepot(String depotId) async {
|
||||
await _db.collection('depots').doc(depotId).delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance singleton
|
||||
final travelService = TravelService();
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/vehicle_model.dart';
|
||||
|
||||
class VehicleService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
static const String _collection = 'vehicles';
|
||||
|
||||
/// Récupère tous les véhicules, triés par nom.
|
||||
Future<List<VehicleModel>> getVehicles() async {
|
||||
final snapshot = await _db
|
||||
.collection(_collection)
|
||||
.orderBy('name')
|
||||
.get();
|
||||
return snapshot.docs
|
||||
.map((doc) => VehicleModel.fromFirestore(doc))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Stream en temps réel
|
||||
Stream<List<VehicleModel>> watchVehicles() {
|
||||
return _db
|
||||
.collection(_collection)
|
||||
.orderBy('name')
|
||||
.snapshots()
|
||||
.map((snap) =>
|
||||
snap.docs.map((d) => VehicleModel.fromFirestore(d)).toList());
|
||||
}
|
||||
|
||||
/// Ajoute un véhicule
|
||||
Future<String> addVehicle(VehicleModel vehicle) async {
|
||||
final ref = await _db.collection(_collection).add(vehicle.toMap());
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
/// Modifie un véhicule existant
|
||||
Future<void> updateVehicle(VehicleModel vehicle) async {
|
||||
final map = vehicle.toMap();
|
||||
map.remove('createdAt'); // Ne pas écraser la date de création
|
||||
await _db.collection(_collection).doc(vehicle.id).update(map);
|
||||
}
|
||||
|
||||
/// Supprime un véhicule
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
await _db.collection(_collection).doc(vehicleId).delete();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/depot_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/vehicles_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/fuel_prices_management.dart';
|
||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/utils/permission_gate.dart';
|
||||
@@ -32,6 +35,21 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
||||
icon: Icons.file_download,
|
||||
widget: const EventsExport(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Dépôts',
|
||||
icon: Icons.warehouse_outlined,
|
||||
widget: const DepotManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Véhicules',
|
||||
icon: Icons.directions_car_outlined,
|
||||
widget: const VehiclesManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Prix carburants',
|
||||
icon: Icons.local_gas_station,
|
||||
widget: const FuelPricesManagement(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -248,6 +248,17 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
contactPhoneController: controller.contactPhoneController,
|
||||
isMobile: isMobile,
|
||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||
onTravelCostSelected: (price) {
|
||||
controller.addTravelCostOption(price);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Frais de déplacement ajoutés : ${price.toStringAsFixed(2)} €'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
EventStaffAndDocumentsSection(
|
||||
allUsers: controller.allUsers,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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';
|
||||
|
||||
/// 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,
|
||||
});
|
||||
|
||||
/// Décode une polyline Google encodée en liste de LatLng.
|
||||
List<LatLng> _decode(String encoded) {
|
||||
if (encoded.isEmpty) return [];
|
||||
try {
|
||||
final result = <LatLng>[];
|
||||
int index = 0, lat = 0, lng = 0;
|
||||
final len = encoded.length;
|
||||
|
||||
while (index < len) {
|
||||
int shift = 0, result0 = 0;
|
||||
int b;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result0 |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
final dlat = (result0 & 1) != 0 ? ~(result0 >> 1) : (result0 >> 1);
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result0 = 0;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result0 |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
final dlng = (result0 & 1) != 0 ? ~(result0 >> 1) : (result0 >> 1);
|
||||
lng += dlng;
|
||||
|
||||
result.add(LatLng(lat / 1e5, lng / 1e5));
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
LatLngBounds? _computeBounds(List<List<LatLng>> allPoints) {
|
||||
double? minLat, maxLat, minLng, maxLng;
|
||||
for (final pts in allPoints) {
|
||||
for (final p in pts) {
|
||||
minLat = minLat == null ? p.latitude : p.latitude < minLat ? p.latitude : minLat;
|
||||
maxLat = maxLat == null ? p.latitude : p.latitude > maxLat ? p.latitude : maxLat;
|
||||
minLng = minLng == null ? p.longitude : p.longitude < minLng ? p.longitude : minLng;
|
||||
maxLng = maxLng == null ? p.longitude : p.longitude > maxLng ? p.longitude : maxLng;
|
||||
}
|
||||
}
|
||||
if (minLat == null) return null;
|
||||
return LatLngBounds(
|
||||
LatLng(minLat - 0.02, minLng! - 0.02),
|
||||
LatLng(maxLat! + 0.02, maxLng! + 0.02),
|
||||
);
|
||||
}
|
||||
|
||||
@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.withOpacity(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.withOpacity(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,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,16 +94,45 @@ class _EventDetailsSectionState extends State<EventDetailsSection> {
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionTitle('Adresse*'),
|
||||
TextFormField(
|
||||
_buildSectionTitle('Adresse'),
|
||||
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,663 @@
|
||||
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,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.warehouse_outlined),
|
||||
),
|
||||
items: _depots
|
||||
.map((d) => DropdownMenuItem(
|
||||
value: d,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(d.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(d.address,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.grey),
|
||||
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,
|
||||
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}'),
|
||||
))
|
||||
.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.withOpacity(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,168 @@
|
||||
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) {
|
||||
_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 ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.location_on_outlined, size: 18),
|
||||
title: Text(
|
||||
_suggestions[i],
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
widget.controller.text = _suggestions[i];
|
||||
widget.controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _suggestions[i].length),
|
||||
);
|
||||
widget.onSelected?.call(_suggestions[i]);
|
||||
_removeOverlay();
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
overlay.insert(_overlayEntry!);
|
||||
setState(() => _showOverlay = true);
|
||||
}
|
||||
|
||||
double _getFieldWidth() {
|
||||
final rb = context.findRenderObject() as RenderBox?;
|
||||
return rb?.size.width ?? 300;
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted) setState(() => _showOverlay = false);
|
||||
}
|
||||
|
||||
@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: (_) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user