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