e14b333a67
- 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
378 lines
15 KiB
Dart
378 lines
15 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|