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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user