From e14b333a67cb7a6f1e7ca37f509e207557259ccc Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Thu, 4 Jun 2026 14:28:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20calculateur=20de=20frais=20de=20d=C3=A9?= =?UTF-8?q?placement=20-=20backend=20et=20mod=C3=A8les=20Flutter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- em2rp/functions/index.js | 7 + em2rp/functions/package-lock.json | 502 ++++++++++++- em2rp/functions/package.json | 2 + em2rp/functions/src/travel.js | 314 +++++++++ .../travel/Ulys.postman_collection.json | 129 ++++ em2rp/functions/travel/gares_peage_export.csv | 643 +++++++++++++++++ .../controllers/event_form_controller.dart | 14 + em2rp/lib/models/depot_model.dart | 49 ++ em2rp/lib/models/route_result_model.dart | 158 +++++ em2rp/lib/models/vehicle_model.dart | 94 +++ em2rp/lib/services/travel_service.dart | 134 ++++ em2rp/lib/services/vehicle_service.dart | 46 ++ em2rp/lib/views/data_management_page.dart | 18 + em2rp/lib/views/event_add_page.dart | 11 + .../widgets/common/route_map_widget.dart | 146 ++++ .../data_management/depot_management.dart | 233 ++++++ .../fuel_prices_management.dart | 201 ++++++ .../data_management/vehicles_management.dart | 377 ++++++++++ .../event_form/event_details_section.dart | 51 +- .../event_form/travel_cost_dialog.dart | 663 ++++++++++++++++++ .../inputs/address_autocomplete_field.dart | 168 +++++ em2rp/pubspec.yaml | 4 + 22 files changed, 3940 insertions(+), 24 deletions(-) create mode 100644 em2rp/functions/src/travel.js create mode 100644 em2rp/functions/travel/Ulys.postman_collection.json create mode 100644 em2rp/functions/travel/gares_peage_export.csv create mode 100644 em2rp/lib/models/depot_model.dart create mode 100644 em2rp/lib/models/route_result_model.dart create mode 100644 em2rp/lib/models/vehicle_model.dart create mode 100644 em2rp/lib/services/travel_service.dart create mode 100644 em2rp/lib/services/vehicle_service.dart create mode 100644 em2rp/lib/views/widgets/common/route_map_widget.dart create mode 100644 em2rp/lib/views/widgets/data_management/depot_management.dart create mode 100644 em2rp/lib/views/widgets/data_management/fuel_prices_management.dart create mode 100644 em2rp/lib/views/widgets/data_management/vehicles_management.dart create mode 100644 em2rp/lib/views/widgets/event_form/travel_cost_dialog.dart create mode 100644 em2rp/lib/views/widgets/inputs/address_autocomplete_field.dart diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 4e28f33..49d64b2 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -4193,3 +4193,10 @@ exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => { res.status(500).json({ error: error.message }); } })); + +// ============================================================================ +// GOOGLE MAPS & TRAVEL (Proxies CORS + Calcul d'itinéraires) +// ============================================================================ +const travel = require('./src/travel'); +exports.googleMapsAutocomplete = onRequest(httpOptions, withCors(travel.googleMapsAutocomplete)); +exports.googleMapsComputeRoute = onRequest(httpOptions, withCors(travel.googleMapsComputeRoute)); diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index 4ce5bd6..4c8a836 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -7,7 +7,9 @@ "name": "functions", "dependencies": { "@google-cloud/storage": "^7.18.0", + "@mapbox/polyline": "^1.2.1", "axios": "^1.13.2", + "csv-parser": "^3.2.1", "dotenv": "^17.2.3", "envdot": "^0.0.3", "firebase-admin": "^12.6.0", @@ -42,7 +44,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -185,7 +186,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1316,6 +1316,17 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@mapbox/polyline": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mapbox/polyline/-/polyline-1.2.1.tgz", + "integrity": "sha512-sn0V18O3OzW4RCcPoUIVDWvEGQaBNH9a0y5lgqrf5hUycyw1CzrhEoxV5irzrMNXKCkw1xRsZXcaVbsVZggHXA==", + "dependencies": { + "meow": "^9.0.0" + }, + "bin": { + "polyline": "bin/polyline.bin.js" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1640,6 +1651,12 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1655,6 +1672,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2298,12 +2321,28 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "license": "MIT", + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001718", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", @@ -2540,6 +2579,18 @@ "node": ">= 8" } }, + "node_modules/csv-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.1.tgz", + "integrity": "sha512-v8RPMSglouR9od735SnwSxLBbCJqEPSbgm1R5qfr8yIiMUCEFjox56kRZid0SvgHJEkxeIEu3+a9QS3YRh7CuA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2557,6 +2608,40 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", @@ -2776,7 +2861,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -3817,6 +3901,15 @@ "uglify-js": "^3.1.4" } }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3866,6 +3959,36 @@ "node": ">= 0.4" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -4029,6 +4152,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4060,14 +4192,12 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4142,6 +4272,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4855,7 +4994,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4904,7 +5042,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -5037,6 +5174,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5080,7 +5226,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -5251,6 +5396,18 @@ "tmpl": "1.0.5" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5269,6 +5426,53 @@ "node": ">= 0.6" } }, + "node_modules/meow": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -5351,6 +5555,15 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5373,6 +5586,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minimist-options/node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5453,6 +5689,33 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5597,7 +5860,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5620,7 +5882,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -5648,7 +5909,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5678,7 +5938,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-to-regexp": { @@ -5691,7 +5950,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5957,6 +6215,15 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6017,6 +6284,135 @@ "dev": true, "license": "MIT" }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6031,6 +6427,19 @@ "node": ">= 6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6045,7 +6454,6 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -6436,6 +6844,38 @@ "source-map": "^0.6.0" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -6561,6 +7001,18 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6609,7 +7061,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6729,6 +7180,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ts-deepmerge": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-2.0.7.tgz", @@ -6903,6 +7363,16 @@ "node": ">=10.12.0" } }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index f82172c..6af4022 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -15,7 +15,9 @@ "main": "index.js", "dependencies": { "@google-cloud/storage": "^7.18.0", + "@mapbox/polyline": "^1.2.1", "axios": "^1.13.2", + "csv-parser": "^3.2.1", "dotenv": "^17.2.3", "envdot": "^0.0.3", "firebase-admin": "^12.6.0", diff --git a/em2rp/functions/src/travel.js b/em2rp/functions/src/travel.js new file mode 100644 index 0000000..7ef433d --- /dev/null +++ b/em2rp/functions/src/travel.js @@ -0,0 +1,314 @@ +'use strict'; + +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const csv = require('csv-parser'); +const polylineLib = require('@mapbox/polyline'); +const auth = require('../utils/auth'); +const logger = require('firebase-functions/logger'); + +// ───────────────────────────────────────────── +// Chargement du CSV des gares de péage (cache mémoire) +// ───────────────────────────────────────────── +let _tollStations = null; + +function loadTollStations() { + return new Promise((resolve, reject) => { + if (_tollStations) return resolve(_tollStations); + const csvPath = path.join(__dirname, '../travel/gares_peage_export.csv'); + if (!fs.existsSync(csvPath)) { + logger.warn('[Travel] CSV not found at ' + csvPath); + _tollStations = []; + return resolve(_tollStations); + } + const results = []; + fs.createReadStream(csvPath) + .pipe(csv()) + .on('data', (row) => { + if (row.id_gare && row.lat && row.lon) { + results.push({ + id: row.id_gare, + operatorId: row.id_gare.substring(0, 2), + tollId: row.id_gare.substring(2, 5), + name: row.nom || '', + lat: parseFloat(row.lat), + lon: parseFloat(row.lon), + }); + } + }) + .on('end', () => { _tollStations = results; resolve(results); }) + .on('error', reject); + }); +} + +// ───────────────────────────────────────────── +// Ulys — Détection des péages sur un tracé +// POST https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage +// Body = la polyline encodée (string brute, pas de JSON wrapper) +// ───────────────────────────────────────────── +async function getUlysTollLegs(encodedPolyline) { + try { + const url = 'https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage'; + const body = JSON.stringify(encodedPolyline); + const res = await axios.post(url, body, { + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + 'Host': 'api-ulys.azure-api.net', + }, + timeout: 10000, + }); + return res.data; + } catch (e) { + logger.warn('[Travel] Ulys /legs failed:', e.message); + return null; + } +} + +// ───────────────────────────────────────────── +// Ulys — Tarif pour un segment (entrée → sortie) +// POST https://api-ulys.azure-api.net/tollstation/v1/rate +// ───────────────────────────────────────────── +async function getUlysRate(vehicleCategory, passages) { + try { + const payload = { + vehicleCategory: String(vehicleCategory), + paymentOption: 2, + tollPassages: passages.map((p) => ({ + toll: { operatorId: p.operatorId, tollId: p.tollId }, + })), + }; + const body = JSON.stringify(payload); + const res = await axios.post( + 'https://api-ulys.azure-api.net/tollstation/v1/rate', + payload, + { + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + 'Host': 'api-ulys.azure-api.net', + }, + timeout: 8000, + }, + ); + const data = res.data; + if (Array.isArray(data) && data.length > 0) { + // Système fermé : 1 réponse avec entranceToll + exitToll + if (data.length === 1 && data[0].price > 0) return data[0].price; + // Système ouvert (barrières individuelles) : sommer les prix + const total = data.reduce((sum, d) => sum + (d.price || 0), 0); + return total > 0 ? total : null; + } + return null; + } catch (e) { + return null; + } +} + +// ───────────────────────────────────────────── +// Calcul du total de péage via Ulys /legs puis /rate +// ───────────────────────────────────────────── +async function calculateTollCost(encodedPolyline, vehicleCategory) { + try { + // 1. Demander à Ulys les gares sur le tracé + const legsData = await getUlysTollLegs(encodedPolyline); + + if (legsData && Array.isArray(legsData.features) && legsData.features.length > 0) { + // Extraire les gares dans l'ordre du tracé + const tollGates = []; + for (const feature of legsData.features) { + const props = feature.properties || {}; + const id = props.id_gare || props.id || props.gareId; + if (id && id.length >= 5) { + tollGates.push({ + id, + operatorId: id.substring(0, 2), + tollId: id.substring(2, 5), + name: props.nom || props.name || id, + }); + } + } + + if (tollGates.length === 0) return 0; + + // Greedy: trouver les segments fermés + barrières ouvertes + return await _computeTollFromGates(tollGates, vehicleCategory); + } + + // Fallback : pas de résultat Ulys /legs, retourner 0 + logger.info('[Travel] Ulys /legs returned no toll gates for this route'); + return 0; + } catch (e) { + logger.error('[Travel] calculateTollCost error:', e); + return 0; + } +} + +async function _computeTollFromGates(gates, vehicleCategory) { + let total = 0; + let i = 0; + while (i < gates.length) { + let found = false; + // Essayer le segment fermé le plus long possible (greedy backward) + for (let j = gates.length - 1; j > i; j--) { + const price = await getUlysRate(vehicleCategory, [gates[i], gates[j]]); + if (price !== null && price > 0) { + total += price; + i = j; + found = true; + break; + } + } + if (!found) { + // Barrière ouverte : tarif unitaire + const price = await getUlysRate(vehicleCategory, [gates[i]]); + if (price !== null && price > 0 && price < 20) { + total += price; + } + i++; + } + } + return total; +} + +// ───────────────────────────────────────────── +// EXPORT: Google Maps Autocomplete (proxy CORS) +// ───────────────────────────────────────────── +exports.googleMapsAutocomplete = async (req, res) => { + if (req.method === 'OPTIONS') { + return res.status(204).send(''); + } + try { + await auth.authenticateUser(req); + + const body = req.body.data || req.body; + const query = body.query || req.query.query; + if (!query) return res.status(400).json({ error: 'query is required' }); + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) return res.status(500).json({ error: 'GOOGLE_MAPS_API_KEY not configured' }); + + const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json'); + url.searchParams.set('input', query); + url.searchParams.set('key', apiKey); + url.searchParams.set('language', 'fr'); + url.searchParams.set('components', 'country:fr'); + url.searchParams.set('types', 'address'); + + const gRes = await axios.get(url.toString(), { timeout: 5000 }); + return res.status(200).json(gRes.data); + } catch (e) { + logger.error('[Travel] googleMapsAutocomplete error:', e.message); + return res.status(500).json({ error: e.message }); + } +}; + +// ───────────────────────────────────────────── +// EXPORT: Google Maps Compute Route (2 itinéraires + péages Ulys) +// ───────────────────────────────────────────── +exports.googleMapsComputeRoute = async (req, res) => { + if (req.method === 'OPTIONS') { + return res.status(204).send(''); + } + try { + await auth.authenticateUser(req); + + const body = req.body.data || req.body; + const { origin, destination, vehicleTollCategory = 2 } = body; + + if (!origin || !destination) { + return res.status(400).json({ error: 'origin and destination are required' }); + } + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) return res.status(500).json({ error: 'GOOGLE_MAPS_API_KEY not configured' }); + + const routesUrl = 'https://routes.googleapis.com/directions/v2:computeRoutes'; + const fieldMask = [ + 'routes.distanceMeters', + 'routes.duration', + 'routes.polyline.encodedPolyline', + 'routes.travelAdvisory.tollInfo', + ].join(','); + + const commonPayload = { + travelMode: 'DRIVE', + routingPreference: 'TRAFFIC_AWARE', + origin: { address: origin }, + destination: { address: destination }, + }; + + const [resToll, resNoToll] = await Promise.all([ + axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: false } }, { + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': fieldMask, + }, + timeout: 15000, + }), + axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: true } }, { + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': fieldMask, + }, + timeout: 15000, + }), + ]); + + const routes = []; + + // --- Route avec péage --- + if (resToll.data.routes && resToll.data.routes.length > 0) { + const r = resToll.data.routes[0]; + const poly = r.polyline?.encodedPolyline || ''; + let tollCost = 0; + if (poly) { + tollCost = await calculateTollCost(poly, vehicleTollCategory); + } + routes.push({ + routeType: 'TOLL', + distanceMeters: r.distanceMeters || 0, + durationSeconds: _parseDuration(r.duration), + encodedPolyline: poly, + tollCost, + }); + } + + // --- Route sans péage --- + if (resNoToll.data.routes && resNoToll.data.routes.length > 0) { + const r = resNoToll.data.routes[0]; + const poly = r.polyline?.encodedPolyline || ''; + + // N'ajouter que si différente de la route avec péage + const isDifferent = routes.length === 0 || + r.distanceMeters !== routes[0].distanceMeters || + Math.abs(_parseDuration(r.duration) - routes[0].durationSeconds) > 60; + + if (isDifferent) { + routes.push({ + routeType: 'TOLL_FREE', + distanceMeters: r.distanceMeters || 0, + durationSeconds: _parseDuration(r.duration), + encodedPolyline: poly, + tollCost: 0, + }); + } + } + + return res.status(200).json({ routes }); + } catch (e) { + logger.error('[Travel] googleMapsComputeRoute error:', e.message, e.response?.data); + return res.status(500).json({ error: e.message }); + } +}; + +function _parseDuration(durationStr) { + if (!durationStr) return 0; + if (typeof durationStr === 'number') return durationStr; + // Format: "1234s" + const match = String(durationStr).match(/^(\d+)s?$/); + return match ? parseInt(match[1]) : 0; +} diff --git a/em2rp/functions/travel/Ulys.postman_collection.json b/em2rp/functions/travel/Ulys.postman_collection.json new file mode 100644 index 0000000..0b99631 --- /dev/null +++ b/em2rp/functions/travel/Ulys.postman_collection.json @@ -0,0 +1,129 @@ +{ + "info": { + "_postman_id": "caae9e2d-0554-44c9-bc61-4acca36ca2d3", + "name": "Ulys", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "55489589", + "_collection_link": "https://go.postman.co/collection/55489589-caae9e2d-0554-44c9-bc61-4acca36ca2d3?source=collection_link" + }, + "item": [ + { + "name": "https://api-ulys.azure-api.net/tollstation/v1/rate", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"vehicleCategory\":\"2\",\"paymentOption\":2,\"tollPassages\":[{\"toll\":{\"operatorId\":\"04\",\"tollId\":\"268\"},\"passageDate\":\"2026-05-29T11:13:06.449Z\"},{\"toll\":{\"operatorId\":\"04\",\"tollId\":\"278\"},\"passageDate\":\"2026-05-29T14:31:57.545Z\"}]}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api-ulys.azure-api.net/tollstation/v1/rate", + "protocol": "https", + "host": [ + "api-ulys", + "azure-api", + "net" + ], + "path": [ + "tollstation", + "v1", + "rate" + ] + } + }, + "response": [] + }, + { + "name": "https://api-ulys.azure-api.net/placemark/v2/legs", + "request": { + "method": "POST", + "header": [ + { + "key": "accept-language", + "value": " \"fr-FR,fr;q=0.8\",", + "type": "text" + }, + { + "key": "sec-ch-ua-mobile", + "value": " ?0,", + "type": "text" + }, + { + "key": "sec-ch-ua-platform", + "value": " \"Windows\",", + "type": "text" + }, + { + "key": "sec-fetch-dest", + "value": " empty,", + "type": "text" + }, + { + "key": "sec-fetch-mode", + "value": " cors,", + "type": "text" + }, + { + "key": "sec-fetch-site", + "value": " cross-site,", + "type": "text" + }, + { + "key": "sec-gpc", + "value": " 1,", + "type": "text" + }, + { + "key": "x-calling-product", + "value": " SITE-VA-V2,", + "type": "text" + }, + { + "key": "Referer", + "value": " https://www.vinci-autoroutes.com/", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "\"ov`cwAys~}Hb@cBnAmC`HsJlBeC~FnLlFnMvE|CvKlCzO|@xWhAza@x@rn@k@rX_@tDAdCeArB}AxAsB\\\\eB^{DT_FiF}UyMwh@ap@sjCCOq@iCuA{FGUyAyF_K}_@i@oB_CcKqBmLoDeNmDiNyCeLlIcHdRgOtJ{HxMsJpFmElKyHpp@sg@~AmA|I_HtBaBnG_FbHoFfCoBxM}Jpl@ie@|EyDtGiF~\\\\_WhSeO^YrVcVp\\\\}[lBcBh@e@hGyFvKaJvGiGpEiEbAeAdDkDvBmC~MyNrAuAhFoFl@o@|@}@|@}@tPcQHIb^_^`@_@tAqAnVoUfM}LzAqApCuCfCuCv@{@fKoLrFgGnBsBnBsBtb@}a@`SsRz@y@hEyDnEqElBqBb@c@zJmKpBwBzJaK`HeHnUeUxC_DhnAgoAfk@{j@xE{E`QgQfRaSdGqG^_@bCiCdApBtArAbBr@fBLfBW~A{@hB}Bf@uA\\\\qA^aEMcEy@qDvBwBlNgNfKgKxo@oo@tGsGjCkCtIeIVW`A_AnIkIf|@m{@tmEqkEvdBgdBvi@aj@la@oa@nFqF`l@gk@|b@ua@tRgMjHuFlF{ChCo@|CYhB`Ad@HjARrBGlCaA`A_Av@w@vB}FTiARaDAsA|BeIdAqCrBiC|BeCvHwHjEeGpGgI~K}MnIkKxP_Udr@{q@~R{Q~W}VpQoM`H}ExN_Jdk@}XjWuJ|HmCrPaFzq@{RtVgJpO}GdT{KhLyGb_@uVnU_RhIaIrHgIpL}JnK_HhPoJtTyNpQwPnOcQtOyPxA~BjBzAxBl@|B?xBm@jB{AjAgBx@_Cd@mCNuCEwC]qCs@cCnDiGbGcNnOo_@xLkWvToe@nKkR~F}JrIsM`NqQ`IiJrIwIrHgGtyDkoCnaEstCXSvPqL|KuIrNgNrSoUtIwItG_G~@tBnA~AzAbAdBb@fBBdB[|A{@lAqA~@eBp@wB^aCfAeFbAeBzCwCxFeE~HyFjm@ee@xx@oj@jkDecCbwBg}Afr@sq@`j@md@j`@cVhXqN`XkNrp@k]~FyCpFmDpHwG`EiEjEwCrB{@xB{@nBh@rBFrBYbBu@zAqAlAgB~@{Bp@_DXiD?oDYgDq@{CiAiCyAoBiBqAqBo@wBIuBXyEoGkHkN}Rcd@aBuHMyDFyDIuDy@}EwEsJmEaJwByAcC]yAgA{AqA_BeB}M{UaIsNeV}b@_Wqg@{KgUwI_UiBsKkAoIy@mJSeGGiGD{ETgG^}Fh@mF~@oFnAiFlAkEjByE~AqDvCgFzCaEtCmC~CeCpDcCxDiBvE_BfDw@lDg@xDa@pEE`E@hGd@h_@bFj\\\\xFnKhDtd@hObm@rSjTnHxq@tVbs@zWt`Blp@vi@pUxj@vV`i@bVlYvMpTnKnXxMhd@vTdTlKjb@|TjhAtl@rf@hWdDfBb^hQxRbJjYvLhYtKdZtJlSzFrLxCbLdCvLdCtM|Br\\\\xEv^dDrOfApPt@vVf@tPFdRIhLUdMWvYyAvToBfU}BhW}DtUqEtXcGrWkHtVcIvSyHlRaItUyKtU}LlVwNxLwHfLaI|KcIpMaKjZqVtNoM~NaNx\\\\y[hb@_b@fb@sb@j\\\\_\\\\`TkSbSqQ`UaSfRmOxBgBbM}IvYwStSoMvRyKdM{GfLqFrScJpN{FxMwEzOmFxMyDjMkDvNmDtQ{DbLsB`MkBbPyB|QqBvXwBxOy@|Pc@vSU\\\\AhVJzRNpPd@dPt@nVzAvX|Bp@FzZvCzf@rFvfCpYffCrYfjD|`@fEb@djDz`@|Y~C|wBtVzxB|VxWdCdYlClg@xDpP`A~UlA|KZ|NVlSN~WEnJM~KYxOo@zO_A`OiAbPyApM_BfPyBlMyBpOwCfOaDtPaEfQwEpO}EpQgGjRmHpSwIrRgJve@eWtPgKnQsLvRkNtP{MrUuRdSeRnOmOpMiN~NyPtO}QjSuWjQyVdRoYzPiYzP}Z`N}W~HkPrGuN|N}\\\\~Nu_@lM}]jKe[dGgS`JsZtIy[zGuWbFeT|GmYbFcUfHs\\\\`H_]hPqz@xd@kdCljAamGllAauGhByIzKum@xBoMx@wEhG{[pBmKvUynA|Lip@jNqr@tF{UtGiWvKo_@bK{[rK}[bL}ZjMy[jL}WpM{XjJsRdHwM|Qq\\\\|QkZjOwU`NeRhVw[|VcZ|Zc\\\\p^m^|Y{Uj\\\\eVxYyRdS{KfPgJzQiJ`i@qU~ImD|LcExKwC~FkBr_@yJn`@gIf\\\\eF~[sDl[iC~ZuAdd@e@b[NbQ\\\\xRv@rRlArPrAlRrBlV~Cfb@~Gby@rOv_AtRtkAhVf~Av[jyBdd@pf@|Jbk@nL|AZ~AZfRtDjX|FbQrD`z@rPbh@hKv~@`RlWjFfl@tMdB`@he@xKzj@xNff@dN|W`ItWjIfTdHvSrHr]jMn[fM`Z|L~X~L`n@dYpq@l\\\\dWlMhuDtmBj}@fd@zr@`]hs@h\\\\rZfNp[fNtYzLdZzLnc@|P~WbKtXzJtXrJtXdJp[fKza@hMp[hJrf@dNhe@vLdt@dQpv@rPfi@hKji@tJxg@nI~g@|H`_@lFj`@hFf}@tK``AzJfz@pHbe@rDje@`DnTtAtVzArj@rC~c@jBvd@`B~j@`Bpj@hAdk@~@~i@f@xi@D~f@@~m@Ohm@c@jn@s@tm@_Axc@_Apc@cAzfAuCnuCmJzwCoKlf@eBxh@_Bz`@mAfe@kA`^y@d_@w@~m@aAnX]bXSnVQ`VEf_@IrQD|`@NfYRp\\\\b@~[j@|Zx@ts@bCjq@|Cve@nCvRzA|^zCv]tChJv@jl@fG`Gn@f~@xKx^zEdk@dI|cAbPfeAlQhqA|U|`AjRnoA~UpPjD~s@dNfhA|ThrCti@|rHlyAprHzxAnhAjTfhAtTxaAzS~c@vJfb@dJ~hA|XlfA|Yfm@vQ~o@lTp\\\\lLht@|X~y@`_@rn@`[`SvK`\\\\nQxr@tb@~r@vf@zp@tg@rPrN`_@z[n_@t]jg@pg@vf@hi@xd@ti@|l@tv@j`@bj@ve@ps@nd@nu@j`@nr@t`@~u@|d@paAb\\\\|u@~^d_At[f|@jc@`qA|`@hnA|b@`sAxQfg@vRvf@f[`r@d]jo@x\\\\ri@p^`h@hW|[`Yd[r`A`}@|aB`wA`r@nn@rj@ti@fb@hd@zs@|y@jg@zn@bi@xs@lcA`zAlbA`_Bdq@~iA`mAjxBbmEdkIfp@lnAx{AtuCvpB|zDjnApfCpe@faAzo@jvApMdYhAbCvXxm@hOn]dh@zkAtoAfyCna@nbAfw@tmB~}@vwBb|@~}BnBpDrNd^`T|h@|n@l}AvK|XnSbg@vp@p`Bvq@z|Azj@`lAdg@daAxX|g@lSn^zl@xaAtf@tu@xUr]pT`[ni@tt@tk@ft@bp@|u@|o@`s@|n@jo@zu@bs@zq@dl@jr@~i@hr@tg@v_A`o@noArv@xm@h^dn@l]z`Bv}@ltAjv@pvAxy@v]xTtcApq@h`Azr@p{@|r@~r@tn@dv@lt@fr@ps@hl@`q@hh@zm@ng@|n@`h@pq@tk@`x@dm@f{@txA~sB`x@zjAhb@|n@hb@tp@nSx\\\\fSr\\\\~NjWp`@|s@na@vy@d_@py@h[`v@hXfr@tZn{@za@~qAb^hkAbQnp@~Tp_AjMjk@xLrl@bLpl@hM`t@tJbm@nFt^hQhrA~BtTfJz{@hKzhAvH~cAhI`vAhDp~@n@lNjBby@pAdjATv{@Mzn@a@bo@oA~v@}Adp@sCd{@kHx_BaMfwBeMbmBeYvgEgSzwCaRpqCiGn_AkEfq@eF|y@wEbz@wExz@{Ar\\\\yFpwA}CdbA{A|q@gAps@q@`cAIrm@Nbq@j@~i@z@ni@rBtw@rDl{@zElx@vFvs@jJv`AlK||@dMp{@vNb{@zPh}@zRx_Ad]lzA~a@vfBvw@pfDr^n}Ahl@lhCjh@b_C`_@|dBr]zcB|U~kAbRz`Af^`mBj]bmB`ZtcBzYrdBl^txBdLfs@n@zDpLxq@`P`cAlYzfBjL~q@xYndBvu@ncEfOzt@rIba@nM|k@jRpx@zSj{@z^~tAx_@xtAdb@hxA~b@|xAvcAboD~Vn~@pVh_AhRhv@vWjiAdOfr@tGf\\\\fOnu@rRpcAt\\\\doBbWn`BhUx}A|QxtAfRzxA`b@|jDlk@ddFzSxnBpFrg@nUhpBbRz`BzR`cBzHjq@nJpv@`DrWnIvo@lLr{@zKpw@xI|k@`L|s@rHhd@pH|b@rJdj@tOxz@dSdcA`T`bAhOjq@fJr`@nK|b@zL~e@pLld@zLxc@dLna@vKh`@lQrk@bh@|cB`Stl@vHzTj\\\\baApWbt@dQ|f@nmAveDfx@lxBzdArnCbc@xlAn^pcAz\\\\pcAr[xdAb[zgAr[joAfMxh@hLni@|VfoApUdoAzQfiAxQhjAzc@|yCrKht@hoA`pIfOraAxGra@h@xCnAbHpLnn@fPps@~HpZli@fmBlTbk@jGrRzE~NpNfc@bFrOnMfd@jOfh@HV|Mhe@~VxdA~Jbb@`A`EnIr_@zIpb@fIhc@rGn]`Lft@`F|]bKhy@pKzcAdDxa@zQhuBxLtuA`IxiArMzmBlIzuAlMf~BlDnv@vM`uCnUr{FdMxdDjGzeBpJn~BlNf|CtLruB|MpqB~HfeA|IfeAjJbbAhKnbAbKx_AbIls@lNxhAhLf}@fXbjBrHpf@pUjvApVtuApNfv@lOxv@rZzwAv]b~AvNfl@jP`q@dTbx@dDzLbWzy@hS`m@hIxVnF|OrEdPfEzObExSvFld@jTl`BlGv[nIfZxGhPvHpN~KpOdMlM~R`MnM|EdGzApRzBlTMrGm@`KgBvGgBdFqBbKsFxLwIpPcOfIoHxEeEdLgIbD{AzCcB|kA_g@xG_DlHcBnYsKvYyItZuHfIyAbIk@pLYdDRjG`@`Lr@~IvAnKbBtLjCvO|FdI~DtJnGtLhI`SvOtMdKjTdNdKxEnHrCbC|@tF|AxE~AtQtEdOdEdFj@~HbDjNjHlKtIhItIxGvJjHhMnGfPbGlT`Ph}@lKhv@nuBliOpo@~pEtPpkA|^fjCvPrkArT~xAjCfPlErTrEzSxDxOlD|MbBpFfH~TjJzUjIzQ`LrSpK`QnM|QnO~QpO`Ql]x]fq@|p@p^v`@pPhSxGtIpFnHvHdL`IpMjG`LlHxO|D~IrFrOxEzNjDvKrEnQnDdP~AvIp@nD|Hle@dTdtAjDjUfCnLbDlLnDbMfGvObCjFtBzDlD|EnDlDxCfBbCz@`Cd@tB?zBK~Ai@dBo@pAu@fAkA~A{BbBuDrAoEn@_FJkDAuBEyDs@eNMeE?oDr@gFr@mA~ByDbEmElWwW`d@}`@jIyGjFeEjEcJp`BgkAbxAkhAjsGabFx^a[xqE_lDrr@kl@nt@kj@jOuLpj@mc@hi@}f@nrAivAh_@kc@xn@kr@vf@u]zJgE`KoCvN{BxNc@hf@~@~aD`a@fI~B~Ih@nvB|XvnA~Pp{Ehk@jS`Cva@jE`tFtr@~~ApTnt@|Mja@xIh_AdSpmAxWf{Af[tIfB~v@`QjL|CrMlDpQpHtUdN~kAxz@rg@l]l^vVhW`QvBtAhWvPdX~Q|WhPbZfObmEboBl`@rP`QzIpOjJpPdM~ThSxUfWrSbYnNpVxLfThM`WxKjYxSnn@vBbOpBfFjHlQvJ~PjKfMbF~DtLzG`YpMjQzLjKtIhJfLxNdRdF`IjAfBpH~NhHdR|HrXlWbfAxH~Z`Qdp@zQ~n@vFhRTz@vZjgAbQht@`Hf\\\\|Ilb@fBlIbJ|e@d@~BVtAdKfi@dNfp@tPts@nR`x@vId`@jHl^~Hnf@jDnV|ChWnCdW~BjWzAhSpA`T@RnD~v@Bd@dSdvE\\\\|Hzc@bhK|XbjGvK~iC|HjeBdHb`B@\\\\zIxpB?DfFtiAVxFvEdfAH|AtClo@`B`]lCzq@bFf`AbKtaCpHniB?jIWpIuAtOqC|XWlHJ`DVhE~@tFjAvEzBpEnEtE~C|AzCp@tDHrDKdEiAvHeFzHkFdD}AtM{D|U_D|UqBjFKhS_@fm@{AfXs@h]aBrcBuJpd@yBbq@sCxs@mCl_CiIrr@kDvOmApU}CzLeBvXkGlPwEte@qPxT{IxO{FzVuIl[eIrOcDzPuCbGw@xHcArUqB~WoArN_@h\\\\X~IV|Jh@dWnBrW~ChVnEhOhD`LdD~OpDpt@dW|YrJ|ThHlCz@rGdCfItDvYnPzHnE~X~MjXrKdDlAdGlB|Bh@vCd@pBFfCMpBOrBg@lBq@zBmAhAw@xAmA`AcAfAyAjAkBnAcC`AkCv@yCn@yCp@{Dl@{EzAsOp@}Fv@iE~@cEfA_DrA_DdAsB|A_CxBeCpBgB|C{BnSeLxFcB`FwAxKaC`IsAdJqAbjHkfA`g@oH|ZgErh@uFlh@}Dj~D_YpbDgUxN}@vS_AdNi@lSe@nRMvFCjAAtM?xNPzNV~Sj@zT`AzO`AdSzAvWdCtXnDr[bFvPrCzQtDj\\\\xHl^vJ|PnFxSdHfTzHpPzGfP`HryA|o@xq@xZzq@|Y`TzIpSlIbk@~T`e@dQne@zP|p@lUd[lKpi@|P~QxF~VxHnfArZtp@|Q|b@xL|RfGrNlEvTnHdTtHnWrJjW|Jj\\\\jNbKjEvSpJjSvJpPjIbPnIxRlK`P|IdOvIbMvHhOfJv_@rVdX`RhTpO|S|OhStOpXhTxXbUbXlTnXbU`b@z]bb@h^lp@dl@t^f\\\\lf@fd@jr@tp@`k@hj@v_@x_@~l@|m@`z@||@`LzLri@|l@~~@hdA`WrX`W`XbXlXtMpM`NzMnP`P`NpMze@dc@rg@hc@`RtOzQfOhc@h]j^xXra@bZ~LvIrMhJxXhRpYbRxY|QvYpQrY|PjZ~Plr@t_@|mAtn@j\\\\bPn\\\\`Phc@bS|c@`S~`@bQ|c@fRpg@lS|i@tSnh@pR|eAn_@xzA|h@te@fQdIxCro@|VfZ|LhZ`MvYbMvYnMrg@nUzf@xU|f@nV|_@|Rjf@jWh`@dT|^vSt_@xT~^tThRlLdRvLzUzO`UvO~WvRdW~R|]zYr]xZhb@t`@nVfVbWjWtLdMhMlNvO~P`PvQne@jj@jVvZrYh_@xTjZ~X|`@fn@x}@dSnYlQhWpS|XnTxXnOdRfPnRzOhQjPbQrV~UbNtLbHfG~JhI`LbIpQjL|L|G|KtFjL`F`LnEfMnEtSdGtHnBfIbBnKhBbL|A`LjAvE^|E`@dLf@nL\\\\jEFxE@rKEvUg@p]iAfj@uCvh@kDrh@{D|i@oFfaAuKzd@kG|RyCxOaC~a@uGza@mHtb@mIlg@eKby@eRraAkWjdAsZptAcc@xZmKzwAsj@~VoIta@qPff@kSx|@m_@vAm@lO_HzI}DjN_Gpe@uThu@_^~b@}Shb@wSb[}Ol`@kS~LiGrOaJpKqGvMgJhJcH~ImH`HeGfLmKfKkKrPoRzO_SfL_PzIiMbH_LpNgVhOqYvNc[zLkZ~M}^|Xix@bGoPxKoXjLwXjHmPvHuOtMiWjOyXrM}TbC{DrFyIbKmOdJ}MxJ}MpT_YfPmRnOuPlQmQlQeQlRyPvQaOvP_MrTaOtR_MjRmKdWuMtLuFnU}JpPwGt]aNbYeLtO_H`P}HpXuNvY}Prb@mX|SmNhToO`d@c\\\\pn@ce@by@ao@ldAuy@p`@k\\\\ld@k`@dd@o`@~x@it@fq@an@bg@ue@|v@{u@n[u[rZg[rd@kf@b[i]hZi]n]qa@x\\\\ab@dW}[`Vm[hZka@`Zya@tZ}c@nQiXnP_XxZgh@xUgb@rRg^vQg^vPe^bEcJdW}j@rDoIpIqSdQsc@jNi_@|GyRxFiPdPqf@`HoUlLwa@fNmi@vMqi@bGyX~FaYzDkTfB_L|AuJdEk\\\\jBoT~@kN\\\\sIj@oL\\\\{MR{SC{MGmM]_Pi@gO_AoViAkU_B{^y@cRmAgd@AyCMaRLkS^uOf@eNnA{RjAuMzAiNxDgX|E{U`FiSlFiQnI{T|L_WnLwS|N_SpIoJfM{KlGmFfEeDfDyBfHmE`FiCpFsCrRqHtLcEhlD}kAlV{IrQ_Gn\\\\cLnZqKx`@mPxY}NhRaL`RgMpRoNzTuQjd@c`@bk@ke@vUuQhRwL|PwJhNwH|QwIhRuHdOiFnSkGfTcF`T{D|UwCpQcB~TkAlUe@rRFlQVrQn@nPjBvQ`CtS`DnRvDrPtE~UfH`W~Iv^zN~n@fVdq@bXpWrJbRnGpPzE~OfD`FbAbPbCnRhBvQpAha@zA~\\\\|@l]fB~QpBbP`CpPjDvTtGzPxGn[nPbV|PpSlQhOzOjNtPdK`O~L|RrNlX|JrTrKtYzGfSjIzXvJb]dg@xfBzPhn@tP`l@vNnf@`JbX`H|QrKtVjJ~Q`MfTtJbO~KnOpNxPvMbNrUbU`j@fh@bKtJzSdSfUjTdZjYfK~KnJbLbC`DtJjM`K~OxJbQfIhP`HlOfKxWnLh]x@dCtL`_@xGdRhGfPhEpJlFpKbFlJzC`FrBbDnLtPxNzPhHtH`EnD`JjIhMvJpRrKfNrGdM`EpP`FbM~BbL|AfM`AzQh@~KC`PW~Ws@|j@}AzvB{Gva@g@bNNfRnA`Jz@~KfBjMnCbKxClLdE|JxEdMbHjHzEzP|NpIfIbJvJzPfTnVz]fRzZjj@~u@lXv_@tRnXlPtTlQzSpTvTjQbPpQlNzNtI~QlIzOpFrLdDvOzC~RnCrCPzIh@nODjLMdNq@|MkBhMeC|OoDvJqDjNwFxXqNxg@gW`x@oa@pQiJxQuGnNmDtLkBzKqApKw@nMQ~PH`WlAbJbAbJlApOtD|NjE|eA~[vu@zTvAb@`B`@pHfB|OvDh^vI|OxD`s@fMtm@lIv}@lJhJ`AhJXbRW`QoEl|@sGlPgApn@gE~f@aA~p@kCnDMpaCmHhSi@v`@gAbc@_@~_@@`f@HzZZtf@fAjP\\\\rv@tC|k@|Bzp@zEj^xCd^dDjl@~Gtp@lIti@zHzj@pJjx@hOlDl@|aAnSzX`Grs@nO`n@pMds@dOn|A|\\\\tqDxv@`gDrr@vqB~b@ll@jMrv@fP|i@fL~s@lOtq@vN~p@rNjrAbYjwBrd@`{Ble@jd@rJnd@rJj`@tItU~DtRtCtUjCxb@vClW|@~Yb@|VNp[SrMWdc@gBnNmA~PiBvb@eG~HuA~ZqFrc@iL~XwIf^wMpXuLvLkFxg@qThl@kWvj@aVrVgM|c@eTjQgIjQaHdSwGhPuF~SuFjTeEtTaErQoCdTeCx[_CvYoAtYUpSZvRZj[l@pUlBtUpCb\\\\fFra@xIlYbIzSzGxQ|GdQnH`RtIpWxMb[xQpSdN`O~KnVdSbWzU`]j[l`@l^zf@|d@tk@ni@ntA|oAvd@vb@|YnXxl@tj@tK~J`MfLh~BrwBl_CfyBlhA~dA`x@rt@tz@lw@z[jZf}@hz@dyAltAdg@`d@|b@d]`GhExNlKn[zSlWnOpYdP|W~MnWnLzc@|Qj[|KxWjIdHxBrWpHve@nL~YhGnQnCxO`CxU|CtYtCl[rBrWhArVj@zWPfRC`RQ~V}@~TkAjH_@vWuAhM]|Oc@b_@[d\\\\PtWb@jUz@~]nBnS`Bna@lEl`@bF`v@hKxq@fJtOrB~OtBlMdBza@xFnc@tFbWtCrRz@bQJlMKtQeAfUaCrOsCxMcDtUgIzPkHlPaJzQsLnQyNnXqXbp@ar@nRoPvNiKtLsHvUcLdX_KdTqF|MgClNeBhPoAvLa@xJEpORzSlAzNbBjOpCj`@hIxT`FjnAlWxbA`TbVdF~d@vJnd@`Jx`@nHxZdFl]bF|p@pIdw@zI~_AnKltApOrt@fHf\\\\vBtYrArIJv[b@xDFnWFlUIvg@gBnd@gCfc@gDne@{Fd}@qOrlCse@pf@yI`eDql@`{Baa@d|Boa@lQ_D|`BqYth@oJjZsFnQ}Cz~@oPtlBe]dcAsQht@kMvr@oKr`@{Fhk@qHvj@eH~~@_Kjd@kEj{@wHfy@iGdn@qDzCOta@{B~Mo@jKe@|f@iBby@{BnnAcCh|@o@v[EdWEhRAfULbRHrU\\\\tQHfZ\\\\nk@~@vs@pB`FNvJXx_@jA|Ux@nk@nBraAzCreA`Dr}@hCxf@PxX[zRk@zScAjWoBzT_CnVgDhReD|\\\\qGh\\\\gIh`@yLpc@aQr`@iQnGkDjS{KjWoOfK}G|\\\\sVbW{R~UwS|UkUnVqWtQaTpQaUjVk\\\\nXqa@by@inArz@ipAlSsZjRaYxPiTzN}PrN}N~OqOdRaPhR{NdNmJlVeOrR}JzSsJdTwInJuCxEaBfWyGzXmGpMkBbZ{C|T}AbPk@lQUjRDtTj@nWtAlTzB~PhCnTvDj[tH~o@pPdq@~Px`@bKni@pN|e@|Lf]vH~TbE~W~DzTrB~UpAzTn@lRArYg@bZoBfb@}Eje@uJj]kJlXcKf]wP|Aw@|YiOz[gUx[eWrb@i^nc@w_@|e@ya@dlBoaB~lB{aBtgAk_Apc@s`@hd@u`@lq@gl@bQiOjWkTvSeQpXiU~f@m`@bb@aZhSoMlLqHzYmPxXqN~c@qS|a@qPdi@iQtc@}Lxj@aMrc@oHr`@gFvl@aFfc@yBza@{@ld@OxY\\\\v]hA|jAdHfzArMxJt@dg@fEj\\\\xC||AbNpp@tFdd@|Dz_@vC``@lCnIZ~Uz@te@Z|f@cAn[kB|`@aEnc@iHtd@eKt]iKfb@sO|a@wRvp@i_@bU{O|SeP~ZiWbUyTl`@{c@lZk_@tP}UfUm_@~Qa\\\\n]cq@pRw`@vIuPvWcf@lZqe@d_@gf@f[}\\\\jXaV|XkTzTkOvNeIr]cQxa@kQjb@mQza@cQteAuc@ljCehAbd@kTrb@aVvc@mYf`@{Yr]aZzYiYrg@ik@rl@uu@zl@cw@pd@qi@xb@ce@`e@kd@ff@ec@jXaUvQwNzNwK`l@ea@fYqQxQeLbh@kXto@yZhl@yV~w@wYt`AaZj|@mV|cAeXnVmH~m@eQ`WcJ`\\\\yL|UiLtZkQxM{IvLeItWaThWwVpc@og@nV_]rU}^xVue@~Xmo@xMi^`Ma`@|Q{q@tPau@rGaZxHu]xIq^|Jq]zJ}ZlNi`@tQub@fRk_@xNeWz]_i@~NsRtRyT|Ze[t[mXvT_PhSgM~ZmPzY_MjYeKxXyHxWuFpUmDrVqC`[aBfWe@hQD|Yh@p_@rCf]nEna@dItOxEnUpHfj@hR|a@nMnXhGnSvDzZ~Dr\\\\dCxZj@dXJpP_@fNq@nY_CpZeEpViEdUsFrSgGpa@{O~ZcOze@cZhZ{TxVgTbu@}q@bk@mh@|x@}u@|a@s[`SwMdVkN`VmLnZ_Mvc@aNnWyFd\\\\gFxVkDht@mKne@qKva@{M|XuL|WeNnS_MfWgQnW_SbXiT`SuPpVqTvc@w^j\\\\wVhRqN~NwJn\\\\oSf_@{Sxa@cS~h@gUjYkKf_@{LbQyErVyGdZuGh]_G|WaEtXcDn\\\\aDxZsBph@uBdUYz^Ohk@h@hi@nA`oB~DxoBdEloBbE|nBfEvMLhnAnCr|@vB`eAbC~`@p@`b@`AhnAdC`{ApCvqAxCzs@|A|f@r@pQBnr@y@nNw@r^oBpe@aFrf@aI|_@mI|WmHz_@eMla@}Obk@aXrk@m[fH{Dbt@ab@tn@_]fa@_R``@{Ova@_Oz`@cMjn@kPtn@gOtaAaUtu@_QhKaCb}@gSdjAwWthAkWf_Bq^hm@cNfp@kOn~@{Sdj@wK~m@kIf]{Cl`@mB|n@}@pr@vAvo@lEzt@~Jfm@jMp`@~Kvg@rQr`@vPtg@rW`e@lYle@l]xVvSpWhVh^v^xd@zi@~~@lkArt@`_Al]bb@dn@|r@lr@jr@vr@nm@`y@vo@t|@dr@jtBb`BzkFbbEtl@fc@dn@x_@zZrOj_@rOh_@~Ldd@nL~c@|Hpd@fFt`@xB~^p@f_@Wpc@{A|i@_Glh@wIfUiFt}@mTjc@aJv_@qFb^aDtb@gB~b@O|_@p@dp@lEpa@vDnb@vEhb@pGze@vJvh@lNn^|K~_@xNn_@rPd_@fRvk@d]v\\\\jUlb@~[b\\\\dYz`@l`@~_@lb@jY|]vt@rbA`[vc@~d@rm@tX~[|\\\\p]nb@d^jVdQ`UzMj[bPfP~GfSzHp^dK`a@`Jbc@zFt_@fCv`@p@h]Ux[oB|ZcDj]_Gb[_Hr[}JjSoIzKsEpCkAnOkHpEwBx^gUt]uWvRcQ~QeQls@kr@bOaNva@{]d|@ip@rVePr`@mWjg@kYdc@yTbj@cWp{@y\\\\|~@aZzt@wQxh@sKz~@cNh{@wIxx@aFlrBiLxrBoLvdBmJ`hAqGzgCuNvwAsHfj@cBdb@g@zd@A~c@Xp}@jCdm@dDvk@zEv_AxKlmAtS~{Ax\\\\b}A`]ti@nL`i@xJ|q@~Kpr@lKjp@~Hxy@xIbiArItf@nCtf@vBlb@zAjb@bArdAjAbe@D~g@Qdp@s@fSe@hk@kBvl@gCvl@eDzIe@`RaAba@yB|lA}GvyAyHzq@yDx_@}Bnb@uBpd@kB`t@eA~s@Hpp@f@|e@bApaAhBlsAtBhsAfClhApBxhArB|p@`@xm@a@leA}CfbAkGfo@oG~o@wIx`@eGdO}Cld@iJfw@uQjc@_MdTgHju@cWnj@kTxk@cWdn@wZl{@af@dvA}v@xr@c^p\\\\sN`\\\\yLh[{JnPkEdb@_K|XuEfEs@f^{Et^{Cx\\\\mBvu@mBvhBy@xi@aAxy@sDba@yDxMoAno@cJ`De@tu@qNnImCbOwEjN_EtFiBbBi@pKiD~\\\\kLlh@cSvcAec@`f@gSt_@gOx`@}G~LiEdLyCxFgAdIWlFTjHrA`HrBrKhF`]`RpOdIhJlC|Fj@`Ge@`EwA`FiD`EcFbDyIjAgF~AgLz@gMpGybArAaOtAeLxAgH~BmH`DeGnD}EpFgEvF_CbFsAhFQtFNrJbAxJ|CdN`Dzd@dLbObCpCb@xCf@xc@bDz[WjTqBlJqAtHG~Gh@nJ~BhRlKdFlBlCbBxAdA~A|AVhCb@bCdAjDxAxClB~BfBrAbCfAvB`@vBFnEq@~D{BjC_DlBeEfByAfB}@`Fe@vDBj@@fMh@bJEtEg@fFeBdV{FzTkFnNmDxA_@lCq@`rBig@jCq@vk@uNRGfTgFnLsCjHoAhi@wJvIgAvIa@hB?dAtBvA|AxA~@`Bf@bBJdBO~Ai@xAaAnAyAfAyBr@kC\\\\yCdB{B|BwAlEmBxMgD`EuAbBm@`JmEhD{@dJ}BdG_BbAW|@UhVeGfQeEfCm@tg@cMtToF|Cu@dS_FnXyGz[_Ib{@gSfQqEtSoFrD}@tfAqWbCk@|bBq`@pLqCjBc@tJcCn\\\\gIxNkDjD{@tPuDpDaADAvI_C^KnEEvA@`AbAlAb@t@Bt@Iv@[p@i@l@aA`@oArBqB`HsDp@M`l@iNjD{@`GyAvGaBxA]hScFbDw@zEmA~GeBrEkAtCu@zGgBtCq@~Bm@|f@eMdAWlEkAxDHlDu@fg@qLzJaCnIoB~HkBtKgCbOwD`b@aKtMiD~KkChB{AfO}DlMaDbOqDrLeD`@KhAYlBg@nBi@~@UpDaAjCs@TGdEy@pA[d@MfCo@vI{BbHiBrRaFvJgCpHiBba@cKrIwBjQ_E`RuElR{ElNoD`Cm@`N}B`BY|AWd@f@z@f@B@D@t@LxArBlBnBfDpBjDh@lB|AdJvFjAl@dAj@lEzBbExBbIdEj@ZxC|A|C`BnCtA`S`Kf@\\\\hBhAzE`CvLjGfYdO|@d@`DlBbC|AxBtAt@b@`G`CrEvBlAf@xSlKl[`PjCnAUrAyAfHuFbXaAzEe@`CQv@iCfMcEhSk@pCr@b@j@`@`WfQxIbLbAtBlDfHjB`EfJfEvCxAfAl@\"", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage", + "protocol": "https", + "host": [ + "api-ulys", + "azure-api", + "net" + ], + "path": [ + "placemark", + "v2", + "legs" + ], + "query": [ + { + "key": "precision", + "value": "6" + }, + { + "key": "includeLayersIds", + "value": "GaresPeage" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/em2rp/functions/travel/gares_peage_export.csv b/em2rp/functions/travel/gares_peage_export.csv new file mode 100644 index 0000000..618a400 --- /dev/null +++ b/em2rp/functions/travel/gares_peage_export.csv @@ -0,0 +1,643 @@ +id_gare,nom,lat,lon +09242,BEAUPONT,46.44094,5.26993 +10157,BELLEGARDE,46.11602,5.79506 +09147,SYLANS,46.15853,5.6484 +09146,ST MARTIN-DU-FRESNE,46.13243,5.54164 +09156,LA CROIX-CHALON,46.17772,5.5589 +09145,BOURG SUD,46.14563,5.28633 +09144,VIRIAT,46.23576,5.27355 +09149,SAINT GENIS,46.27197,5.01576 +09143,BOURG NORD,46.2599,5.16695 +09142,REPLONGES,46.30076,4.89584 +09141,FEILLENS,46.32883,4.88241 +09150,BEYNOST,45.82045,4.99411 +09153,LA BOISSE,45.82704,5.03146 +09253,LA COTIERE,45.83409,5.0324 +09251,LA BOISSE - MONTLUEL,45.8407,5.04702 +09422,MIONNAY,45.883,4.89889 +09151,BALAN,45.85092,5.09938 +09152,PEROUGES,45.86086,5.1813 +09154,AMBERIEU,45.97765,5.3127 +09155,PONT D AIN,46.04812,5.32788 +09443,CROTTET,46.28678,4.87001 +07144,CHATEAU-THIERRY,49.07832,3.39881 +07141,MONTREUIL,49.01321,3.15531 +07146,DORMANS,49.15379,3.71689 +07032,ST QUENTIN SUD,49.81227,3.29479 +07031,ST QUENTIN NORD,49.86139,3.24072 +07033,LA FERE,49.6872,3.46533 +07034,LAON,49.6078,3.66102 +07136,REIMS,49.32216,3.98417 +07135,GUIGNICOURT,49.42699,3.92801 +09062,FORET DE TRONCAIS,46.50647,2.63033 +09064,MONTMARAULT,46.32764,2.96645 +06060,MANOSQUE,43.80781,5.81289 +06059,ST PAUL LES DURANCE,43.70249,5.7346 +06061,LA BRILLANNE,43.92395,5.89373 +06063,PEYRUIS,44.03919,5.96354 +06065,AUBIGNOSC,44.12443,5.98428 +06064,AUBIGNOSC OUEST,44.12513,5.97854 +06067,SISTERON SUD,44.17168,5.95542 +06068,SISTERON NORD,44.2236,5.91579 +06014,ANTIBES OUEST,43.6028,7.07248 +06024,SOPHIA,43.60275,7.08124 +06022,ANTIBES PV NORD,43.60463,7.06645 +06023,ANTIBES EST SORTIE,43.60421,7.08636 +06017,CAGNES OUEST SUD,43.64321,7.13404 +06016,CAGNES EST,43.65933,7.14863 +06015,CAGNES OUEST,43.64804,7.13906 +06013,ANTIBES EST,43.60301,7.08397 +06019,ST ISIDORE ECH OUEST,43.70408,7.19005 +06025,MONACO,43.74513,7.37264 +06026,LA TURBIE ECH.,43.74346,7.37835 +04358,PAMIERS,43.15415,1.61603 +04357,MAZERES SAVERDUN,43.23448,1.63595 +09173,SAINT THIBAULT,48.22341,4.12962 +09174,TORVILLIERS,48.2844,3.96602 +09183,THENNELIERES,48.28933,4.16302 +09172,MAGNANT,48.17907,4.44056 +09171,VILLE SOUS LAFERTE,48.11496,4.78555 +09170,CHAUMONT-SEMOUTIERS,48.04283,5.05935 +07186,VALLEE DE L'AUBE,48.52551,4.18556 +07185,CHARMONT-S/BARBUISE,48.41039,4.14267 +04339,NARBONNE-SUD,43.16443,2.99012 +04338,NARBONNE-EST,43.17904,3.03496 +04340,SIGEAN,43.03314,2.95619 +04341,LEUCATE,42.93912,2.97304 +04350,CASTELNAUDARY,43.29079,1.94671 +04349,BRAM,43.23897,2.10001 +04348,CARCASSONNE-O,43.19993,2.31004 +04347,CARCASSONNE-E,43.20205,2.4182 +04346,LEZIGNAN,43.17187,2.74107 +04213,CAVAILLON,43.81988,5.02777 +04214,SENAS,43.74201,5.08971 +04215,SALON NORD,43.65943,5.10259 +04279,A8: AIX-EN-PROVENCE,43.55106,5.23769 +04278,COUDOUX,43.55294,5.23863 +06001,CANET DE MEYREUIL,43.49465,5.52496 +06035,CASSIS,43.22517,5.58151 +06034,LA CIOTAT ECH.,43.20208,5.59476 +06054,PERTUIS SUD,43.66218,5.49758 +06038,PAS DE TRETS,43.3863,5.6018 +06036,PONT DE L ETOILE,43.32437,5.59801 +04267,GRANS,43.62304,5.08354 +04266,SALON OUEST,43.6364,5.02274 +04219,SALON SUD,43.62598,5.1015 +08612,DOZULE FL ECH,49.22992,-0.08247 +08621,TROARN FL,49.1778,-0.20296 +08631,CAGNY FL,49.16833,-0.24512 +04536,ST-JEAN D'ANGELY,45.96286,-0.54589 +04537,SAINTES,45.75224,-0.66399 +04538,PONS,45.5745,-0.59558 +04540,SAINT-AUBIN,45.25434,-0.5478 +04539,MIRAMBEAU,45.37766,-0.57955 +04545,TONNAY-CHARENTE,45.95515,-0.88097 +05056,VIERZON-EST,47.2118,2.11835 +05055,VIERZON-NORD,47.24397,2.06412 +05057,BOURGES,47.04492,2.34171 +09061,ST AMAND-MONTROND,46.722,2.45831 +04118,MANSAC,45.15247,1.37821 +04123,TULLE NORD,45.32901,1.76396 +04124,TULLE EST,45.31812,1.85046 +04125,EGLETONS,45.40739,2.02119 +04126,USSEL OUEST,45.51246,2.25888 +04127,USSEL EST,45.58897,2.41459 +09111,BIERRE-LES-SEMUR,47.43663,4.30944 +09110,AVALLON,47.50932,3.99198 +09114,BEAUNE SUD,47.0024,4.85476 +09129,BEAUNE NORD,47.04201,4.85075 +09124,NUITS-ST-GEORGES,47.13099,4.96743 +09160,DIJON-ARC S/TILLE,47.34461,5.15933 +09126,DIJON-CRIMOLOIS,47.27707,5.13804 +09161,TIL CHATEL,47.53859,5.19472 +09139,SEURRE,47.02937,5.17033 +09130,SOIRANS,47.20534,5.305 +04104,MONTPON,44.98597,0.15622 +04105,MUSSIDAN SUD,45.01073,0.37757 +04107,MUSSIDAN EST,45.06515,0.43001 +04106,MUSSIDAN BARRIERE,45.06529,0.42986 +04114,THENON EST,45.15161,1.16785 +04112,THENON,45.15124,1.16747 +04116,LA BACHELLERIE SUD,45.14813,1.17472 +09128,L ISLE-S/LE-DOUBS,47.41251,6.58553 +09133,BAUME-LES-DAMES,47.37102,6.37965 +09131,BESANCON EST,47.33224,6.1513 +09134,BESANCON NORD,47.27603,5.98798 +09135,BESANCON OUEST,47.23478,5.89813 +04204,VALENCE-N,44.97018,4.88768 +04203,TAIN,45.06844,4.8685 +04205,VALENCE-S,44.90511,4.88009 +04206,LORIOL,44.75681,4.79136 +04207,MONTELIMAR-N,44.66896,4.79483 +04208,MONTELIMAR-S,44.48123,4.76353 +03092,LA BAUME D'HOSTUN,45.06467,5.20806 +03091,CHATUZANGE BARRIERE,45.02608,5.0969 +08532,HEUDEBOUVILLE FL ECH PARIS,49.19492,1.23192 +08551,BOURG ACHARD FL ECH,49.36642,0.81813 +08571,BOURNEVILLE FL ECH,49.37708,0.62672 +08581,TOUTAINVILLE FL ECH,49.36423,0.46766 +08592,BEUZEVILLE FL ECH PARIS,49.33757,0.36939 +12030,BROGLIE/ORBEC SENS 2,49.03165,0.44483 +12031,BROGLIE/ORBEC SENS 1,49.03719,0.4416 +12040,BERNAY,49.13768,0.57809 +12050,BRIONNE,49.24206,0.77249 +05306,ARTENAY,48.0843,1.8557 +05304,ALLAINES,48.20533,1.84601 +05605,CHARTRES-EST,48.45632,1.53784 +05607,THIVARS,48.36159,1.44648 +05609,LUIGNY,48.23459,1.03313 +05608,ILLIERS-COMBRAY,48.29444,1.27031 +04222,REMOULINS,43.93725,4.5982 +04221,ROQUEMAURE,44.02563,4.73546 +04224,NIMES-O,43.81363,4.34275 +04223,NIMES-E,43.85615,4.42069 +04275,LUNEL,43.70284,4.11962 +04225,GALLARGUES,43.7229,4.18087 +04260,NIMES CENTRE,43.80757,4.37448 +04261,GARONS,43.76132,4.42753 +04356,NAILLOUX,43.38237,1.61688 +04352,MONTGISCARD,43.46088,1.58831 +04351,VILLEFRANCHE,43.39858,1.6962 +04458,SAINT-JORY,43.71824,1.39796 +04455,EUROCENTRE,43.76503,1.38044 +04646,MONTREJEAU,43.10017,0.59546 +04644,LANNEMEZAN,43.09782,0.39023 +04648,ST GAUDENS,43.11568,0.7566 +04650,LESTELLE,43.12029,0.89548 +04651,LESTELLE ST MARTORY,43.11773,0.89297 +04470,L'UNION,43.64518,1.49797 +04467,PODENSAC,44.60754,-0.36681 +04466,LANGON,44.54422,-0.26201 +04465,LA REOLE,44.51137,-0.04954 +04464,MARMANDE,44.43479,0.13293 +04101,ARVEYRES,44.88514,-0.26839 +04102,LIBOURNE NORD,44.95703,-0.24522 +04103,COUTRAS,45.012,-0.09983 +04276,BAILLARGUES,43.67053,4.01301 +04333,SETE,43.47755,3.68525 +04331,MONTPELLIER ST-JEAN,43.56153,3.8305 +04335,AGDE-PEZENAS,43.37414,3.41816 +04334,BEZIERS CABRIALS,43.3433,3.28907 +04337,BEZIERS-OUEST,43.30445,3.2191 +05318,AMBOISE CH.RENAULT,47.54281,0.98489 +05320,TOURS-C/MONNAIE,47.49058,0.81927 +05486,TOURS-NORD,47.45219,0.73926 +05490,CHAMBRAY,47.34906,0.70388 +05485,LA THIBAUDIERE,47.33078,0.68772 +05484,MONTS - SORIGNY,47.25456,0.67091 +05524,SAINTE MAURE,47.10728,0.58766 +05522,TOURS-C/SORIGNY,47.21845,0.65771 +05478,NEUILLE PONT PIERRE,47.55541,0.59565 +05967,BOURGUEIL,47.25319,0.16665 +05960,VIVY,47.31056,-0.03177 +05014,BLERE,47.28654,0.98447 +04216,AUBERIVES,45.3898,4.80535 +04202,CHANAS,45.32115,4.80972 +03022,CROLLES BRIGNOUD,45.2716,5.90105 +03023,LE TOUVET,45.34745,5.96371 +03021,CROLLES BARRIERE,45.27169,5.90089 +03024,PONTCHARRA,45.42579,5.99511 +03071,CHESNES,45.65572,5.09788 +03002,ST QUENTIN FAL. BRETELLE,45.64785,5.11979 +03062,VILLEFONTAINE,45.62843,5.16432 +03003,ISLE D'ABEAU CENTRE,45.60516,5.2344 +03004,BOURGOIN,45.58235,5.30027 +03005,LA TOUR DU PIN,45.56194,5.42886 +03072,LA TOUR DU PIN EST,45.55669,5.46452 +03006,LES ABRETS,45.57134,5.60416 +03061,SAINT GENIX SUR GUIERS,45.57277,5.65913 +03085,RIVES,45.38415,5.47313 +03086,VOIRON,45.34763,5.56633 +03083,MOIRANS NORD,45.32415,5.60512 +03087,VOREPPE BARRIERE,45.28323,5.622 +03084,MOIRANS,45.32035,5.60783 +03095,TULLINS,45.287,5.52175 +03093,SAINT MARCELLIN,45.13824,5.32621 +03094,VINAY,45.19954,5.41944 +09136,GENDREY,47.18353,5.70894 +09137,DOLE,47.1361,5.50619 +09138,CHOISEY,47.06457,5.44674 +09238,ARLAY,46.77782,5.51859 +09240,BEAUREPAIRE,46.66637,5.41894 +04907,BENESSE,43.62393,-1.40031 +04906,CAPBRETON,43.63235,-1.39224 +04908,ONDRES,43.54151,-1.4356 +04624,PEYREHORADE,43.51704,-1.10384 +04687,SALIES,43.5098,-0.92187 +05314,MER,47.72856,1.50862 +05312,MEUNG SUR LOIRE,47.8328,1.669 +05316,BLOIS,47.62149,1.34635 +05053,LAMOTTE-BEUVRON,47.58173,1.99103 +05054,SALBRIS,47.41849,2.0257 +05013,ST ROMAIN SUR CHER,47.30652,1.35312 +05011,CHEMERY,47.32467,1.50014 +05010,VILLEFRANCHE S/ CHER,47.32452,1.76561 +04178,MONTBRISON,45.63736,4.19611 +04177,FEURS,45.73754,4.18636 +04174,NOIRETABLE,45.84935,3.79423 +04175,ST GERMAIN L.,45.86092,4.04202 +04180,BALBIGNY,45.84111,4.16441 +05246,ANCENIS/NANTES,47.40254,-1.19352 +05245,ANGERS/ANCENIS,47.40243,-1.1935 +05248,NANTES/ANCENIS,47.39927,-1.19276 +04556,AIGREFEUILLE,47.0693,-1.43729 +04557,BIGNON,47.11481,-1.49175 +05309,GIDY,47.96698,1.85157 +05308,ORLEANS-NORD,47.94948,1.85462 +14380,SAVIGNY /CLAIRIS,48.05598,3.08802 +14375,ST HILAIRE,48.03677,3.02262 +14365,GONDREVILLE A77/S,48.06084,2.66766 +14370,FONTENAY /LOING,48.0642,2.76711 +14360,GONDREVILLE A77/N,48.0608,2.66765 +14355,AUXY,48.08539,2.47274 +14350,ESCRENNES,48.11633,2.19111 +05052,OLIVET,47.84093,1.86936 +05050,ORLEANS-CENTRE,47.89819,1.85323 +09404,LE TOURNEAU,47.99293,2.67768 +04408,MARTEL,44.99329,1.53144 +04406,SOUILLAC,44.90066,1.50424 +04405,LABASTIDE MURAT,44.69812,1.58057 +04404,CAHORS NORD,44.53225,1.50626 +04403,CAHORS SUD,44.34273,1.49406 +04463,AIGUILLON,44.28452,0.26986 +04469,Agen Ouest,44.1875,0.54602 +04462,AGEN,44.16498,0.60573 +04783,SEICHES,47.56645,-0.32803 +04782,DURTAL,47.66779,-0.25964 +04784,CORZE,47.53933,-0.3408 +05280,ST JEAN DE LINIERES,47.46658,-0.68606 +05274,SAINT GERMAIN,47.43168,-0.81633 +05958,BEAUFORT,47.46812,-0.19348 +05959,LONGUE,47.40448,-0.11597 +04561,THOUARCE,47.3295,-0.59635 +04563,CHEMILLE,47.23593,-0.72764 +04564,CHOLET NORD,47.08333,-0.82795 +04565,CHOLET SUD,47.01894,-0.88073 +07190,REIMS EST,49.21144,4.07834 +07154,REIMS SUD,49.20521,4.00643 +07191,CHALONS LA VEUVE,49.04479,4.32266 +07189,ST GIBRIEN,48.97356,4.28868 +07192,CHALONS MOURMELON,49.04094,4.32093 +07193,ST ETIENNE AU TEMPLE,49.03349,4.43586 +07194,STE MENEHOULD,49.0764,4.88366 +07616,CLERMONT EN ARGONNE,49.09443,5.10176 +07137,LA NEUVILLETTE,49.29793,3.99801 +07188,MONT CHOISY,48.91073,4.28809 +07187,SOMMESOUS,48.73061,4.22984 +07633,VATRY,48.76782,4.24012 +09162,LANGRES SUD,47.79236,5.22621 +09163,LANGRES NORD,47.93512,5.28682 +09164,MONTIGNY-LE-ROI,47.99736,5.51194 +05821,VAIGES,48.05551,-0.48728 +05823,LAVAL-EST,48.10762,-0.73857 +05825,LAVAL-OUEST,48.10462,-0.83436 +07198,JARNY,49.19991,5.90167 +07199,BEAUMONT,49.19882,5.92401 +09168,COLOMBEY-LES-BELLES,48.54066,5.90884 +07195,VOIE SACREE,49.09382,5.27836 +07196,VERDUN,49.11096,5.41385 +07197,FRESNES EN WOEVRE,49.12909,5.62133 +07177,STE-MARIE,49.19333,5.98902 +07716,BOULAY,49.1411,6.46657 +07719,ST AVOLD,49.13582,6.71291 +07721,FAREBERSVILLER,49.10816,6.85335 +07707,PUTTELANGE,49.06986,6.91814 +07701,LOUPERSHOUSE,49.07553,6.89666 +07702,SARREGUEMINES,49.04476,7.02883 +07704,PHALSBOURG,48.77206,7.24069 +07705,SAVERNE,48.76172,7.38999 +07029,MARQUION,50.20249,3.10689 +07017,CAMBRAI,50.17563,3.19272 +07030,MASNIERES,50.06855,3.17244 +07005,SENLIS BONSECOURS,49.20703,2.60921 +07006,SENLIS,49.2154,2.62813 +07007,PONT STE MAXENCE,49.32069,2.69478 +07008,COMPIEGNE OUEST,49.39898,2.69922 +07009,RESSONS,49.52169,2.71574 +07414,MERU,49.21073,2.15071 +07415,BEAUVAIS CENTRE,49.39922,2.12564 +07416,BEAUVAIS NORD,49.43377,2.12615 +07417,HARDIVILLERS,49.60999,2.20284 +05142,ALENCON NORD,48.45298,0.12411 +17111,Entrée SEES,48.63813,0.18495 +12210,ARGENTAN,48.63284,0.1909 +12020,GACE SENS 2,48.77175,0.30896 +12021,GACE SENS 1,48.77694,0.30347 +07012,ALBERT,49.96568,2.86338 +07013,BAPAUME,50.1042,2.8679 +07014,ARRAS,50.2694,2.86387 +07434,BERCK,50.41161,1.6899 +07436,ETAPLES-LE TOUQUET,50.50672,1.68025 +07437,NEUFCHATEL HARDELOT,50.61112,1.64983 +07438,BOULOGNE SUD,50.67396,1.65981 +07021,VALLEE DE LA HEM,50.82097,2.06379 +07022,ST-OMER B,50.7229,2.16749 +07020,CALAIS,50.71905,2.17249 +07024,AIRE SUR LA LYS,50.66821,2.25608 +07023,ST-OMER,50.71935,2.1729 +07025,LILLERS,50.55304,2.46281 +07026,BETHUNE,50.514,2.61776 +07060,NOEUX LES MINES,50.4869,2.68389 +07027,LIEVIN,50.43782,2.70728 +07015,DOURGES,50.32504,2.9107 +07028,ARRAS,50.34517,2.7875 +09076,COMBRONDE,45.99599,3.10472 +09077,RIOM,45.89546,3.14816 +09078,GERZAT-VILLE,45.84223,3.15962 +04128,ST JULIEN SANCY,45.66634,2.68254 +04129,VULCANIA BROMONT,45.8339,2.82261 +04130,MANZAT,45.95393,2.98285 +04171,LEZOUX,45.84864,3.38363 +04172,THIERS-OUEST,45.86003,3.50381 +04173,THIERS-EST,45.87882,3.62491 +04905,BAYONNE SUD,43.4623,-1.49849 +04903,BIARRITZ,43.45046,-1.55445 +04982,ST JEAN DE LUZ NORD,43.37193,-1.67494 +04902,ST JEAN DE LUZ SUD,43.37196,-1.67775 +04620,GUICHE,43.51222,-1.22266 +04689,ORTHEZ,43.46716,-0.74861 +04691,LESCAR,43.34524,-0.4188 +04690,ARTIX,43.39508,-0.55123 +04692,PAU CENTRE,43.33051,-0.35045 +04695,TARBES OUEST,43.22081,0.02358 +04638,TARBES EST,43.21372,0.10822 +04640,TOURNAY,43.17743,0.23992 +04642,CAPVERN,43.10327,0.34273 +04342,PERPIGNAN-NORD,42.7818,2.89739 +04343,PERPIGNAN-SUD,42.66674,2.85891 +04344,LE BOULOU,42.52366,2.81767 +07703,SARRE UNION,48.91392,7.12449 +07708,HOCHFELDEN OUEST,48.76926,7.60208 +09221,VILLEFRANCHE-NORD,46.02229,4.72208 +09120,BELLEVILLE S/SAONE,46.1038,4.75047 +09121,VILLEFRANCHE-VILLE,45.97728,4.73366 +04268,CONDRIEU ENTREE,45.50581,4.84228 +04269,CONDRIEU SORTIE,45.50521,4.83904 +09421,GENAY,45.90029,4.81942 +04181,TARARE OUEST,45.89156,4.4031 +04183,TARARE EST F,45.87151,4.50903 +04184,TARARE EST ENTREE O,45.87624,4.52202 +09115,CHALON CENTRE,46.80244,4.82981 +09116,CHALON SUD,46.75343,4.83299 +09117,TOURNUS,46.57911,4.90124 +09140,MACON CENTRE,46.3382,4.84688 +09118,MACON NORD,46.36589,4.83918 +09119,MACON SUD,46.28308,4.79296 +09241,LE MIROIR,46.54741,5.32655 +05611,LA FERTE BERNARD,48.14989,0.68724 +05612,CONNERRE,48.07532,0.47932 +05617,LE MANS-OUEST,48.0217,0.12745 +05615,LE MANS NORD,48.05045,0.17391 +04780,LE MANS SUD,47.97373,0.05757 +04781,SABLE LA FLECHE,47.77553,-0.20865 +05169,MONTABON,47.68608,0.3714 +05168,ECOMMOY,47.82037,0.30169 +05153,PARIGNE L'EVEQUE,47.95569,0.32017 +05151,AUVOURS,48.0047,0.31254 +05131,MARESCHE,48.1967,0.16871 +05133,ROUESSE FONTAINE,48.31156,0.13349 +05143,ALENCON SUD,48.39788,0.1047 +05819,JOUE EN CHARNIE,48.00434,-0.21729 +03008,CHAMBERY NORD,45.60265,5.88708 +03009,AIX SUD,45.65367,5.92169 +03007,AIGUEBELETTE,45.57691,5.79935 +03027,CHIGNIN BRETELLE,45.5092,6.00634 +03025,CHIGNIN LES MARCHES,45.50898,6.00623 +03031,MONTMELIAN,45.49492,6.05763 +03032,SAINT PIERRE D'ALBIGNY,45.54852,6.1603 +03033,AITON,45.55604,6.24815 +03034,STE HELENE BARRIERE,45.61976,6.31206 +02050,ST PIERRE DE BELLEVILLE,45.46979,6.28709 +02051,STE MARIE DE CUINES,45.34596,6.30541 +02052,HERMILLON,45.29886,6.35492 +02053,ST JULIEN MONTDENIS,45.25162,6.39687 +02054,ST MICHEL ECHANGEUR,45.21844,6.45893 +02057,ST MICHEL-MODANE,45.21651,6.46586 +10002,CLUSES AMONT,46.04652,6.59638 +10003,CLUSES AVAL,46.04893,6.58956 +10004,SCIONZIER,46.06839,6.55374 +10012,BONNEVILLE OUEST,46.07345,6.38294 +10158,ELOISE,46.06521,5.8639 +03011,RUMILLY,45.81591,6.00686 +03020,SEYNOD SUD,45.84596,6.05773 +03012,ANNECY CENTRE,45.89783,6.09258 +03013,ANNECY NORD,45.93882,6.11694 +03014,ALLONZIER,45.98984,6.12869 +03016,CRUSEILLES A 410,45.99337,6.12816 +08311,ST ROMAIN SO,49.55165,0.33583 +08322,ST ROMAIN SF,49.55101,0.33873 +08341,BOLBEC,49.58133,0.44307 +08351,FECAMP,49.63236,0.64 +08361,YVETOT,49.62746,0.80626 +08371,YERVILLE,49.64775,0.84262 +08381,BEAUTOT,49.6353,1.05065 +08391,COTTEVRARD,49.64675,1.24268 +07443,AUMALE OUEST,49.75625,1.69842 +07444,AUMALE EST,49.75939,1.7031 +07172,ST-JEAN LES 2 JUMEAU,48.9469,3.03972 +07174,MONTREUIL AUX LIONS (19),49.01316,3.1554 +09178,CHATILLON-LABORDE,48.54222,2.79737 +09179,ST-GERMAIN-LAXIS,48.58811,2.72483 +09176,MAROLLES-SUR-SEINE,48.38015,3.02023 +09177,FORGES,48.42142,2.94341 +09102,URY,48.33869,2.59548 +09104,NEMOURS,48.26951,2.7133 +09103,FONTAINEBLEAU,48.28882,2.68315 +09201,VAL DE LOING-SOUPPES,48.17632,2.76729 +09403,DORDIVES,48.17139,2.76706 +05198,DOURDAN,48.56902,1.99025 +05302,ALLAINVILLE,48.45643,1.90827 +05603,ABLIS,48.52877,1.83225 +05601,LA FOLIE-B/PARIS,48.55324,1.93062 +08511,CHAMBOURCY FL,48.9118,2.04672 +04533,SOUDAN,46.4255,-0.08208 +04534,NIORT EST,46.35524,-0.33118 +04547,VOUILLE,46.30743,-0.36642 +04535,NIORT-S,46.244,-0.46061 +04548,NIORT NORD,46.41932,-0.39707 +07010,ROYE,49.70606,2.76919 +07053,GARE TGV,49.85555,2.83067 +07011,PERONNE,49.87697,2.83976 +07418,ESSERTAUX,49.73973,2.22877 +07422,SALOUEL,49.85977,2.21381 +07420,AMIENS SUD,49.85416,2.25022 +07425,AMIENS OUEST,49.89176,2.23839 +07426,AMIENS NORD,49.93383,2.24381 +07428,FLIXECOURT,50.02951,2.06974 +07431,ABBEVILLE NORD,50.13538,1.81102 +07430,ABBEVILLE EST,50.09981,1.86941 +07432,COTE PICARDE,50.25441,1.74658 +07446,POIX-DE-PICARDIE,49.80846,1.96876 +07052,VILLERS BRETONNEUX,49.85496,2.52207 +07054,ATHIES,49.83871,2.98978 +04402,CAUSSADE,44.14993,1.51564 +04461,VALENCE D'AGEN,44.06418,0.86653 +04460,CASTELSARRASIN,44.0558,1.09731 +04459,MONTAUBAN,43.92898,1.31427 +06003,POURRIERES,43.47756,5.75573 +06004,ST.MAXIMIN,43.44938,5.87706 +06007,LE MUY,43.46068,6.55084 +06008,PUGET ECHANGEUR,43.45693,6.68943 +06049,FREJUS OUEST,43.46935,6.72917 +06010,FREJUS,43.47221,6.74342 +06032,BANDOL ECH.,43.14438,5.76866 +06042,PUGET VILLE,43.25791,6.12263 +06046,CARNOULES,43.29345,6.20046 +06006,CANNET DES MAURES,43.39342,6.35218 +04209,BOLLENE,44.29026,4.75111 +04217,ORANGE-N,44.16422,4.76458 +04210,ORANGE,44.13527,4.79569 +04218,ORANGE-S,44.11089,4.84525 +04211,AVIGNON-N,43.9819,4.88828 +04212,AVIGNON-S,43.89289,4.91565 +04555,MONTAIGU,46.9596,-1.35294 +04554,LES ESSARTS,46.79043,-1.19453 +04553,CHANTONNAY,46.62728,-1.15449 +04552,STE HERMINE,46.5336,-1.07897 +04550,FONTENAY CENTRE,46.43595,-0.82151 +04570,FONTENAY OUEST,46.46462,-0.87667 +04549,NIORT OUEST,46.38784,-0.64788 +04566,LA VERRIE,46.94473,-0.9765 +04567,LES HERBIERS,46.90208,-1.04676 +05528,CHATELLERAULT-SUD,46.77866,0.50452 +05526,CHATELLERAULT-NORD,46.83697,0.53082 +05530,POITIERS-NORD,46.62136,0.34394 +05529,FUTUROSCOPE,46.67011,0.35987 +05532,POITIERS-SUD,46.54907,0.28938 +09265,ROBECOURT,48.14478,5.68904 +09166,BULGNEVILLE,48.21578,5.8325 +09167,CHATENOIS,48.29947,5.85293 +09175,VULAINES,48.23784,3.60136 +09184,ST-DENIS-LES-SENS,48.23696,3.26209 +09105,COURTENAY,48.0598,3.09537 +09106,JOIGNY,47.9397,3.24406 +09107,AUXERRE NORD,47.85162,3.54878 +09108,AUXERRE SUD,47.79676,3.65284 +09109,NITRY,47.65906,3.87958 +09181,VILLENEUVE-DONDAGRE,48.14968,3.17133 +07002,CHANTILLY,49.08425,2.55161 +07018,THUN L'EVEQUE,50.22928,3.27218 +07171,COUTEVROULT,48.85356,2.83874 +07140,MONTREUIL AUX LIONS,49.00953,3.14581 +07718,ST AVOLD,49.1362,6.70162 +09180,LES EPRUNES,48.58892,2.65679 +09101,FLEURY-EN-BIERE,48.42582,2.53979 +09112,POUILLY-EN-AUXOIS,47.25241,4.56116 +04288,VIENNE SUD,45.47464,4.83439 +04201,VIENNE,45.47693,4.83243 +04220,LANCON,43.59398,5.17255 +06002,LA BARQUE,43.48327,5.53839 +04345,LE PERTHUS,42.52788,2.82113 +04390,LE BOULOU (O),42.52367,2.81752 +04541,VIRSAC,45.02368,-0.43507 +08521,BUCHELAY FL,48.99097,1.64331 +08611,DOZULE FL,49.22841,-0.08116 +08501,MONTESSON FL,48.91429,2.15109 +07413,AMBLAINVILLE,49.20513,2.1689 +07439,HERQUELINGUE,50.68885,1.64206 +04401,MONTAUBAN NORD,44.05229,1.41021 +05177,ST CHRISTOPHE,47.63838,0.49703 +12010,SEES,48.63297,0.19109 +12060,ROUMOIS,49.35396,0.83522 +08601,QUETTEVILLE FL,49.32057,0.31114 +07451,JULES VERNE,49.85868,2.39867 +09169,GYE,48.62975,5.88358 +09431,FONTAINE-LARIVIERE,47.6758,6.98183 +09132,SAINT MAURICE,47.4255,6.67126 +10011,NANGY,46.15316,6.29518 +10159,VIRY,46.12023,6.00951 +06070,LA SAULCE,44.44035,6.0286 +03041,LE CROZET,45.04556,5.67876 +06037,AURIOL,43.36689,5.64395 +04262,ARLES,43.69045,4.5449 +04265,SAINT MARTIN DE CRAU,43.63771,4.85783 +04355,TOULOUSE-SUD/EST,43.5449,1.50046 +04468,SAINT-SELVE,44.65901,-0.45426 +04457,TOULOUSE-NORD/OUEST,43.65838,1.42803 +04456,TOULOUSE-NORD/EST,43.65805,1.42699 +19001,BPV SAUGNAC,44.34693,-0.85998 +19003,BPV CASTETS,43.83527,-1.18061 +04901,BIRIATOU,43.34098,-1.74938 +04622,SAMES,43.52955,-1.18678 +04476,MURET,43.50526,1.35223 +18001,BAZAS,44.44527,-0.24235 +18002,CAPTIEUX,44.28603,-0.22806 +18003,ROQUEFORT,44.04489,-0.34321 +18004,MONT DE MARSAN,43.94698,-0.39392 +18006,AIRE SUR L'ADOUR N,43.72216,-0.27088 +18007,AIRE SUR L'ADOUR S,43.66454,-0.27668 +18008,GARLIN,43.56528,-0.29261 +18009,THEZE,43.47137,-0.32105 +04472,TOULOUSE EST,43.64715,1.50782 +09063,MONTLUCON,46.39634,2.71132 +04179,VEAUCHETTE,45.56095,4.24203 +13001,VIADUC DE MILLAU,44.13397,3.02535 +09405,MYENNES,47.43731,2.94081 +05827,LA GRAVELLE VITRE,48.08263,-1.02758 +05968,RESTIGNE,47.27017,0.25391 +05016,VEIGNE,47.31191,0.7353 +05015,ESVRES,47.30717,0.79607 +05713,VELIZY,48.7828,2.15728 +05712,VAUCRESSON,48.83241,2.14747 +05711,RUEIL,48.86991,2.15805 +04562,BEAULIEU SUR LAYON,47.32641,-0.60428 +04568,LA ROCHE SUR YON,46.67234,-1.34583 +17112,Sortie SEES,48.63603,0.1844 +17114,RONAI vers SEES,48.81632,-0.12945 +17113,RONAI vers FALAISE,48.8164,-0.12919 +17116,Sortie NECY,48.82346,-0.13664 +04122,ST GERMAIN LES VERGN,45.28384,1.61737 +04170,LES MARTRES ARTIERE,45.8278,3.24112 +08561,BOURNEVILLE FL,49.38496,0.60381 +20001,BOUVILLE,49.54825,0.92179 +08541,INCARVILLE FL,49.24809,1.17938 +09125,DIJON SUD,47.26023,5.03208 +07152,REIMS OUEST(THILLOIS,49.24947,3.95591 +09239,BERSAILLIN,46.84932,5.57458 +09165,GROISSIAT,46.22366,5.6142 +09065,GANNAT,46.0981,3.13554 +09465,VICHY,46.13841,3.3513 +04544,CABARIOT,45.94391,-0.84935 +08211,PONT DE TANCARVILLE,49.46365,0.47404 +04801,PYRENEES ORIENTALES,42.53958,1.82401 +08222,PONT DE NORMANDIE,49.45001,0.2717 +06021,ST.ISIDORE ECH. EST,43.70655,7.19071 +06028,LAGHET,43.7454,7.37858 +06055,MEYRARGUES,43.66118,5.50356 +06056,PERTUIS NORD,43.66162,5.5034 +06039,BELCODENE,43.41783,5.5752 +08593,BEUZEVILLE FL ECH CAEN,49.33906,0.36807 +05247,ANCENIS/ANGERS,47.39936,-1.19277 +05273,VIEILLEVILLE,47.29114,-1.4804 +07722,HOCHFELDEN,48.76874,7.60218 +03010,AIX NORD,45.71614,5.92225 +10014,BONNEVILLE EST,46.07018,6.42445 +08321,EPRETOT BPV,49.55172,0.3355 +06005,BRIGNOLES,43.4191,6.06505 +06011,LES ADRETS,43.54408,6.81245 +07001,ROISSY CDG,49.21556,2.62796 +07706,SCHWINDRATZHEIM,48.76953,7.60203 +09122,VILLEFRANCHE-LIMAS,45.97344,4.73193 +06009,CAPITOU,43.46857,6.72924 +06012,ANTIBES P/V,43.60255,7.0783 +06027,LA TURBIE P/V,43.74367,7.37827 +06020,ST.ISIDORE P/V,43.70752,7.19138 +05270,ANCENIS BARRIERE,47.40069,-1.19587 +08531,HEUDEBOUVILLE FL,49.19512,1.23011 +08591,BEUZEVILLE FL,49.33815,0.3678 +08533,HEUDEBOUVILLE FL ECH CAEN,49.19667,1.22974 +04407,GIGNAC,44.99076,1.52646 +07441,HAUDRICOURT,49.75948,1.70294 +10001,CLUSES,46.04695,6.5966 +03026,CHIGNIN BARRIERE,45.51256,6.00209 +02056,ST MICHEL BARRIERE,45.21869,6.45903 +03001,ST QUENTIN FAL. BARRIERE,45.64866,5.12021 +06033,LA CIOTAT P/V,43.20485,5.59054 +06031,BANDOL P/V,43.14628,5.77188 +04354,TOULOUSE-SUD/OUEST,43.54469,1.50019 +04904,LA NEGRESSE,43.44839,-1.55338 +09079,CLERMONT-BARRIERE,45.84133,3.16053 +17115,Entrée NECY,48.8235,-0.13463 +04182,ST ROMAIN POPEY,45.87177,4.50891 +07150,REIMS NORD,49.24633,3.96251 +03015,ST MARTIN BELLEVUE A410,45.98981,6.12837 diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 74c78ac..98be5f7 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -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: } + 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 equipment, List containers) { _assignedEquipment = equipment; _assignedContainers = containers; diff --git a/em2rp/lib/models/depot_model.dart b/em2rp/lib/models/depot_model.dart new file mode 100644 index 0000000..cc856c2 --- /dev/null +++ b/em2rp/lib/models/depot_model.dart @@ -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 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, doc.id); + } + + Map 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, + ); + } +} diff --git a/em2rp/lib/models/route_result_model.dart b/em2rp/lib/models/route_result_model.dart new file mode 100644 index 0000000..5c4380b --- /dev/null +++ b/em2rp/lib/models/route_result_model.dart @@ -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 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 map) { + return FuelPrices( + diesel: _parseDouble(map['diesel'] ?? 1.60), + essence: _parseDouble(map['essence'] ?? 1.75), + electricite: _parseDouble(map['electricite'] ?? 0.22), + ); + } + + Map 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; + } +} diff --git a/em2rp/lib/models/vehicle_model.dart b/em2rp/lib/models/vehicle_model.dart new file mode 100644 index 0000000..79a6633 --- /dev/null +++ b/em2rp/lib/models/vehicle_model.dart @@ -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 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, + doc.id, + ); + } + + Map 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; + } +} diff --git a/em2rp/lib/services/travel_service.dart b/em2rp/lib/services/travel_service.dart new file mode 100644 index 0000000..fdc6733 --- /dev/null +++ b/em2rp/lib/services/travel_service.dart @@ -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 _getToken() async { + final user = FirebaseAuth.instance.currentUser; + return await user?.getIdToken(); + } + + Future> _headers() async { + final token = await _getToken(); + return { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + } + + // ─── Autocomplétion d'adresses ──────────────────────────── + Future> 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; + final predictions = data['predictions'] as List? ?? []; + 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> 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; + final routes = data['routes'] as List? ?? []; + return routes + .map((r) => RouteResult.fromMap(r as Map)) + .toList(); + } catch (e) { + DebugLog.error('[Travel] computeRoutes error', e); + rethrow; + } + } + + // ─── Prix des carburants ─────────────────────────────────── + Future 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 saveFuelPrices(FuelPrices prices) async { + await _db.collection('app_config').doc('fuel_prices').set(prices.toMap()); + } + + // ─── Dépôts ─────────────────────────────────────────────── + Future> getDepots() async { + final snap = await _db.collection('depots').orderBy('name').get(); + return snap.docs.map((d) => DepotModel.fromFirestore(d)).toList(); + } + + Stream> watchDepots() { + return _db + .collection('depots') + .orderBy('name') + .snapshots() + .map((s) => s.docs.map((d) => DepotModel.fromFirestore(d)).toList()); + } + + Future addDepot(DepotModel depot) async { + final ref = await _db.collection('depots').add(depot.toMap()); + return ref.id; + } + + Future updateDepot(DepotModel depot) async { + final map = depot.toMap(); + map.remove('createdAt'); + await _db.collection('depots').doc(depot.id).update(map); + } + + Future deleteDepot(String depotId) async { + await _db.collection('depots').doc(depotId).delete(); + } +} + +/// Instance singleton +final travelService = TravelService(); diff --git a/em2rp/lib/services/vehicle_service.dart b/em2rp/lib/services/vehicle_service.dart new file mode 100644 index 0000000..ec6ba84 --- /dev/null +++ b/em2rp/lib/services/vehicle_service.dart @@ -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> 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> watchVehicles() { + return _db + .collection(_collection) + .orderBy('name') + .snapshots() + .map((snap) => + snap.docs.map((d) => VehicleModel.fromFirestore(d)).toList()); + } + + /// Ajoute un véhicule + Future addVehicle(VehicleModel vehicle) async { + final ref = await _db.collection(_collection).add(vehicle.toMap()); + return ref.id; + } + + /// Modifie un véhicule existant + Future 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 deleteVehicle(String vehicleId) async { + await _db.collection(_collection).doc(vehicleId).delete(); + } +} diff --git a/em2rp/lib/views/data_management_page.dart b/em2rp/lib/views/data_management_page.dart index 5361968..97b0464 100644 --- a/em2rp/lib/views/data_management_page.dart +++ b/em2rp/lib/views/data_management_page.dart @@ -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 { 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 diff --git a/em2rp/lib/views/event_add_page.dart b/em2rp/lib/views/event_add_page.dart index 4344e6c..655a003 100644 --- a/em2rp/lib/views/event_add_page.dart +++ b/em2rp/lib/views/event_add_page.dart @@ -248,6 +248,17 @@ class _EventAddEditPageState extends State { 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, diff --git a/em2rp/lib/views/widgets/common/route_map_widget.dart b/em2rp/lib/views/widgets/common/route_map_widget.dart new file mode 100644 index 0000000..51b927f --- /dev/null +++ b/em2rp/lib/views/widgets/common/route_map_widget.dart @@ -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 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 _decode(String encoded) { + if (encoded.isEmpty) return []; + try { + final result = []; + 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> 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 = []; + final allPointGroups = >[]; + + 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 = []; + 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), + ], + ); + } +} diff --git a/em2rp/lib/views/widgets/data_management/depot_management.dart b/em2rp/lib/views/widgets/data_management/depot_management.dart new file mode 100644 index 0000000..4df902e --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/depot_management.dart @@ -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 createState() => _DepotManagementState(); +} + +class _DepotManagementState extends State { + 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(); + + 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 _delete(DepotModel depot) async { + final confirm = await showDialog( + 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>( + 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), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/em2rp/lib/views/widgets/data_management/fuel_prices_management.dart b/em2rp/lib/views/widgets/data_management/fuel_prices_management.dart new file mode 100644 index 0000000..32d5bb2 --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/fuel_prices_management.dart @@ -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 createState() => _FuelPricesManagementState(); +} + +class _FuelPricesManagementState extends State { + final _service = TravelService(); + final _formKey = GlobalKey(); + final _dieselCtrl = TextEditingController(); + final _essenceCtrl = TextEditingController(); + final _electriqueCtrl = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/em2rp/lib/views/widgets/data_management/vehicles_management.dart b/em2rp/lib/views/widgets/data_management/vehicles_management.dart new file mode 100644 index 0000000..5356d37 --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/vehicles_management.dart @@ -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 createState() => _VehiclesManagementState(); +} + +class _VehiclesManagementState extends State { + final _service = VehicleService(); + bool _isLoading = false; + + static const _fuelTypes = ['Diesel', 'Essence', 'Electrique']; + + void _showVehicleDialog({VehicleModel? vehicle}) { + final formKey = GlobalKey(); + 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( + 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 _delete(VehicleModel v) async { + final confirm = await showDialog( + 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>( + 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), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/em2rp/lib/views/widgets/event_form/event_details_section.dart b/em2rp/lib/views/widgets/event_form/event_details_section.dart index 7d3a985..c6f0eef 100644 --- a/em2rp/lib/views/widgets/event_form/event_details_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_details_section.dart @@ -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 { ], ), - _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); + } + }, + ), ), ], ); diff --git a/em2rp/lib/views/widgets/event_form/travel_cost_dialog.dart b/em2rp/lib/views/widgets/event_form/travel_cost_dialog.dart new file mode 100644 index 0000000..625f3e2 --- /dev/null +++ b/em2rp/lib/views/widgets/event_form/travel_cost_dialog.dart @@ -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 showTravelCostDialog({ + required BuildContext context, + required String eventAddress, +}) { + return showDialog( + 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 createState() => _TravelCostDialogState(); +} + +class _TravelCostDialogState extends State { + final _vehicleService = VehicleService(); + final _travelService = TravelService(); + + // Données chargées + List _vehicles = []; + List _depots = []; + FuelPrices _fuelPrices = const FuelPrices(); + + // Sélections + VehicleModel? _selectedVehicle; + DepotModel? _selectedDepot; + int _nbTechnicians = 2; + double _hourlyRate = 25.0; + bool _applyFreeZone = false; + + // Résultats + List _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 _loadData() async { + setState(() => _isLoadingData = true); + try { + final results = await Future.wait([ + _vehicleService.getVehicles(), + _travelService.getDepots(), + _travelService.getFuelPrices(), + ]); + final vehicles = results[0] as List; + final depots = results[1] as List; + 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 _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( + 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( + 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)))]), + ); +} diff --git a/em2rp/lib/views/widgets/inputs/address_autocomplete_field.dart b/em2rp/lib/views/widgets/inputs/address_autocomplete_field.dart new file mode 100644 index 0000000..56ad3a5 --- /dev/null +++ b/em2rp/lib/views/widgets/inputs/address_autocomplete_field.dart @@ -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 createState() => + _AddressAutocompleteFieldState(); +} + +class _AddressAutocompleteFieldState extends State { + final _service = TravelService(); + final _focusNode = FocusNode(); + final _overlayKey = GlobalKey(); + + List _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: (_) {}, + ), + ); + } +} diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index 43907e8..92d5c33 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -54,6 +54,10 @@ dependencies: universal_io: ^2.2.2 flutter_dotenv: ^6.0.0 + # Map + flutter_map: ^7.0.2 + latlong2: ^0.9.1 + # Sharing & Launch url_launcher: ^6.2.2 share_plus: ^12.0.1