feat: calculateur de frais de déplacement - backend et modèles Flutter
- Cloud Function travel.js : autocomplete Google Places + calcul itinéraires via Google Routes API avec péages Ulys /legs (precision=6) + /rate - Modèles : VehicleModel, DepotModel, RouteResultModel + FuelPrices - Services : VehicleService, TravelService (Firestore CRUD + API calls) - Gestion des données : 3 nouveaux onglets (Dépôts, Véhicules, Prix carburants) - Autocomplétion adresse dans le formulaire événement - Dialog calcul frais : config + carte flutter_map OSM + sélection itinéraire - Injection option FRAIS_KM dans l'événement à la sélection - flutter_map 7.0.2 + latlong2 0.9.1 ajoutés - npm: csv-parser + @mapbox/polyline installés dans functions
This commit is contained in:
@@ -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));
|
||||
|
||||
Generated
+486
-16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
@@ -256,6 +256,20 @@ class EventFormController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Ajoute ou met à jour l'option FRAIS_KM avec le prix calculé.
|
||||
/// L'option est au format attendu par Firestore : { id: "FRAIS_KM", price: <valeur> }
|
||||
void addTravelCostOption(double price) {
|
||||
// Retirer l'éventuelle option FRAIS_KM existante
|
||||
_selectedOptions.removeWhere((opt) => opt['id'] == 'FRAIS_KM');
|
||||
// Ajouter la nouvelle
|
||||
_selectedOptions.add({
|
||||
'id': 'FRAIS_KM',
|
||||
'price': double.parse(price.toStringAsFixed(2)),
|
||||
});
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||
_assignedEquipment = equipment;
|
||||
_assignedContainers = containers;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class DepotModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const DepotModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory DepotModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
return DepotModel(
|
||||
id: id,
|
||||
name: (map['name'] ?? '').toString(),
|
||||
address: (map['address'] ?? '').toString(),
|
||||
createdAt: map['createdAt'] is Timestamp
|
||||
? (map['createdAt'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
factory DepotModel.fromFirestore(DocumentSnapshot doc) {
|
||||
return DepotModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'address': address,
|
||||
'createdAt': createdAt != null
|
||||
? Timestamp.fromDate(createdAt!)
|
||||
: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
DepotModel copyWith({String? id, String? name, String? address}) {
|
||||
return DepotModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/// Résultat d'un itinéraire calculé par Google Maps + Ulys.
|
||||
class RouteResult {
|
||||
/// 'TOLL' ou 'TOLL_FREE'
|
||||
final String routeType;
|
||||
final int distanceMeters;
|
||||
final int durationSeconds;
|
||||
final String encodedPolyline;
|
||||
final double tollCost;
|
||||
|
||||
const RouteResult({
|
||||
required this.routeType,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
required this.encodedPolyline,
|
||||
required this.tollCost,
|
||||
});
|
||||
|
||||
factory RouteResult.fromMap(Map<String, dynamic> map) {
|
||||
return RouteResult(
|
||||
routeType: (map['routeType'] ?? 'TOLL').toString(),
|
||||
distanceMeters: _parseInt(map['distanceMeters'] ?? 0),
|
||||
durationSeconds: _parseInt(map['durationSeconds'] ?? 0),
|
||||
encodedPolyline: (map['encodedPolyline'] ?? '').toString(),
|
||||
tollCost: _parseDouble(map['tollCost'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isTollFree => routeType == 'TOLL_FREE';
|
||||
|
||||
double get distanceKm => distanceMeters / 1000.0;
|
||||
double get durationMinutes => durationSeconds / 60.0;
|
||||
double get durationHours => durationSeconds / 3600.0;
|
||||
|
||||
/// Calcule le coût carburant.
|
||||
/// [consumptionPer100km] : L/100km (ou kWh/100km si électrique)
|
||||
/// [fuelPricePerLiter] : €/L ou €/kWh
|
||||
/// [freeZoneKm] : km gratuits à déduire (zone de gratuité)
|
||||
double fuelCost({
|
||||
required double consumptionPer100km,
|
||||
required double fuelPricePerLiter,
|
||||
double freeZoneKm = 0,
|
||||
}) {
|
||||
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||
return (effectiveKm / 100.0) * consumptionPer100km * fuelPricePerLiter;
|
||||
}
|
||||
|
||||
/// Calcule le coût de maintenance.
|
||||
double maintenanceCost({
|
||||
required double costPerKm,
|
||||
double freeZoneKm = 0,
|
||||
}) {
|
||||
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||
return effectiveKm * costPerKm;
|
||||
}
|
||||
|
||||
/// Calcule le coût de main-d'œuvre (techniciens).
|
||||
/// [freeZoneMinutes] : minutes gratuites à déduire (zone de gratuité)
|
||||
double laborCost({
|
||||
required int nbTechnicians,
|
||||
required double hourlyRate,
|
||||
double freeZoneMinutes = 0,
|
||||
}) {
|
||||
final effectiveMinutes =
|
||||
(durationMinutes - freeZoneMinutes).clamp(0, double.infinity);
|
||||
return (effectiveMinutes / 60.0) * nbTechnicians * hourlyRate;
|
||||
}
|
||||
|
||||
/// Calcule le coût total pour un aller simple.
|
||||
double totalCost({
|
||||
required double consumptionPer100km,
|
||||
required double fuelPricePerLiter,
|
||||
required double maintenanceCostPerKm,
|
||||
required int nbTechnicians,
|
||||
required double hourlyRate,
|
||||
bool applyFreeZone = false,
|
||||
}) {
|
||||
const freeKm = 20.0;
|
||||
const freeMinutes = 20.0;
|
||||
|
||||
return fuelCost(
|
||||
consumptionPer100km: consumptionPer100km,
|
||||
fuelPricePerLiter: fuelPricePerLiter,
|
||||
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||
) +
|
||||
maintenanceCost(
|
||||
costPerKm: maintenanceCostPerKm,
|
||||
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||
) +
|
||||
laborCost(
|
||||
nbTechnicians: nbTechnicians,
|
||||
hourlyRate: hourlyRate,
|
||||
freeZoneMinutes: applyFreeZone ? freeMinutes : 0,
|
||||
) +
|
||||
tollCost;
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prix des carburants (stocké dans Firestore app_config/fuel_prices)
|
||||
class FuelPrices {
|
||||
final double diesel; // €/L
|
||||
final double essence; // €/L
|
||||
final double electricite; // €/kWh
|
||||
|
||||
const FuelPrices({
|
||||
this.diesel = 1.60,
|
||||
this.essence = 1.75,
|
||||
this.electricite = 0.22,
|
||||
});
|
||||
|
||||
factory FuelPrices.fromMap(Map<String, dynamic> map) {
|
||||
return FuelPrices(
|
||||
diesel: _parseDouble(map['diesel'] ?? 1.60),
|
||||
essence: _parseDouble(map['essence'] ?? 1.75),
|
||||
electricite: _parseDouble(map['electricite'] ?? 0.22),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'diesel': diesel,
|
||||
'essence': essence,
|
||||
'electricite': electricite,
|
||||
};
|
||||
|
||||
double priceForFuelType(String fuelType) {
|
||||
switch (fuelType.toLowerCase()) {
|
||||
case 'diesel':
|
||||
return diesel;
|
||||
case 'essence':
|
||||
return essence;
|
||||
case 'electrique':
|
||||
case 'électrique':
|
||||
return electricite;
|
||||
default:
|
||||
return diesel;
|
||||
}
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class VehicleModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final double consumptionPer100km; // L/100km (ou kWh/100km si électrique)
|
||||
final String fuelType; // 'Diesel', 'Essence', 'Electrique'
|
||||
final double maintenanceCostPerKm; // €/km
|
||||
final int tollCategoryId; // 1 à 5 (catégorie Ulys)
|
||||
final DateTime? createdAt;
|
||||
|
||||
const VehicleModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.consumptionPer100km,
|
||||
required this.fuelType,
|
||||
required this.maintenanceCostPerKm,
|
||||
required this.tollCategoryId,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory VehicleModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
return VehicleModel(
|
||||
id: id,
|
||||
name: (map['name'] ?? '').toString(),
|
||||
consumptionPer100km: _parseDouble(map['consumptionPer100km'] ?? 0),
|
||||
fuelType: (map['fuelType'] ?? 'Diesel').toString(),
|
||||
maintenanceCostPerKm: _parseDouble(map['maintenanceCostPerKm'] ?? 0),
|
||||
tollCategoryId: _parseInt(map['tollCategoryId'] ?? 2),
|
||||
createdAt: map['createdAt'] is Timestamp
|
||||
? (map['createdAt'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
factory VehicleModel.fromFirestore(DocumentSnapshot doc) {
|
||||
return VehicleModel.fromMap(
|
||||
doc.data() as Map<String, dynamic>,
|
||||
doc.id,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'consumptionPer100km': consumptionPer100km,
|
||||
'fuelType': fuelType,
|
||||
'maintenanceCostPerKm': maintenanceCostPerKm,
|
||||
'tollCategoryId': tollCategoryId,
|
||||
'createdAt': createdAt != null
|
||||
? Timestamp.fromDate(createdAt!)
|
||||
: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
VehicleModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? consumptionPer100km,
|
||||
String? fuelType,
|
||||
double? maintenanceCostPerKm,
|
||||
int? tollCategoryId,
|
||||
}) {
|
||||
return VehicleModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
consumptionPer100km: consumptionPer100km ?? this.consumptionPer100km,
|
||||
fuelType: fuelType ?? this.fuelType,
|
||||
maintenanceCostPerKm: maintenanceCostPerKm ?? this.maintenanceCostPerKm,
|
||||
tollCategoryId: tollCategoryId ?? this.tollCategoryId,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Label lisible pour l'unité de consommation
|
||||
String get consumptionUnit {
|
||||
if (fuelType == 'Electrique') return 'kWh/100km';
|
||||
return 'L/100km';
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:em2rp/config/api_config.dart';
|
||||
import 'package:em2rp/models/depot_model.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
class TravelService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
|
||||
// ─── Auth token ───────────────────────────────────────────
|
||||
Future<String?> _getToken() async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
return await user?.getIdToken();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers() async {
|
||||
final token = await _getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Autocomplétion d'adresses ────────────────────────────
|
||||
Future<List<String>> autocompleteAddress(String query) async {
|
||||
if (query.trim().length < 3) return [];
|
||||
try {
|
||||
final headers = await _headers();
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsAutocomplete');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'data': {'query': query}}),
|
||||
);
|
||||
if (response.statusCode != 200) return [];
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final predictions = data['predictions'] as List<dynamic>? ?? [];
|
||||
return predictions
|
||||
.map((p) => (p['description'] ?? '').toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[Travel] autocompleteAddress error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Calcul des itinéraires ───────────────────────────────
|
||||
Future<List<RouteResult>> computeRoutes({
|
||||
required String origin,
|
||||
required String destination,
|
||||
int vehicleTollCategory = 2,
|
||||
}) async {
|
||||
try {
|
||||
final headers = await _headers();
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsComputeRoute');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'data': {
|
||||
'origin': origin,
|
||||
'destination': destination,
|
||||
'vehicleTollCategory': vehicleTollCategory,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body);
|
||||
throw Exception('googleMapsComputeRoute: ${err['error']}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final routes = data['routes'] as List<dynamic>? ?? [];
|
||||
return routes
|
||||
.map((r) => RouteResult.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[Travel] computeRoutes error', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prix des carburants ───────────────────────────────────
|
||||
Future<FuelPrices> getFuelPrices() async {
|
||||
try {
|
||||
final doc = await _db.collection('app_config').doc('fuel_prices').get();
|
||||
if (!doc.exists) return const FuelPrices();
|
||||
return FuelPrices.fromMap(doc.data()!);
|
||||
} catch (e) {
|
||||
return const FuelPrices();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFuelPrices(FuelPrices prices) async {
|
||||
await _db.collection('app_config').doc('fuel_prices').set(prices.toMap());
|
||||
}
|
||||
|
||||
// ─── Dépôts ───────────────────────────────────────────────
|
||||
Future<List<DepotModel>> getDepots() async {
|
||||
final snap = await _db.collection('depots').orderBy('name').get();
|
||||
return snap.docs.map((d) => DepotModel.fromFirestore(d)).toList();
|
||||
}
|
||||
|
||||
Stream<List<DepotModel>> watchDepots() {
|
||||
return _db
|
||||
.collection('depots')
|
||||
.orderBy('name')
|
||||
.snapshots()
|
||||
.map((s) => s.docs.map((d) => DepotModel.fromFirestore(d)).toList());
|
||||
}
|
||||
|
||||
Future<String> addDepot(DepotModel depot) async {
|
||||
final ref = await _db.collection('depots').add(depot.toMap());
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
Future<void> updateDepot(DepotModel depot) async {
|
||||
final map = depot.toMap();
|
||||
map.remove('createdAt');
|
||||
await _db.collection('depots').doc(depot.id).update(map);
|
||||
}
|
||||
|
||||
Future<void> deleteDepot(String depotId) async {
|
||||
await _db.collection('depots').doc(depotId).delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance singleton
|
||||
final travelService = TravelService();
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/vehicle_model.dart';
|
||||
|
||||
class VehicleService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
static const String _collection = 'vehicles';
|
||||
|
||||
/// Récupère tous les véhicules, triés par nom.
|
||||
Future<List<VehicleModel>> getVehicles() async {
|
||||
final snapshot = await _db
|
||||
.collection(_collection)
|
||||
.orderBy('name')
|
||||
.get();
|
||||
return snapshot.docs
|
||||
.map((doc) => VehicleModel.fromFirestore(doc))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Stream en temps réel
|
||||
Stream<List<VehicleModel>> watchVehicles() {
|
||||
return _db
|
||||
.collection(_collection)
|
||||
.orderBy('name')
|
||||
.snapshots()
|
||||
.map((snap) =>
|
||||
snap.docs.map((d) => VehicleModel.fromFirestore(d)).toList());
|
||||
}
|
||||
|
||||
/// Ajoute un véhicule
|
||||
Future<String> addVehicle(VehicleModel vehicle) async {
|
||||
final ref = await _db.collection(_collection).add(vehicle.toMap());
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
/// Modifie un véhicule existant
|
||||
Future<void> updateVehicle(VehicleModel vehicle) async {
|
||||
final map = vehicle.toMap();
|
||||
map.remove('createdAt'); // Ne pas écraser la date de création
|
||||
await _db.collection(_collection).doc(vehicle.id).update(map);
|
||||
}
|
||||
|
||||
/// Supprime un véhicule
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
await _db.collection(_collection).doc(vehicleId).delete();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/depot_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/vehicles_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/fuel_prices_management.dart';
|
||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/utils/permission_gate.dart';
|
||||
@@ -32,6 +35,21 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
||||
icon: Icons.file_download,
|
||||
widget: const EventsExport(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Dépôts',
|
||||
icon: Icons.warehouse_outlined,
|
||||
widget: const DepotManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Véhicules',
|
||||
icon: Icons.directions_car_outlined,
|
||||
widget: const VehiclesManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Prix carburants',
|
||||
icon: Icons.local_gas_station,
|
||||
widget: const FuelPricesManagement(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -248,6 +248,17 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
contactPhoneController: controller.contactPhoneController,
|
||||
isMobile: isMobile,
|
||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||
onTravelCostSelected: (price) {
|
||||
controller.addTravelCostOption(price);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Frais de déplacement ajoutés : ${price.toStringAsFixed(2)} €'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
EventStaffAndDocumentsSection(
|
||||
allUsers: controller.allUsers,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
|
||||
/// Affiche 1 ou 2 itinéraires sur une carte OpenStreetMap.
|
||||
/// Route TOLL = bleu, Route TOLL_FREE = vert.
|
||||
class RouteMapWidget extends StatelessWidget {
|
||||
final List<RouteResult> routes;
|
||||
final RouteResult? selectedRoute;
|
||||
|
||||
const RouteMapWidget({
|
||||
super.key,
|
||||
required this.routes,
|
||||
this.selectedRoute,
|
||||
});
|
||||
|
||||
/// Décode une polyline Google encodée en liste de LatLng.
|
||||
List<LatLng> _decode(String encoded) {
|
||||
if (encoded.isEmpty) return [];
|
||||
try {
|
||||
final result = <LatLng>[];
|
||||
int index = 0, lat = 0, lng = 0;
|
||||
final len = encoded.length;
|
||||
|
||||
while (index < len) {
|
||||
int shift = 0, result0 = 0;
|
||||
int b;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result0 |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
final dlat = (result0 & 1) != 0 ? ~(result0 >> 1) : (result0 >> 1);
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result0 = 0;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result0 |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
final dlng = (result0 & 1) != 0 ? ~(result0 >> 1) : (result0 >> 1);
|
||||
lng += dlng;
|
||||
|
||||
result.add(LatLng(lat / 1e5, lng / 1e5));
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
LatLngBounds? _computeBounds(List<List<LatLng>> allPoints) {
|
||||
double? minLat, maxLat, minLng, maxLng;
|
||||
for (final pts in allPoints) {
|
||||
for (final p in pts) {
|
||||
minLat = minLat == null ? p.latitude : p.latitude < minLat ? p.latitude : minLat;
|
||||
maxLat = maxLat == null ? p.latitude : p.latitude > maxLat ? p.latitude : maxLat;
|
||||
minLng = minLng == null ? p.longitude : p.longitude < minLng ? p.longitude : minLng;
|
||||
maxLng = maxLng == null ? p.longitude : p.longitude > maxLng ? p.longitude : maxLng;
|
||||
}
|
||||
}
|
||||
if (minLat == null) return null;
|
||||
return LatLngBounds(
|
||||
LatLng(minLat - 0.02, minLng! - 0.02),
|
||||
LatLng(maxLat! + 0.02, maxLng! + 0.02),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allPolylines = <Polyline>[];
|
||||
final allPointGroups = <List<LatLng>>[];
|
||||
|
||||
for (final route in routes) {
|
||||
final pts = _decode(route.encodedPolyline);
|
||||
if (pts.isEmpty) continue;
|
||||
allPointGroups.add(pts);
|
||||
|
||||
final isSelected =
|
||||
selectedRoute == null || selectedRoute!.routeType == route.routeType;
|
||||
final isToll = route.routeType == 'TOLL';
|
||||
|
||||
allPolylines.add(Polyline(
|
||||
points: pts,
|
||||
strokeWidth: isSelected ? 5.0 : 3.0,
|
||||
color: isToll
|
||||
? (isSelected
|
||||
? const Color(0xFF1565C0)
|
||||
: const Color(0xFF1565C0).withValues(alpha: 0.4))
|
||||
: (isSelected
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFF2E7D32).withValues(alpha: 0.4)),
|
||||
));
|
||||
}
|
||||
|
||||
final bounds = _computeBounds(allPointGroups);
|
||||
final mapController = MapController();
|
||||
|
||||
// Marqueurs de départ / arrivée
|
||||
final markers = <Marker>[];
|
||||
for (final group in allPointGroups) {
|
||||
if (group.isEmpty) continue;
|
||||
// Départ
|
||||
markers.add(Marker(
|
||||
point: group.first,
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: const Icon(Icons.circle, color: Colors.green, size: 20),
|
||||
));
|
||||
// Arrivée
|
||||
markers.add(Marker(
|
||||
point: group.last,
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: const Icon(Icons.location_pin, color: Colors.red, size: 32),
|
||||
));
|
||||
}
|
||||
|
||||
return FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
initialCameraFit: bounds != null
|
||||
? CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(32),
|
||||
)
|
||||
: const CameraFit.coordinates(
|
||||
coordinates: [LatLng(46.2276, 2.2137)], padding: EdgeInsets.all(32)),
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.em2events.em2rp',
|
||||
),
|
||||
PolylineLayer(polylines: allPolylines),
|
||||
MarkerLayer(markers: markers),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/depot_model.dart';
|
||||
import 'package:em2rp/services/travel_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
|
||||
|
||||
class DepotManagement extends StatefulWidget {
|
||||
const DepotManagement({super.key});
|
||||
|
||||
@override
|
||||
State<DepotManagement> createState() => _DepotManagementState();
|
||||
}
|
||||
|
||||
class _DepotManagementState extends State<DepotManagement> {
|
||||
final _service = TravelService();
|
||||
bool _isLoading = false;
|
||||
|
||||
void _showDepotDialog({DepotModel? depot}) {
|
||||
final nameCtrl = TextEditingController(text: depot?.name ?? '');
|
||||
final addressCtrl = TextEditingController(text: depot?.address ?? '');
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(depot == null ? 'Ajouter un dépôt' : 'Modifier le dépôt'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du dépôt *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.warehouse_outlined),
|
||||
hintText: 'ex: Dépôt principal',
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AddressAutocompleteField(
|
||||
controller: addressCtrl,
|
||||
label: 'Adresse du dépôt *',
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
Navigator.pop(ctx);
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (depot == null) {
|
||||
await _service.addDepot(DepotModel(
|
||||
id: '',
|
||||
name: nameCtrl.text.trim(),
|
||||
address: addressCtrl.text.trim(),
|
||||
));
|
||||
} else {
|
||||
await _service.updateDepot(depot.copyWith(
|
||||
name: nameCtrl.text.trim(),
|
||||
address: addressCtrl.text.trim(),
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: Text(depot == null ? 'Ajouter' : 'Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(DepotModel depot) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Supprimer le dépôt'),
|
||||
content: Text('Supprimer "${depot.name}" ?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await _service.deleteDepot(depot.id);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warehouse_outlined, color: AppColors.rouge, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Dépôts',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un dépôt'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => _showDepotDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Définissez les adresses de départ pour le calcul des frais de déplacement.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<DepotModel>>(
|
||||
stream: _service.watchDepots(),
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final depots = snap.data ?? [];
|
||||
if (depots.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warehouse_outlined,
|
||||
size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun dépôt configuré',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.grey[500]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Ajoutez un dépôt pour commencer.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: depots.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final d = depots[i];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.rouge.withOpacity(0.1),
|
||||
child: Icon(Icons.warehouse_outlined, color: AppColors.rouge),
|
||||
),
|
||||
title: Text(d.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(d.address),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => _showDepotDialog(depot: d),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _delete(d),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import 'package:em2rp/services/travel_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
class FuelPricesManagement extends StatefulWidget {
|
||||
const FuelPricesManagement({super.key});
|
||||
|
||||
@override
|
||||
State<FuelPricesManagement> createState() => _FuelPricesManagementState();
|
||||
}
|
||||
|
||||
class _FuelPricesManagementState extends State<FuelPricesManagement> {
|
||||
final _service = TravelService();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _dieselCtrl = TextEditingController();
|
||||
final _essenceCtrl = TextEditingController();
|
||||
final _electriqueCtrl = TextEditingController();
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _isLoading = true);
|
||||
final prices = await _service.getFuelPrices();
|
||||
_dieselCtrl.text = prices.diesel.toStringAsFixed(3);
|
||||
_essenceCtrl.text = prices.essence.toStringAsFixed(3);
|
||||
_electriqueCtrl.text = prices.electricite.toStringAsFixed(3);
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final prices = FuelPrices(
|
||||
diesel: double.parse(_dieselCtrl.text.trim()),
|
||||
essence: double.parse(_essenceCtrl.text.trim()),
|
||||
electricite: double.parse(_electriqueCtrl.text.trim()),
|
||||
);
|
||||
await _service.saveFuelPrices(prices);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Prix mis à jour ✓'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dieselCtrl.dispose();
|
||||
_essenceCtrl.dispose();
|
||||
_electriqueCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildPriceField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String unit,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: color.withOpacity(0.1),
|
||||
child: Icon(icon, color: color),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: unit,
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,3}'))
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Requis';
|
||||
final parsed = double.tryParse(v);
|
||||
if (parsed == null || parsed <= 0) return 'Valeur invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.local_gas_station, color: AppColors.rouge, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Prix des carburants',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ces prix sont utilisés pour calculer automatiquement le coût en carburant des déplacements.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPriceField(
|
||||
controller: _dieselCtrl,
|
||||
label: 'Prix du Diesel',
|
||||
unit: '€/L',
|
||||
icon: Icons.local_gas_station,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPriceField(
|
||||
controller: _essenceCtrl,
|
||||
label: 'Prix de l\'Essence',
|
||||
unit: '€/L',
|
||||
icon: Icons.local_gas_station,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPriceField(
|
||||
controller: _electriqueCtrl,
|
||||
label: 'Prix de l\'Électricité',
|
||||
unit: '€/kWh',
|
||||
icon: Icons.electric_bolt,
|
||||
color: Colors.amber,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: Text(_isSaving ? 'Enregistrement...' : 'Enregistrer les prix'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: _isSaving ? null : _save,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:em2rp/models/vehicle_model.dart';
|
||||
import 'package:em2rp/services/vehicle_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
class VehiclesManagement extends StatefulWidget {
|
||||
const VehiclesManagement({super.key});
|
||||
|
||||
@override
|
||||
State<VehiclesManagement> createState() => _VehiclesManagementState();
|
||||
}
|
||||
|
||||
class _VehiclesManagementState extends State<VehiclesManagement> {
|
||||
final _service = VehicleService();
|
||||
bool _isLoading = false;
|
||||
|
||||
static const _fuelTypes = ['Diesel', 'Essence', 'Electrique'];
|
||||
|
||||
void _showVehicleDialog({VehicleModel? vehicle}) {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final nameCtrl =
|
||||
TextEditingController(text: vehicle?.name ?? '');
|
||||
final consoCtrl = TextEditingController(
|
||||
text: vehicle?.consumptionPer100km.toString() ?? '');
|
||||
final maintCtrl = TextEditingController(
|
||||
text: vehicle?.maintenanceCostPerKm.toString() ?? '');
|
||||
String fuelType = vehicle?.fuelType ?? 'Diesel';
|
||||
int tollCategory = vehicle?.tollCategoryId ?? 2;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(builder: (ctx, setDlg) {
|
||||
return AlertDialog(
|
||||
title: Text(vehicle == null ? 'Ajouter un véhicule' : 'Modifier le véhicule'),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Nom
|
||||
TextFormField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du véhicule *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.directions_car_outlined),
|
||||
hintText: 'ex: Renault Master',
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Type de carburant
|
||||
DropdownButtonFormField<String>(
|
||||
value: fuelType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type de carburant *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.local_gas_station_outlined),
|
||||
),
|
||||
items: _fuelTypes
|
||||
.map((f) => DropdownMenuItem(value: f, child: Text(f)))
|
||||
.toList(),
|
||||
onChanged: (v) => setDlg(() => fuelType = v!),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Consommation
|
||||
TextFormField(
|
||||
controller: consoCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: fuelType == 'Electrique'
|
||||
? 'Consommation (kWh/100km) *'
|
||||
: 'Consommation (L/100km) *',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.speed_outlined),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Requis';
|
||||
if (double.tryParse(v) == null) return 'Nombre invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Coût maintenance
|
||||
TextFormField(
|
||||
controller: maintCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Coût maintenance (€/km) *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.build_outlined),
|
||||
hintText: 'ex: 0.08',
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,3}'))
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Requis';
|
||||
if (double.tryParse(v) == null) return 'Nombre invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Catégorie péage
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Catégorie de péage : $tollCategory',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: List.generate(5, (i) {
|
||||
final cat = i + 1;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setDlg(() => tollCategory = cat),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tollCategory == cat
|
||||
? AppColors.rouge
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$cat',
|
||||
style: TextStyle(
|
||||
color: tollCategory == cat
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'1: motos • 2: VP/VUL ≤3.5t • 3: camions 2 essieux • 4: camions 3 essieux • 5: camions 4+ essieux',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
Navigator.pop(ctx);
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final v = VehicleModel(
|
||||
id: vehicle?.id ?? '',
|
||||
name: nameCtrl.text.trim(),
|
||||
fuelType: fuelType,
|
||||
consumptionPer100km:
|
||||
double.parse(consoCtrl.text.trim()),
|
||||
maintenanceCostPerKm:
|
||||
double.parse(maintCtrl.text.trim()),
|
||||
tollCategoryId: tollCategory,
|
||||
);
|
||||
if (vehicle == null) {
|
||||
await _service.addVehicle(v);
|
||||
} else {
|
||||
await _service.updateVehicle(v);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: Text(vehicle == null ? 'Ajouter' : 'Enregistrer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(VehicleModel v) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Supprimer le véhicule'),
|
||||
content: Text('Supprimer "${v.name}" ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await _service.deleteVehicle(v.id);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon _fuelIcon(String fuelType) {
|
||||
switch (fuelType) {
|
||||
case 'Electrique':
|
||||
return const Icon(Icons.electric_bolt, color: Colors.amber);
|
||||
case 'Essence':
|
||||
return const Icon(Icons.local_gas_station, color: Colors.orange);
|
||||
default:
|
||||
return const Icon(Icons.local_gas_station, color: Colors.blue);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.directions_car_outlined,
|
||||
color: AppColors.rouge, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Véhicules',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un véhicule'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => _showVehicleDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paramétrez la flotte de véhicules pour le calcul automatique des frais de déplacement.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading) const LinearProgressIndicator(),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<VehicleModel>>(
|
||||
stream: _service.watchVehicles(),
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final vehicles = snap.data ?? [];
|
||||
if (vehicles.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.directions_car_outlined,
|
||||
size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun véhicule',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.grey[500]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Ajoutez des véhicules pour commencer.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: vehicles.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final v = vehicles[i];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[100],
|
||||
child: _fuelIcon(v.fuelType),
|
||||
),
|
||||
title: Text(v.name,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'${v.consumptionPer100km} ${v.consumptionUnit} • Maint. ${v.maintenanceCostPerKm} €/km • Cat. péage ${v.tollCategoryId}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(v.fuelType),
|
||||
backgroundColor: v.fuelType == 'Electrique'
|
||||
? Colors.amber[50]
|
||||
: v.fuelType == 'Essence'
|
||||
? Colors.orange[50]
|
||||
: Colors.blue[50],
|
||||
side: BorderSide.none,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => _showVehicleDialog(vehicle: v),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _delete(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
|
||||
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
|
||||
import 'package:em2rp/views/widgets/event_form/travel_cost_dialog.dart';
|
||||
|
||||
class EventDetailsSection extends StatefulWidget {
|
||||
final TextEditingController descriptionController;
|
||||
@@ -11,6 +13,9 @@ class EventDetailsSection extends StatefulWidget {
|
||||
final TextEditingController contactPhoneController;
|
||||
final bool isMobile;
|
||||
final VoidCallback onAnyFieldChanged;
|
||||
/// Callback appelé quand l'utilisateur sélectionne un itinéraire
|
||||
/// avec le prix total calculé (pour ajouter l'option FRAIS_KM)
|
||||
final void Function(double price)? onTravelCostSelected;
|
||||
|
||||
const EventDetailsSection({
|
||||
super.key,
|
||||
@@ -23,6 +28,7 @@ class EventDetailsSection extends StatefulWidget {
|
||||
required this.contactPhoneController,
|
||||
required this.isMobile,
|
||||
required this.onAnyFieldChanged,
|
||||
this.onTravelCostSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -88,16 +94,45 @@ class _EventDetailsSectionState extends State<EventDetailsSection> {
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionTitle('Adresse*'),
|
||||
TextFormField(
|
||||
_buildSectionTitle('Adresse'),
|
||||
AddressAutocompleteField(
|
||||
controller: widget.addressController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Adresse*',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.location_on),
|
||||
),
|
||||
label: 'Adresse*',
|
||||
validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null,
|
||||
onChanged: (_) => widget.onAnyFieldChanged(),
|
||||
onSelected: (_) => widget.onAnyFieldChanged(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Bouton calcul frais déplacement
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.route_outlined),
|
||||
label: const Text('Calculer les frais de déplacement'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.blueGrey[700],
|
||||
side: BorderSide(color: Colors.blueGrey[300]!),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: () async {
|
||||
final address = widget.addressController.text.trim();
|
||||
if (address.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez d\'abord renseigner l\'adresse de l\'événement.'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final price = await showTravelCostDialog(
|
||||
context: context,
|
||||
eventAddress: address,
|
||||
);
|
||||
if (price != null && widget.onTravelCostSelected != null) {
|
||||
widget.onTravelCostSelected!(price);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:em2rp/models/vehicle_model.dart';
|
||||
import 'package:em2rp/models/depot_model.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import 'package:em2rp/services/vehicle_service.dart';
|
||||
import 'package:em2rp/services/travel_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/views/widgets/common/route_map_widget.dart';
|
||||
|
||||
/// Dialog complète de calcul des frais de déplacement.
|
||||
/// Retourne le prix calculé si l'utilisateur sélectionne un itinéraire,
|
||||
/// null sinon.
|
||||
Future<double?> showTravelCostDialog({
|
||||
required BuildContext context,
|
||||
required String eventAddress,
|
||||
}) {
|
||||
return showDialog<double>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => TravelCostDialog(eventAddress: eventAddress),
|
||||
);
|
||||
}
|
||||
|
||||
class TravelCostDialog extends StatefulWidget {
|
||||
final String eventAddress;
|
||||
|
||||
const TravelCostDialog({super.key, required this.eventAddress});
|
||||
|
||||
@override
|
||||
State<TravelCostDialog> createState() => _TravelCostDialogState();
|
||||
}
|
||||
|
||||
class _TravelCostDialogState extends State<TravelCostDialog> {
|
||||
final _vehicleService = VehicleService();
|
||||
final _travelService = TravelService();
|
||||
|
||||
// Données chargées
|
||||
List<VehicleModel> _vehicles = [];
|
||||
List<DepotModel> _depots = [];
|
||||
FuelPrices _fuelPrices = const FuelPrices();
|
||||
|
||||
// Sélections
|
||||
VehicleModel? _selectedVehicle;
|
||||
DepotModel? _selectedDepot;
|
||||
int _nbTechnicians = 2;
|
||||
double _hourlyRate = 25.0;
|
||||
bool _applyFreeZone = false;
|
||||
|
||||
// Résultats
|
||||
List<RouteResult> _routes = [];
|
||||
RouteResult? _selectedRoute;
|
||||
|
||||
// État
|
||||
bool _isLoadingData = true;
|
||||
bool _isCalculating = false;
|
||||
String? _error;
|
||||
|
||||
final _hourlyCtrl = TextEditingController(text: '25');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hourlyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _isLoadingData = true);
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_vehicleService.getVehicles(),
|
||||
_travelService.getDepots(),
|
||||
_travelService.getFuelPrices(),
|
||||
]);
|
||||
final vehicles = results[0] as List<VehicleModel>;
|
||||
final depots = results[1] as List<DepotModel>;
|
||||
final prices = results[2] as FuelPrices;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_vehicles = vehicles;
|
||||
_depots = depots;
|
||||
_fuelPrices = prices;
|
||||
_selectedVehicle = vehicles.isNotEmpty ? vehicles.first : null;
|
||||
_selectedDepot = depots.isNotEmpty ? depots.first : null;
|
||||
_isLoadingData = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Erreur lors du chargement: $e';
|
||||
_isLoadingData = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _calculate() async {
|
||||
if (_selectedVehicle == null || _selectedDepot == null) return;
|
||||
|
||||
setState(() {
|
||||
_isCalculating = true;
|
||||
_error = null;
|
||||
_routes = [];
|
||||
_selectedRoute = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final routes = await _travelService.computeRoutes(
|
||||
origin: _selectedDepot!.address,
|
||||
destination: widget.eventAddress,
|
||||
vehicleTollCategory: _selectedVehicle!.tollCategoryId,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_routes = routes;
|
||||
_isCalculating = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Erreur de calcul: $e';
|
||||
_isCalculating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _selectRoute(RouteResult route) {
|
||||
final total = route.totalCost(
|
||||
consumptionPer100km: _selectedVehicle!.consumptionPer100km,
|
||||
fuelPricePerLiter: _fuelPrices.priceForFuelType(_selectedVehicle!.fuelType),
|
||||
maintenanceCostPerKm: _selectedVehicle!.maintenanceCostPerKm,
|
||||
nbTechnicians: _nbTechnicians,
|
||||
hourlyRate: _hourlyRate,
|
||||
applyFreeZone: _applyFreeZone,
|
||||
);
|
||||
Navigator.of(context).pop(total);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
if (h == 0) return '${m}min';
|
||||
return '${h}h${m.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDistance(int meters) {
|
||||
final km = meters / 1000.0;
|
||||
return '${km.toStringAsFixed(0)} km';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
final isWide = screenW > 900;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: isWide ? 900 : 600,
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Flexible(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.route, color: Colors.white, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Calculer les frais de déplacement',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.eventAddress,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoadingData) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_routes.isEmpty) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _buildConfigPanel(),
|
||||
);
|
||||
}
|
||||
|
||||
// Résultats
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
final isWide = screenW > 900;
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: RouteMapWidget(routes: _routes, selectedRoute: _selectedRoute),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildResultsPanel(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: RouteMapWidget(routes: _routes, selectedRoute: _selectedRoute),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildResultsPanel(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildConfigPanel() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_error != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red[200]!),
|
||||
),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red))),
|
||||
]),
|
||||
),
|
||||
|
||||
// Dépôt de départ
|
||||
_sectionTitle('Dépôt de départ'),
|
||||
if (_depots.isEmpty)
|
||||
_emptyHint(
|
||||
'Aucun dépôt configuré. Ajoutez-en un dans Gestion des données → Dépôts.')
|
||||
else
|
||||
DropdownButtonFormField<DepotModel>(
|
||||
value: _selectedDepot,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.warehouse_outlined),
|
||||
),
|
||||
items: _depots
|
||||
.map((d) => DropdownMenuItem(
|
||||
value: d,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(d.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(d.address,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.grey),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _selectedDepot = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Véhicule
|
||||
_sectionTitle('Véhicule'),
|
||||
if (_vehicles.isEmpty)
|
||||
_emptyHint(
|
||||
'Aucun véhicule configuré. Ajoutez-en un dans Gestion des données → Véhicules.')
|
||||
else
|
||||
DropdownButtonFormField<VehicleModel>(
|
||||
value: _selectedVehicle,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.directions_car_outlined),
|
||||
),
|
||||
items: _vehicles
|
||||
.map((v) => DropdownMenuItem(
|
||||
value: v,
|
||||
child: Text(
|
||||
'${v.name} — ${v.consumptionPer100km} ${v.consumptionUnit} | Cat. péage ${v.tollCategoryId}'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _selectedVehicle = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Techniciens + taux horaire
|
||||
_sectionTitle('Main-d\'œuvre'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Nb techniciens',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: _nbTechnicians > 1
|
||||
? () => setState(() => _nbTechnicians--)
|
||||
: null,
|
||||
),
|
||||
Text(
|
||||
'$_nbTechnicians',
|
||||
style: const TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => setState(() => _nbTechnicians++),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _hourlyCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Taux horaire',
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: '€/h',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (v) {
|
||||
final parsed = double.tryParse(v);
|
||||
if (parsed != null) setState(() => _hourlyRate = parsed);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Zone de gratuité
|
||||
CheckboxListTile(
|
||||
value: _applyFreeZone,
|
||||
onChanged: (v) => setState(() => _applyFreeZone = v ?? false),
|
||||
activeColor: AppColors.rouge,
|
||||
title: const Text('Appliquer la zone de gratuité'),
|
||||
subtitle:
|
||||
const Text('Déduit 20 km (carburant + maintenance) et 20 min (main-d\'œuvre)'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Bouton Calculer
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isCalculating
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.calculate_outlined),
|
||||
label: Text(_isCalculating ? 'Calcul en cours...' : 'Calculer le trajet'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
onPressed: (_isCalculating ||
|
||||
_selectedVehicle == null ||
|
||||
_selectedDepot == null)
|
||||
? null
|
||||
: _calculate,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsPanel() {
|
||||
final vehicle = _selectedVehicle!;
|
||||
final fuelPrice = _fuelPrices.priceForFuelType(vehicle.fuelType);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_sectionTitle('Itinéraires'),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Recalculer'),
|
||||
onPressed: () => setState(() {
|
||||
_routes = [];
|
||||
_selectedRoute = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Légende
|
||||
Row(children: [
|
||||
_legendDot(const Color(0xFF1565C0)),
|
||||
const SizedBox(width: 4),
|
||||
const Text('Avec péage', style: TextStyle(fontSize: 12)),
|
||||
const SizedBox(width: 16),
|
||||
_legendDot(const Color(0xFF2E7D32)),
|
||||
const SizedBox(width: 4),
|
||||
const Text('Sans péage', style: TextStyle(fontSize: 12)),
|
||||
]),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
..._routes.map((route) {
|
||||
final total = route.totalCost(
|
||||
consumptionPer100km: vehicle.consumptionPer100km,
|
||||
fuelPricePerLiter: fuelPrice,
|
||||
maintenanceCostPerKm: vehicle.maintenanceCostPerKm,
|
||||
nbTechnicians: _nbTechnicians,
|
||||
hourlyRate: _hourlyRate,
|
||||
applyFreeZone: _applyFreeZone,
|
||||
);
|
||||
final fuel = route.fuelCost(
|
||||
consumptionPer100km: vehicle.consumptionPer100km,
|
||||
fuelPricePerLiter: fuelPrice,
|
||||
freeZoneKm: _applyFreeZone ? 20 : 0,
|
||||
);
|
||||
final maint = route.maintenanceCost(
|
||||
costPerKm: vehicle.maintenanceCostPerKm,
|
||||
freeZoneKm: _applyFreeZone ? 20 : 0,
|
||||
);
|
||||
final labor = route.laborCost(
|
||||
nbTechnicians: _nbTechnicians,
|
||||
hourlyRate: _hourlyRate,
|
||||
freeZoneMinutes: _applyFreeZone ? 20 : 0,
|
||||
);
|
||||
|
||||
final isToll = route.routeType == 'TOLL';
|
||||
final color = isToll
|
||||
? const Color(0xFF1565C0)
|
||||
: const Color(0xFF2E7D32);
|
||||
|
||||
return _buildRouteCard(
|
||||
route: route,
|
||||
color: color,
|
||||
label: isToll ? '🚗 Route la plus rapide' : '🛣️ Route sans péage',
|
||||
total: total,
|
||||
fuel: fuel,
|
||||
maint: maint,
|
||||
labor: labor,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRouteCard({
|
||||
required RouteResult route,
|
||||
required Color color,
|
||||
required String label,
|
||||
required double total,
|
||||
required double fuel,
|
||||
required double maint,
|
||||
required double labor,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// En-tête
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(10)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatDistance(route.distanceMeters),
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatDuration(route.durationSeconds),
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Détail des coûts
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
children: [
|
||||
_costRow('⛽ Carburant', fuel),
|
||||
_costRow('🔧 Maintenance', maint),
|
||||
_costRow('👷 Main-d\'œuvre (×$_nbTechnicians)', labor),
|
||||
if (route.tollCost > 0) _costRow('🛣️ Péage', route.tollCost),
|
||||
const Divider(),
|
||||
_costRow('Total', total, bold: true, large: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton Sélectionner
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => _selectRoute(route),
|
||||
child: Text(
|
||||
'Sélectionner — ${total.toStringAsFixed(2)} €',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _costRow(String label, double amount,
|
||||
{bool bold = false, bool large = false}) {
|
||||
final style = TextStyle(
|
||||
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: large ? 15 : 13,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: style),
|
||||
Text('${amount.toStringAsFixed(2)} €',
|
||||
style: style.copyWith(
|
||||
color: bold ? AppColors.rouge : Colors.black87)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legendDot(Color color) => Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
);
|
||||
|
||||
Widget _sectionTitle(String title) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
);
|
||||
|
||||
Widget _emptyHint(String msg) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange[200]!),
|
||||
),
|
||||
child:
|
||||
Row(children: [const Icon(Icons.info_outline, color: Colors.orange), const SizedBox(width: 8), Expanded(child: Text(msg, style: const TextStyle(fontSize: 12)))]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/services/travel_service.dart';
|
||||
|
||||
/// Champ texte avec autocomplétion d'adresses via Google Places.
|
||||
/// Remplace le TextFormField standard pour le champ adresse.
|
||||
class AddressAutocompleteField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String? Function(String?)? validator;
|
||||
final void Function(String)? onSelected;
|
||||
final InputDecoration? decoration;
|
||||
|
||||
const AddressAutocompleteField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.label = 'Adresse',
|
||||
this.validator,
|
||||
this.onSelected,
|
||||
this.decoration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddressAutocompleteField> createState() =>
|
||||
_AddressAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _AddressAutocompleteFieldState extends State<AddressAutocompleteField> {
|
||||
final _service = TravelService();
|
||||
final _focusNode = FocusNode();
|
||||
final _overlayKey = GlobalKey();
|
||||
|
||||
List<String> _suggestions = [];
|
||||
Timer? _debounce;
|
||||
OverlayEntry? _overlayEntry;
|
||||
final _layerLink = LayerLink();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
_focusNode.addListener(() {
|
||||
if (!_focusNode.hasFocus) {
|
||||
_removeOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
_debounce?.cancel();
|
||||
final text = widget.controller.text;
|
||||
if (text.length < 3) {
|
||||
_removeOverlay();
|
||||
return;
|
||||
}
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () async {
|
||||
final results = await _service.autocompleteAddress(text);
|
||||
if (mounted) {
|
||||
setState(() => _suggestions = results);
|
||||
if (results.isNotEmpty && _focusNode.hasFocus) {
|
||||
_showSuggestions();
|
||||
} else {
|
||||
_removeOverlay();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showSuggestions() {
|
||||
_removeOverlay();
|
||||
final overlay = Overlay.of(context);
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (ctx) => Positioned(
|
||||
width: _getFieldWidth(),
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: const Offset(0, 56),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _suggestions.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, indent: 16),
|
||||
itemBuilder: (ctx, i) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.location_on_outlined, size: 18),
|
||||
title: Text(
|
||||
_suggestions[i],
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
widget.controller.text = _suggestions[i];
|
||||
widget.controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _suggestions[i].length),
|
||||
);
|
||||
widget.onSelected?.call(_suggestions[i]);
|
||||
_removeOverlay();
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
overlay.insert(_overlayEntry!);
|
||||
setState(() => _showOverlay = true);
|
||||
}
|
||||
|
||||
double _getFieldWidth() {
|
||||
final rb = context.findRenderObject() as RenderBox?;
|
||||
return rb?.size.width ?? 300;
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted) setState(() => _showOverlay = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
_focusNode.dispose();
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: TextFormField(
|
||||
key: _overlayKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: widget.decoration ??
|
||||
InputDecoration(
|
||||
labelText: widget.label,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.location_on_outlined),
|
||||
suffixIcon: widget.controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () {
|
||||
widget.controller.clear();
|
||||
_removeOverlay();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
validator: widget.validator,
|
||||
onChanged: (_) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user