Compare commits
10 commits
ec49fef2f3
...
93ba79bfa7
| Author | SHA1 | Date | |
|---|---|---|---|
| 93ba79bfa7 | |||
| 9914bd5276 | |||
| c366f8f9e8 | |||
| 4091f3a44f | |||
| 609ddb47a9 | |||
| 8c05c8ad4b | |||
| 9a4b587853 | |||
| 150f23d7a9 | |||
| 8a25474770 | |||
| 47928ce9f2 |
26 changed files with 1650 additions and 269 deletions
|
|
@ -52,6 +52,7 @@
|
||||||
"open:deeplink": "npx uri-scheme open --android",
|
"open:deeplink": "npx uri-scheme open --android",
|
||||||
"screenshot:ios": "scripts/screenshot-ios.sh",
|
"screenshot:ios": "scripts/screenshot-ios.sh",
|
||||||
"screenshot:android": "scripts/screenshot-android.sh",
|
"screenshot:android": "scripts/screenshot-android.sh",
|
||||||
|
"dae:download": "yarn --cwd scripts/dae download",
|
||||||
"dae:json-to-csv": "yarn --cwd scripts/dae json-to-csv",
|
"dae:json-to-csv": "yarn --cwd scripts/dae json-to-csv",
|
||||||
"dae:csv-to-db": "yarn --cwd scripts/dae csv-to-db",
|
"dae:csv-to-db": "yarn --cwd scripts/dae csv-to-db",
|
||||||
"dae:build": "yarn --cwd scripts/dae build"
|
"dae:build": "yarn --cwd scripts/dae build"
|
||||||
|
|
|
||||||
45
scripts/dae/download-geodae.mjs
Normal file
45
scripts/dae/download-geodae.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Download the GeoDAE JSON file from data.gouv.fr
|
||||||
|
// Source: https://www.data.gouv.fr/datasets/geodae-base-nationale-des-defibrillateurs
|
||||||
|
// Resource ID: 86ea48a0-dd94-4a23-b71c-80d3041d7db2
|
||||||
|
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const RESOURCE_ID = "86ea48a0-dd94-4a23-b71c-80d3041d7db2";
|
||||||
|
const DOWNLOAD_URL = `https://www.data.gouv.fr/api/1/datasets/r/${RESOURCE_ID}`;
|
||||||
|
const OUTPUT = join(__dirname, "geodae.json");
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
console.log(`Downloading GeoDAE data from data.gouv.fr ...`);
|
||||||
|
console.log(`URL: ${DOWNLOAD_URL}`);
|
||||||
|
|
||||||
|
const response = await fetch(DOWNLOAD_URL, { redirect: "follow" });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Download failed: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
if (contentLength) {
|
||||||
|
console.log(
|
||||||
|
`File size: ${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline(response.body, createWriteStream(OUTPUT));
|
||||||
|
|
||||||
|
console.log(`Saved to ${OUTPUT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
download().catch((err) => {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -14,9 +14,9 @@ const OUTPUT = join(__dirname, "geodae.csv");
|
||||||
|
|
||||||
function escapeCsv(value) {
|
function escapeCsv(value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
// Replace newlines with spaces to keep one row per entry
|
// Replace newlines and tabs with spaces to keep one row per entry
|
||||||
const str = String(value)
|
const str = String(value)
|
||||||
.replace(/[\r\n]+/g, " ")
|
.replace(/[\r\n\t]+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (str.includes('"') || str.includes(",")) {
|
if (str.includes('"') || str.includes(",")) {
|
||||||
return '"' + str.replace(/"/g, '""') + '"';
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
|
@ -140,7 +140,7 @@ function is24h(arr) {
|
||||||
function buildHoraires(p) {
|
function buildHoraires(p) {
|
||||||
const days = formatDays(p.c_disp_j);
|
const days = formatDays(p.c_disp_j);
|
||||||
const hours = formatHours(p.c_disp_h);
|
const hours = formatHours(p.c_disp_h);
|
||||||
const complt = (p.c_disp_complt || "").replace(/[\r\n]+/g, " ").trim();
|
const complt = (p.c_disp_complt || "").replace(/[\r\n\t]+/g, " ").trim();
|
||||||
|
|
||||||
if (!complt) {
|
if (!complt) {
|
||||||
// No complement: just days + hours
|
// No complement: just days + hours
|
||||||
|
|
@ -174,15 +174,61 @@ function buildHoraires(p) {
|
||||||
|
|
||||||
function formatAddress(p) {
|
function formatAddress(p) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const num = (p.c_adr_num || "").trim();
|
let num = (p.c_adr_num || "").trim();
|
||||||
const street = (p.c_adr_voie || "").trim();
|
let street = (p.c_adr_voie || "")
|
||||||
|
.split("\t")[0] // strip tab-separated cp/city embedded in the field
|
||||||
|
.split("|")[0] // strip pipe-separated cp/city embedded in the field
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Drop invalid numbers: placeholders, decimals, letters, etc.
|
||||||
|
// Valid street numbers: digits with optional dash/slash/space separators (e.g. "62", "62-64", "10 12")
|
||||||
|
if (!/^\d[\d\s\-/]*$/.test(num)) num = "";
|
||||||
|
|
||||||
|
const cp = (p.c_com_cp || "").trim();
|
||||||
|
|
||||||
|
// Drop num when it equals the postal code (data entry mistake)
|
||||||
|
if (num && num === cp) num = "";
|
||||||
|
// Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC"
|
||||||
|
let city = (p.c_com_nom || "").trim();
|
||||||
|
if (cp && city) {
|
||||||
|
city = city
|
||||||
|
.replace(
|
||||||
|
new RegExp(
|
||||||
|
"\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)",
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip cp+city already embedded in street field
|
||||||
|
// e.g. "Mont Salomon 38200 Vienne" or "62117 rue de Lambres" when cp matches
|
||||||
|
if (cp && street.includes(cp)) {
|
||||||
|
const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
// Trailing: "street 38200 Vienne" → "street"
|
||||||
|
street = street
|
||||||
|
.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "")
|
||||||
|
.trim();
|
||||||
|
// Leading: "62117 rue de Lambres" → "rue de Lambres"
|
||||||
|
street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (num && street) {
|
if (num && street) {
|
||||||
parts.push(num + " " + street);
|
// Avoid duplicated number when street already starts with the same number
|
||||||
|
// Handles plain "62 Rue…", ranges "62-64 Rue…", and slashes "62/64 Rue…"
|
||||||
|
const numEscaped = num.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const alreadyHasNum = new RegExp("^" + numEscaped + "(?!\\d)").test(street);
|
||||||
|
if (alreadyHasNum) {
|
||||||
|
parts.push(street);
|
||||||
|
} else {
|
||||||
|
parts.push(num + " " + street);
|
||||||
|
}
|
||||||
} else if (street) {
|
} else if (street) {
|
||||||
parts.push(street);
|
parts.push(street);
|
||||||
|
} else if (num) {
|
||||||
|
parts.push(num);
|
||||||
}
|
}
|
||||||
const cp = (p.c_com_cp || "").trim();
|
|
||||||
const city = (p.c_com_nom || "").trim();
|
|
||||||
if (cp && city) {
|
if (cp && city) {
|
||||||
parts.push(cp + " " + city);
|
parts.push(cp + " " + city);
|
||||||
} else if (city) {
|
} else if (city) {
|
||||||
|
|
@ -250,6 +296,84 @@ function passesFilter(p) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if coordinates fall in a plausible French territory.
|
||||||
|
*/
|
||||||
|
function isPlausibleFrance(lat, lon) {
|
||||||
|
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
|
||||||
|
// Metropolitan France
|
||||||
|
if (lat >= 41 && lat <= 52 && lon >= -6 && lon <= 11) return true;
|
||||||
|
// La Réunion
|
||||||
|
if (lat >= -22 && lat <= -20 && lon >= 54 && lon <= 57) return true;
|
||||||
|
// Mayotte
|
||||||
|
if (lat >= -14 && lat <= -12 && lon >= 44 && lon <= 46) return true;
|
||||||
|
// Guadeloupe / Martinique / Saint-Martin / Saint-Barthélemy
|
||||||
|
if (lat >= 14 && lat <= 18 && lon >= -64 && lon <= -60) return true;
|
||||||
|
// Guyane
|
||||||
|
if (lat >= 2 && lat <= 6 && lon >= -55 && lon <= -51) return true;
|
||||||
|
// Nouvelle-Calédonie
|
||||||
|
if (lat >= -23 && lat <= -19 && lon >= 163 && lon <= 169) return true;
|
||||||
|
// Polynésie française
|
||||||
|
if (lat >= -28 && lat <= -7 && lon >= -155 && lon <= -130) return true;
|
||||||
|
// Saint-Pierre-et-Miquelon
|
||||||
|
if (lat >= 46 && lat <= 48 && lon >= -57 && lon <= -55) return true;
|
||||||
|
// Wallis-et-Futuna
|
||||||
|
if (lat >= -15 && lat <= -13 && lon >= -179 && lon <= -176) return true;
|
||||||
|
// TAAF (Kerguelen, Crozet, Amsterdam, etc.)
|
||||||
|
if (lat >= -50 && lat <= -37 && lon >= 50 && lon <= 78) return true;
|
||||||
|
// Clipperton
|
||||||
|
if (lat >= 10 && lat <= 11 && lon >= -110 && lon <= -108) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to fix an out-of-range coordinate by dividing by powers of 10.
|
||||||
|
* Returns the fixed value if it falls in [minValid, maxValid], else null.
|
||||||
|
*/
|
||||||
|
function tryNormalizeCoord(val, limit) {
|
||||||
|
if (Math.abs(val) <= limit) return val;
|
||||||
|
let v = val;
|
||||||
|
while (Math.abs(v) > limit) {
|
||||||
|
v /= 10;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to produce valid WGS84 coordinates from potentially garbled input.
|
||||||
|
* Strategy:
|
||||||
|
* 1. Use properties directly if valid
|
||||||
|
* 2. Fall back to GeoJSON geometry (standard [lon, lat] then swapped)
|
||||||
|
* 3. Try power-of-10 normalization for misplaced decimals
|
||||||
|
*/
|
||||||
|
function fixCoordinates(lat, lon, geometry) {
|
||||||
|
// 1. Already valid WGS84 — trust the source as-is
|
||||||
|
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
|
||||||
|
|
||||||
|
// Out of WGS84 range — try to recover using fallbacks + plausibility check
|
||||||
|
|
||||||
|
// 2. Try GeoJSON geometry
|
||||||
|
if (geometry && geometry.coordinates) {
|
||||||
|
let coords = geometry.coordinates;
|
||||||
|
// Flatten nested arrays (MultiPoint, etc.)
|
||||||
|
while (Array.isArray(coords[0])) coords = coords[0];
|
||||||
|
if (coords.length === 2) {
|
||||||
|
const [gLon, gLat] = coords; // GeoJSON = [lon, lat]
|
||||||
|
if (isPlausibleFrance(gLat, gLon)) return { lat: gLat, lon: gLon };
|
||||||
|
// Try swapped (some entries have lat/lon inverted in geometry)
|
||||||
|
if (isPlausibleFrance(gLon, gLat)) return { lat: gLon, lon: gLat };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try power-of-10 normalization for misplaced decimals
|
||||||
|
const fixedLat = tryNormalizeCoord(lat, 90);
|
||||||
|
const fixedLon = tryNormalizeCoord(lon, 180);
|
||||||
|
if (isPlausibleFrance(fixedLat, fixedLon))
|
||||||
|
return { lat: fixedLat, lon: fixedLon };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
console.log("Reading geodae.json...");
|
console.log("Reading geodae.json...");
|
||||||
|
|
@ -281,13 +405,20 @@ for (const feature of features) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lat = p.c_lat_coor1;
|
const rawLat = p.c_lat_coor1;
|
||||||
const lon = p.c_long_coor1;
|
const rawLon = p.c_long_coor1;
|
||||||
if (lat == null || lon == null) {
|
if (rawLat == null || rawLon == null) {
|
||||||
filtered++;
|
filtered++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fixed = fixCoordinates(rawLat, rawLon, feature.geometry);
|
||||||
|
if (!fixed) {
|
||||||
|
filtered++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { lat, lon } = fixed;
|
||||||
|
|
||||||
const always = isAlwaysAvailable(p);
|
const always = isAlwaysAvailable(p);
|
||||||
if (always) alwaysCount++;
|
if (always) alwaysCount++;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "yarn@4.5.3",
|
"packageManager": "yarn@4.5.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"download": "node download-geodae.mjs",
|
||||||
"json-to-csv": "node geodae-to-csv.js",
|
"json-to-csv": "node geodae-to-csv.js",
|
||||||
"csv-to-db": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db",
|
"csv-to-db": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db",
|
||||||
"csv-to-db:semicolon": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db --delimiter ';'",
|
"csv-to-db:semicolon": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db --delimiter ';'",
|
||||||
"build": "yarn json-to-csv && yarn csv-to-db"
|
"build": "yarn download && yarn json-to-csv && yarn csv-to-db"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ const iconStyle = {
|
||||||
iconSize: 0.5,
|
iconSize: 0.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defibIconStyle = {
|
const defibCircleStyle = {
|
||||||
...iconStyle,
|
circleRadius: 8,
|
||||||
iconAllowOverlap: true,
|
circleColor: ["get", "defibColor"],
|
||||||
|
circleStrokeColor: "#FFFFFF",
|
||||||
|
circleStrokeWidth: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||||
|
|
@ -64,12 +66,12 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Defibrillators (DAE) – separate layer (non-clustered) */}
|
{/* Defibrillators (DAE) – separate layer (non-clustered) */}
|
||||||
<Maplibre.SymbolLayer
|
<Maplibre.CircleLayer
|
||||||
filter={["==", ["get", "isDefib"], true]}
|
filter={["==", ["get", "isDefib"], true]}
|
||||||
key="points-defib"
|
key="points-defib"
|
||||||
id="points-defib"
|
id="points-defib"
|
||||||
aboveLayerID="points-origin"
|
aboveLayerID="points-origin"
|
||||||
style={defibIconStyle}
|
style={defibCircleStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,28 @@ export default function getDb() {
|
||||||
return _dbPromise;
|
return _dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current DB connection and clear all cached state.
|
||||||
|
* After calling this, the next `getDb()` / `getDbSafe()` call will re-open
|
||||||
|
* the DB from disk — picking up any file that was swapped in the meantime.
|
||||||
|
*/
|
||||||
|
export function resetDb() {
|
||||||
|
// Close the op-sqlite backend if it was loaded.
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { resetDbOpSqlite } = require("./openDbOpSqlite");
|
||||||
|
if (typeof resetDbOpSqlite === "function") {
|
||||||
|
resetDbOpSqlite();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// op-sqlite not available — nothing to close.
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbPromise = null;
|
||||||
|
_backendPromise = null;
|
||||||
|
_selectedBackendName = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-throwing DB opener.
|
* Non-throwing DB opener.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,24 @@ async function openDbOpSqlite() {
|
||||||
return _dbPromise;
|
return _dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current DB connection and clear cached promises.
|
||||||
|
* After calling this, the next `openDbOpSqlite()` call will re-open the DB.
|
||||||
|
*/
|
||||||
|
function resetDbOpSqlite() {
|
||||||
|
if (_rawDb) {
|
||||||
|
try {
|
||||||
|
if (typeof _rawDb.close === "function") {
|
||||||
|
_rawDb.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: DB may already be closed or in an invalid state.
|
||||||
|
}
|
||||||
|
_rawDb = null;
|
||||||
|
}
|
||||||
|
_dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Exports (CJS + ESM-ish):
|
// Exports (CJS + ESM-ish):
|
||||||
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
|
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
|
||||||
// cannot hand back a nullish / unexpected callable export shape.
|
// cannot hand back a nullish / unexpected callable export shape.
|
||||||
|
|
@ -220,6 +238,7 @@ module.exports = {
|
||||||
openDbOpSqlite,
|
openDbOpSqlite,
|
||||||
openDb: openDbOpSqlite,
|
openDb: openDbOpSqlite,
|
||||||
default: openDbOpSqlite,
|
default: openDbOpSqlite,
|
||||||
|
resetDbOpSqlite,
|
||||||
// Named export for unit tests.
|
// Named export for unit tests.
|
||||||
adaptDbToRepoInterface,
|
adaptDbToRepoInterface,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
213
src/db/updateDaeDb.js
Normal file
213
src/db/updateDaeDb.js
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Over-the-air DAE database update.
|
||||||
|
//
|
||||||
|
// Downloads a fresh geodae.db from the Minio/S3 bucket, validates it,
|
||||||
|
// swaps the on-device copy, and resets the DB connection so subsequent
|
||||||
|
// queries use the new data.
|
||||||
|
//
|
||||||
|
// IMPORTANT:
|
||||||
|
// - All native requires must stay inside functions so this file can be loaded
|
||||||
|
// in Jest/node without crashing.
|
||||||
|
|
||||||
|
import env from "~/env";
|
||||||
|
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||||
|
|
||||||
|
const DB_NAME = "geodae.db";
|
||||||
|
const GEODAE_BUCKET = "geodae";
|
||||||
|
const METADATA_FILE = "metadata.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public Minio URL for a given bucket/object.
|
||||||
|
* @param {string} object - object key within the geodae bucket
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function geodaeUrl(object) {
|
||||||
|
const base = env.MINIO_URL.replace(/\/+$/, "");
|
||||||
|
return `${base}/${GEODAE_BUCKET}/${object}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UpdateProgress
|
||||||
|
* @property {number} totalBytesWritten
|
||||||
|
* @property {number} totalBytesExpectedToWrite
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UpdateResult
|
||||||
|
* @property {boolean} success
|
||||||
|
* @property {boolean} [alreadyUpToDate]
|
||||||
|
* @property {string} [updatedAt]
|
||||||
|
* @property {Error} [error]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install the latest geodae.db from the server.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {function(UpdateProgress): void} [options.onProgress] - download progress callback
|
||||||
|
* @param {function(string): void} [options.onPhase] - phase change callback ("checking"|"downloading"|"installing")
|
||||||
|
* @returns {Promise<UpdateResult>}
|
||||||
|
*/
|
||||||
|
export async function updateDaeDb({ onProgress, onPhase } = {}) {
|
||||||
|
// Lazy requires to keep Jest/node stable.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const FileSystemModule = require("expo-file-system");
|
||||||
|
const FileSystem = FileSystemModule?.default ?? FileSystemModule;
|
||||||
|
|
||||||
|
const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`;
|
||||||
|
const dbUri = `${sqliteDirUri}/${DB_NAME}`;
|
||||||
|
const tmpUri = `${FileSystem.cacheDirectory}geodae-update-${Date.now()}.db`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Phase 1: Check metadata ──────────────────────────────────────────
|
||||||
|
onPhase?.("checking");
|
||||||
|
|
||||||
|
const metadataUrl = geodaeUrl(METADATA_FILE);
|
||||||
|
const metaResponse = await fetch(metadataUrl);
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`[DAE_UPDATE] Failed to fetch metadata: HTTP ${metaResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const metadata = await metaResponse.json();
|
||||||
|
const remoteUpdatedAt = metadata.updatedAt;
|
||||||
|
|
||||||
|
if (!remoteUpdatedAt) {
|
||||||
|
throw new Error("[DAE_UPDATE] Metadata missing updatedAt field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with stored last update timestamp
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const memoryAsyncStorageModule = require("~/storage/memoryAsyncStorage");
|
||||||
|
const memoryAsyncStorage =
|
||||||
|
memoryAsyncStorageModule?.default ?? memoryAsyncStorageModule;
|
||||||
|
const storedUpdatedAt = await memoryAsyncStorage.getItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
storedUpdatedAt &&
|
||||||
|
new Date(remoteUpdatedAt).getTime() <= new Date(storedUpdatedAt).getTime()
|
||||||
|
) {
|
||||||
|
return { success: true, alreadyUpToDate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Download ────────────────────────────────────────────────
|
||||||
|
onPhase?.("downloading");
|
||||||
|
|
||||||
|
const dbUrl = geodaeUrl(DB_NAME);
|
||||||
|
const downloadResumable = FileSystem.createDownloadResumable(
|
||||||
|
dbUrl,
|
||||||
|
tmpUri,
|
||||||
|
{},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
const downloadResult = await downloadResumable.downloadAsync();
|
||||||
|
|
||||||
|
if (!downloadResult?.uri) {
|
||||||
|
throw new Error("[DAE_UPDATE] Download failed: no URI returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the downloaded file is non-empty
|
||||||
|
const tmpInfo = await FileSystem.getInfoAsync(tmpUri);
|
||||||
|
if (!tmpInfo.exists || tmpInfo.size === 0) {
|
||||||
|
throw new Error("[DAE_UPDATE] Downloaded file is empty or missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Validate ────────────────────────────────────────────────
|
||||||
|
onPhase?.("installing");
|
||||||
|
|
||||||
|
// Quick validation: open the downloaded DB and check schema
|
||||||
|
// We use the same validation as the main DB opener.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { assertDbHasTable } = require("./validateDbSchema");
|
||||||
|
|
||||||
|
// Try to open the temp DB with op-sqlite for validation
|
||||||
|
let validationDb = null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const opSqliteMod = require("@op-engineering/op-sqlite");
|
||||||
|
const open = opSqliteMod?.open ?? opSqliteMod?.default?.open;
|
||||||
|
if (typeof open === "function") {
|
||||||
|
// op-sqlite needs the directory and filename separately
|
||||||
|
const tmpDir = tmpUri.substring(0, tmpUri.lastIndexOf("/"));
|
||||||
|
const tmpName = tmpUri.substring(tmpUri.lastIndexOf("/") + 1);
|
||||||
|
validationDb = open({ name: tmpName, location: tmpDir });
|
||||||
|
|
||||||
|
// Wrap for assertDbHasTable compatibility
|
||||||
|
const getAllAsync = async (sql, params = []) => {
|
||||||
|
const exec =
|
||||||
|
typeof validationDb.executeAsync === "function"
|
||||||
|
? validationDb.executeAsync.bind(validationDb)
|
||||||
|
: validationDb.execute?.bind(validationDb);
|
||||||
|
if (!exec) throw new Error("No execute method on validation DB");
|
||||||
|
const res = params.length ? await exec(sql, params) : await exec(sql);
|
||||||
|
return res?.rows ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
await assertDbHasTable({ getAllAsync }, "defibs");
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
const err = new Error("[DAE_UPDATE] Downloaded DB failed validation");
|
||||||
|
err.cause = validationError;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
// Close validation DB
|
||||||
|
if (validationDb && typeof validationDb.close === "function") {
|
||||||
|
try {
|
||||||
|
validationDb.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: Swap ────────────────────────────────────────────────────
|
||||||
|
// IMPORTANT: resetDb() closes the DB and clears cached promises.
|
||||||
|
// No concurrent DB queries should be in flight at this point.
|
||||||
|
// The caller (store action) is the only code path that triggers this,
|
||||||
|
// and it awaits completion before allowing new queries.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { resetDb } = require("./openDb");
|
||||||
|
resetDb();
|
||||||
|
|
||||||
|
// Ensure SQLite directory exists
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri);
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(sqliteDirUri, {
|
||||||
|
intermediates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the DB file
|
||||||
|
await FileSystem.moveAsync({ from: tmpUri, to: dbUri });
|
||||||
|
|
||||||
|
// Persist the update timestamp
|
||||||
|
await memoryAsyncStorage.setItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
remoteUpdatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
"[DAE_UPDATE] Successfully updated geodae.db to version:",
|
||||||
|
remoteUpdatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, updatedAt: remoteUpdatedAt };
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file on any error (FileSystem is in scope from the outer try)
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn("[DAE_UPDATE] Update failed:", error?.message, error);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ export default function useLatestWithSubscription(
|
||||||
|
|
||||||
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
||||||
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
||||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
|
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
|
||||||
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -239,6 +239,15 @@ export default function useLatestWithSubscription(
|
||||||
if (age < livenessStaleMs) return;
|
if (age < livenessStaleMs) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const becameInactiveAt = lastBecameInactiveAtRef.current;
|
||||||
|
const inactiveWindowMs = becameInactiveAt ? now - becameInactiveAt : null;
|
||||||
|
if (
|
||||||
|
typeof inactiveWindowMs === "number" &&
|
||||||
|
inactiveWindowMs < livenessStaleMs + 15_000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
||||||
lastLivenessKickAtRef.current = now;
|
lastLivenessKickAtRef.current = now;
|
||||||
|
|
||||||
|
|
@ -276,7 +285,7 @@ export default function useLatestWithSubscription(
|
||||||
|
|
||||||
// Escalation policy for repeated consecutive stale kicks.
|
// Escalation policy for repeated consecutive stale kicks.
|
||||||
if (
|
if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
|
||||||
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
const lastRecovery = wsLastRecoveryDateRef.current
|
const lastRecovery = wsLastRecoveryDateRef.current
|
||||||
|
|
@ -310,7 +319,7 @@ export default function useLatestWithSubscription(
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
networkActions.triggerReload();
|
networkActions.triggerReload("transport");
|
||||||
} else if (
|
} else if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
||||||
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export default function useStreamQueryWithSubscription(
|
||||||
|
|
||||||
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
// Some devices keep the WS transport "connected" after a lock/unlock, but the
|
||||||
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
|
||||||
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
|
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
|
||||||
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -281,6 +281,15 @@ export default function useStreamQueryWithSubscription(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Avoid spamming resubscribe triggers.
|
// Avoid spamming resubscribe triggers.
|
||||||
|
const becameInactiveAt = lastBecameInactiveAtRef.current;
|
||||||
|
const inactiveWindowMs = becameInactiveAt ? now - becameInactiveAt : null;
|
||||||
|
if (
|
||||||
|
typeof inactiveWindowMs === "number" &&
|
||||||
|
inactiveWindowMs < livenessStaleMs + 15_000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
|
||||||
lastLivenessKickAtRef.current = now;
|
lastLivenessKickAtRef.current = now;
|
||||||
|
|
||||||
|
|
@ -318,7 +327,7 @@ export default function useStreamQueryWithSubscription(
|
||||||
|
|
||||||
// Escalation policy for repeated consecutive stale kicks.
|
// Escalation policy for repeated consecutive stale kicks.
|
||||||
if (
|
if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
|
||||||
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
const lastRecovery = wsLastRecoveryDateRef.current
|
const lastRecovery = wsLastRecoveryDateRef.current
|
||||||
|
|
@ -352,7 +361,7 @@ export default function useStreamQueryWithSubscription(
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
networkActions.triggerReload();
|
networkActions.triggerReload("transport");
|
||||||
} else if (
|
} else if (
|
||||||
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
|
||||||
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import getRetryMaxAttempts from "./getRetryMaxAttemps";
|
||||||
|
|
||||||
import { createLogger } from "~/lib/logger";
|
import { createLogger } from "~/lib/logger";
|
||||||
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
|
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
|
||||||
|
import createCache from "./cache";
|
||||||
|
|
||||||
const { useNetworkState, networkActions } = store;
|
const { useNetworkState, networkActions } = store;
|
||||||
|
|
||||||
|
|
@ -28,11 +29,15 @@ const networkProvidersLogger = createLogger({
|
||||||
feature: "NetworkProviders",
|
feature: "NetworkProviders",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sharedApolloCache = createCache();
|
||||||
|
|
||||||
const initializeNewApolloClient = (reload) => {
|
const initializeNewApolloClient = (reload) => {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
const { apolloClient } = network;
|
const { apolloClient } = network;
|
||||||
apolloClient.stop();
|
apolloClient.stop();
|
||||||
apolloClient.clearStore();
|
if (apolloClient.cache !== sharedApolloCache) {
|
||||||
|
apolloClient.clearStore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
network.apolloClient = createApolloClient({
|
network.apolloClient = createApolloClient({
|
||||||
|
|
@ -40,6 +45,7 @@ const initializeNewApolloClient = (reload) => {
|
||||||
GRAPHQL_URL: env.GRAPHQL_URL,
|
GRAPHQL_URL: env.GRAPHQL_URL,
|
||||||
GRAPHQL_WS_URL: env.GRAPHQL_WS_URL,
|
GRAPHQL_WS_URL: env.GRAPHQL_WS_URL,
|
||||||
getRetryMaxAttempts,
|
getRetryMaxAttempts,
|
||||||
|
cache: sharedApolloCache,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
initializeNewApolloClient();
|
initializeNewApolloClient();
|
||||||
|
|
@ -51,34 +57,62 @@ network.oaFilesKy = oaFilesKy;
|
||||||
|
|
||||||
export default function NetworkProviders({ children }) {
|
export default function NetworkProviders({ children }) {
|
||||||
const [key, setKey] = useState(0);
|
const [key, setKey] = useState(0);
|
||||||
|
const [transportClient, setTransportClient] = useState(
|
||||||
|
() => network.apolloClient,
|
||||||
|
);
|
||||||
|
|
||||||
const networkState = useNetworkState(["initialized", "triggerReload"]);
|
const networkState = useNetworkState([
|
||||||
|
"initialized",
|
||||||
|
"triggerReload",
|
||||||
|
"reloadKind",
|
||||||
|
"transportGeneration",
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (networkState.triggerReload) {
|
if (networkState.triggerReload) {
|
||||||
networkProvidersLogger.debug("Network triggerReload received", {
|
networkProvidersLogger.debug("Network triggerReload received", {
|
||||||
|
reloadKind: networkState.reloadKind,
|
||||||
reloadId: store.getAuthState()?.reloadId,
|
reloadId: store.getAuthState()?.reloadId,
|
||||||
hasUserToken: !!store.getAuthState()?.userToken,
|
hasUserToken: !!store.getAuthState()?.userToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFullReload = networkState.reloadKind !== "transport";
|
||||||
initializeNewApolloClient(true);
|
initializeNewApolloClient(true);
|
||||||
setKey((prevKey) => prevKey + 1);
|
|
||||||
|
if (isFullReload) {
|
||||||
|
setTransportClient(network.apolloClient);
|
||||||
|
setKey((prevKey) => prevKey + 1);
|
||||||
|
} else {
|
||||||
|
setTransportClient(network.apolloClient);
|
||||||
|
networkProvidersLogger.debug("Network transport recovered in place", {
|
||||||
|
reloadId: store.getAuthState()?.reloadId,
|
||||||
|
hasUserToken: !!store.getAuthState()?.userToken,
|
||||||
|
transportGeneration: networkState.transportGeneration,
|
||||||
|
});
|
||||||
|
networkActions.onReload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [networkState.triggerReload]);
|
}, [
|
||||||
|
networkState.triggerReload,
|
||||||
|
networkState.reloadKind,
|
||||||
|
networkState.transportGeneration,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (key > 0) {
|
if (key > 0) {
|
||||||
networkProvidersLogger.debug("Network reloaded", {
|
networkProvidersLogger.debug("Network reloaded", {
|
||||||
|
reloadKind: networkState.reloadKind,
|
||||||
reloadId: store.getAuthState()?.reloadId,
|
reloadId: store.getAuthState()?.reloadId,
|
||||||
hasUserToken: !!store.getAuthState()?.userToken,
|
hasUserToken: !!store.getAuthState()?.userToken,
|
||||||
});
|
});
|
||||||
networkActions.onReload();
|
networkActions.onReload();
|
||||||
}
|
}
|
||||||
}, [key]);
|
}, [key, networkState.reloadKind]);
|
||||||
|
|
||||||
if (!networkState.initialized) {
|
if (!networkState.initialized) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = [[ApolloProvider, { client: network.apolloClient }]];
|
const providers = [[ApolloProvider, { client: transportClient }]];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposeComponents key={key} components={providers}>
|
<ComposeComponents key={key} components={providers}>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ if (__DEV__ || process.env.NODE_ENV !== "production") {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function createApolloClient(options) {
|
export default function createApolloClient(options) {
|
||||||
|
const cache = options.cache || createCache();
|
||||||
const errorLink = createErrorLink(options);
|
const errorLink = createErrorLink(options);
|
||||||
const authLink = createAuthLink(options);
|
const authLink = createAuthLink(options);
|
||||||
const cancelLink = createCancelLink();
|
const cancelLink = createCancelLink();
|
||||||
|
|
@ -50,8 +51,6 @@ export default function createApolloClient(options) {
|
||||||
httpLink: httpChain,
|
httpLink: httpChain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cache = createCache();
|
|
||||||
|
|
||||||
const apolloClient = new ApolloClient({
|
const apolloClient = new ApolloClient({
|
||||||
cache,
|
cache,
|
||||||
// connectToDevTools: true, // Enable dev tools for better debugging
|
// connectToDevTools: true, // Enable dev tools for better debugging
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ export default function ControlButtons({
|
||||||
setZoomLevel,
|
setZoomLevel,
|
||||||
detached,
|
detached,
|
||||||
}) {
|
}) {
|
||||||
// const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,10 @@ export default function useFeatures({
|
||||||
defib.horaires_std,
|
defib.horaires_std,
|
||||||
defib.disponible_24h,
|
defib.disponible_24h,
|
||||||
);
|
);
|
||||||
const icon =
|
// Only show available defibs on the alert navigation map
|
||||||
status === "open" ? "green" : status === "closed" ? "red" : "grey";
|
if (status !== "open") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = `defib:${defib.id}`;
|
const id = `defib:${defib.id}`;
|
||||||
|
|
||||||
features.push({
|
features.push({
|
||||||
|
|
@ -128,7 +130,7 @@ export default function useFeatures({
|
||||||
id,
|
id,
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
icon,
|
defibColor: "#4CAF50",
|
||||||
defib,
|
defib,
|
||||||
isDefib: true,
|
isDefib: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
useSessionState,
|
useSessionState,
|
||||||
alertActions,
|
alertActions,
|
||||||
useAggregatedMessagesState,
|
useAggregatedMessagesState,
|
||||||
|
useDefibsState,
|
||||||
defibsActions,
|
defibsActions,
|
||||||
} from "~/stores";
|
} from "~/stores";
|
||||||
import { getCurrentLocation } from "~/location";
|
import { getCurrentLocation } from "~/location";
|
||||||
|
|
@ -84,9 +85,17 @@ export default withConnectivity(
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { showDefibsOnAlertMap: defibsEnabled } = useDefibsState([
|
||||||
|
"showDefibsOnAlertMap",
|
||||||
|
]);
|
||||||
const [loadingDaeCorridor, setLoadingDaeCorridor] = useState(false);
|
const [loadingDaeCorridor, setLoadingDaeCorridor] = useState(false);
|
||||||
|
|
||||||
const showDefibsOnAlertMap = useCallback(async () => {
|
const toggleDefibsOnAlertMap = useCallback(async () => {
|
||||||
|
if (defibsEnabled) {
|
||||||
|
defibsActions.setShowDefibsOnAlertMap(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (loadingDaeCorridor) {
|
if (loadingDaeCorridor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +183,7 @@ export default withConnectivity(
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDaeCorridor(false);
|
setLoadingDaeCorridor(false);
|
||||||
}
|
}
|
||||||
}, [alert, loadingDaeCorridor, navigation, toast]);
|
}, [alert, defibsEnabled, loadingDaeCorridor, navigation, toast]);
|
||||||
|
|
||||||
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
|
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
|
||||||
const notifyAround = useCallback(async () => {
|
const notifyAround = useCallback(async () => {
|
||||||
|
|
@ -501,21 +510,33 @@ export default withConnectivity(
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
loading={loadingDaeCorridor}
|
|
||||||
disabled={loadingDaeCorridor}
|
disabled={loadingDaeCorridor}
|
||||||
icon={() => (
|
icon={() => (
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="heart-pulse"
|
name={
|
||||||
|
loadingDaeCorridor
|
||||||
|
? "loading"
|
||||||
|
: defibsEnabled
|
||||||
|
? "heart-off"
|
||||||
|
: "heart-pulse"
|
||||||
|
}
|
||||||
style={[styles.actionIcon, styles.actionShowDefibsIcon]}
|
style={[styles.actionIcon, styles.actionShowDefibsIcon]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
style={[styles.actionButton, styles.actionShowDefibsButton]}
|
style={[
|
||||||
onPress={showDefibsOnAlertMap}
|
styles.actionButton,
|
||||||
|
defibsEnabled
|
||||||
|
? styles.actionShowDefibsButtonActive
|
||||||
|
: styles.actionShowDefibsButton,
|
||||||
|
]}
|
||||||
|
onPress={toggleDefibsOnAlertMap}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.actionText, styles.actionShowDefibsText]}
|
style={[styles.actionText, styles.actionShowDefibsText]}
|
||||||
>
|
>
|
||||||
Afficher les défibrillateurs
|
{defibsEnabled
|
||||||
|
? "Ne plus afficher les défibrillateurs"
|
||||||
|
: "Afficher les défibrillateurs"}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
|
||||||
actionShowDefibsButton: {
|
actionShowDefibsButton: {
|
||||||
backgroundColor: colors.blue,
|
backgroundColor: colors.blue,
|
||||||
},
|
},
|
||||||
|
actionShowDefibsButtonActive: {
|
||||||
|
backgroundColor: colors.grey,
|
||||||
|
},
|
||||||
actionShowDefibsText: {},
|
actionShowDefibsText: {},
|
||||||
actionShowDefibsIcon: {},
|
actionShowDefibsIcon: {},
|
||||||
actionSmsButton: {},
|
actionSmsButton: {},
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,38 @@ import { View, StyleSheet } from "react-native";
|
||||||
import Maplibre from "@maplibre/maplibre-react-native";
|
import Maplibre from "@maplibre/maplibre-react-native";
|
||||||
import polyline from "@mapbox/polyline";
|
import polyline from "@mapbox/polyline";
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { Button } from "react-native-paper";
|
import Drawer from "react-native-drawer";
|
||||||
|
|
||||||
import MapView from "~/containers/Map/MapView";
|
import MapView from "~/containers/Map/MapView";
|
||||||
import Camera from "~/containers/Map/Camera";
|
import Camera from "~/containers/Map/Camera";
|
||||||
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
|
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
|
||||||
import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
|
import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
|
||||||
|
import StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
|
||||||
|
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import Loader from "~/components/Loader";
|
import IconTouchTarget from "~/components/IconTouchTarget";
|
||||||
import { useTheme } from "~/theme";
|
import { useTheme } from "~/theme";
|
||||||
import { useDefibsState, useNetworkState } from "~/stores";
|
import { useDefibsState, useNetworkState } from "~/stores";
|
||||||
import useLocation from "~/hooks/useLocation";
|
import useLocation from "~/hooks/useLocation";
|
||||||
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
import { osmProfileUrl } from "~/scenes/AlertCurMap/routing";
|
import {
|
||||||
|
osmProfileUrl,
|
||||||
|
profileDefaultModes,
|
||||||
|
} from "~/scenes/AlertCurMap/routing";
|
||||||
|
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
|
||||||
|
import {
|
||||||
|
announceForA11yIfScreenReaderEnabled,
|
||||||
|
setA11yFocusAfterInteractions,
|
||||||
|
} from "~/lib/a11y";
|
||||||
|
|
||||||
|
import RoutingSteps from "~/scenes/AlertCurMap/RoutingSteps";
|
||||||
|
import MapHeadRouting from "~/scenes/AlertCurMap/MapHeadRouting";
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATE_CALCULATING_INIT,
|
||||||
|
STATE_CALCULATING_LOADED,
|
||||||
|
STATE_CALCULATING_LOADING,
|
||||||
|
} from "~/scenes/AlertCurMap/constants";
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
open: "#4CAF50",
|
open: "#4CAF50",
|
||||||
|
|
@ -30,21 +48,6 @@ const STATUS_COLORS = {
|
||||||
unknown: "#9E9E9E",
|
unknown: "#9E9E9E",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
|
||||||
if (!seconds || seconds <= 0) return "";
|
|
||||||
const mins = Math.round(seconds / 60);
|
|
||||||
if (mins < 60) return `${mins} min`;
|
|
||||||
const h = Math.floor(mins / 60);
|
|
||||||
const m = mins % 60;
|
|
||||||
return m > 0 ? `${h}h${m}` : `${h}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDistance(meters) {
|
|
||||||
if (!meters || meters <= 0) return "";
|
|
||||||
if (meters < 1000) return `${Math.round(meters)} m`;
|
|
||||||
return `${(meters / 1000).toFixed(1)} km`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(function DAEItemCarte() {
|
export default React.memo(function DAEItemCarte() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
|
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
|
||||||
|
|
@ -54,6 +57,7 @@ export default React.memo(function DAEItemCarte() {
|
||||||
const mapRef = useRef();
|
const mapRef = useRef();
|
||||||
const cameraRef = useRef();
|
const cameraRef = useRef();
|
||||||
const [cameraKey, setCameraKey] = useState(1);
|
const [cameraKey, setCameraKey] = useState(1);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
|
||||||
const abortControllerRef = useRef(null);
|
const abortControllerRef = useRef(null);
|
||||||
|
|
||||||
const refreshCamera = useCallback(() => {
|
const refreshCamera = useCallback(() => {
|
||||||
|
|
@ -65,11 +69,13 @@ export default React.memo(function DAEItemCarte() {
|
||||||
const hasDefibCoords = defib && defib.latitude && defib.longitude;
|
const hasDefibCoords = defib && defib.latitude && defib.longitude;
|
||||||
|
|
||||||
const [routeCoords, setRouteCoords] = useState(null);
|
const [routeCoords, setRouteCoords] = useState(null);
|
||||||
const [routeInfo, setRouteInfo] = useState(null);
|
|
||||||
const [routeError, setRouteError] = useState(null);
|
const [routeError, setRouteError] = useState(null);
|
||||||
const [loadingRoute, setLoadingRoute] = useState(false);
|
const [loadingRoute, setLoadingRoute] = useState(false);
|
||||||
|
const [route, setRoute] = useState(null);
|
||||||
|
const [calculating, setCalculating] = useState(STATE_CALCULATING_INIT);
|
||||||
|
|
||||||
const profile = "foot"; // walking itinerary to defib
|
const defaultProfile = "foot";
|
||||||
|
const [profile, setProfile] = useState(defaultProfile);
|
||||||
|
|
||||||
// Compute route
|
// Compute route
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -87,6 +93,7 @@ export default React.memo(function DAEItemCarte() {
|
||||||
|
|
||||||
const fetchRoute = async () => {
|
const fetchRoute = async () => {
|
||||||
setLoadingRoute(true);
|
setLoadingRoute(true);
|
||||||
|
setCalculating(STATE_CALCULATING_LOADING);
|
||||||
setRouteError(null);
|
setRouteError(null);
|
||||||
try {
|
try {
|
||||||
const origin = `${coords.longitude},${coords.latitude}`;
|
const origin = `${coords.longitude},${coords.latitude}`;
|
||||||
|
|
@ -98,15 +105,13 @@ export default React.memo(function DAEItemCarte() {
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
|
|
||||||
if (result.routes && result.routes.length > 0) {
|
if (result.routes && result.routes.length > 0) {
|
||||||
const route = result.routes[0];
|
const fetchedRoute = result.routes[0];
|
||||||
const decoded = polyline
|
const decoded = polyline
|
||||||
.decode(route.geometry)
|
.decode(fetchedRoute.geometry)
|
||||||
.map((p) => p.reverse());
|
.map((p) => p.reverse());
|
||||||
setRouteCoords(decoded);
|
setRouteCoords(decoded);
|
||||||
setRouteInfo({
|
setRoute(fetchedRoute);
|
||||||
distance: route.distance,
|
setCalculating(STATE_CALCULATING_LOADED);
|
||||||
duration: route.duration,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== "AbortError") {
|
if (err.name !== "AbortError") {
|
||||||
|
|
@ -132,6 +137,72 @@ export default React.memo(function DAEItemCarte() {
|
||||||
profile,
|
profile,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Compute instructions from route steps
|
||||||
|
const allSteps = useMemo(() => {
|
||||||
|
if (!route) return [];
|
||||||
|
return route.legs.flatMap((leg) => leg.steps);
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
const instructions = useMemo(() => {
|
||||||
|
if (allSteps.length === 0) return [];
|
||||||
|
return routeToInstructions(allSteps);
|
||||||
|
}, [allSteps]);
|
||||||
|
|
||||||
|
const distance = useMemo(
|
||||||
|
() => allSteps.reduce((acc, step) => acc + (step?.distance || 0), 0),
|
||||||
|
[allSteps],
|
||||||
|
);
|
||||||
|
const duration = useMemo(
|
||||||
|
() => allSteps.reduce((acc, step) => acc + (step?.duration || 0), 0),
|
||||||
|
[allSteps],
|
||||||
|
);
|
||||||
|
|
||||||
|
const destinationName = useMemo(() => {
|
||||||
|
if (!route) return defib?.nom || "";
|
||||||
|
const { legs } = route;
|
||||||
|
const lastLeg = legs[legs.length - 1];
|
||||||
|
if (!lastLeg) return defib?.nom || "";
|
||||||
|
const { steps } = lastLeg;
|
||||||
|
const lastStep = steps[steps.length - 1];
|
||||||
|
return lastStep?.name || defib?.nom || "";
|
||||||
|
}, [route, defib]);
|
||||||
|
|
||||||
|
// Stepper drawer state
|
||||||
|
const [stepperIsOpened, setStepperIsOpened] = useState(false);
|
||||||
|
const routingSheetTitleA11yRef = useRef(null);
|
||||||
|
const a11yStepsEntryRef = useRef(null);
|
||||||
|
const mapHeadOpenRef = useRef(null);
|
||||||
|
const mapHeadSeeAllRef = useRef(null);
|
||||||
|
const lastStepsTriggerRef = useRef(null);
|
||||||
|
|
||||||
|
const openStepper = useCallback((triggerRef) => {
|
||||||
|
if (triggerRef) {
|
||||||
|
lastStepsTriggerRef.current = triggerRef;
|
||||||
|
}
|
||||||
|
setStepperIsOpened(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeStepper = useCallback(() => {
|
||||||
|
setStepperIsOpened(false);
|
||||||
|
setA11yFocusAfterInteractions(lastStepsTriggerRef);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stepperOnOpen = useCallback(() => {
|
||||||
|
if (!stepperIsOpened) {
|
||||||
|
setStepperIsOpened(true);
|
||||||
|
}
|
||||||
|
setA11yFocusAfterInteractions(routingSheetTitleA11yRef);
|
||||||
|
announceForA11yIfScreenReaderEnabled("Liste des étapes ouverte");
|
||||||
|
}, [stepperIsOpened]);
|
||||||
|
|
||||||
|
const stepperOnClose = useCallback(() => {
|
||||||
|
if (stepperIsOpened) {
|
||||||
|
setStepperIsOpened(false);
|
||||||
|
}
|
||||||
|
announceForA11yIfScreenReaderEnabled("Liste des étapes fermée");
|
||||||
|
setA11yFocusAfterInteractions(lastStepsTriggerRef);
|
||||||
|
}, [stepperIsOpened]);
|
||||||
|
|
||||||
// Defib marker GeoJSON
|
// Defib marker GeoJSON
|
||||||
const defibGeoJSON = useMemo(() => {
|
const defibGeoJSON = useMemo(() => {
|
||||||
if (!hasDefibCoords) return null;
|
if (!hasDefibCoords) return null;
|
||||||
|
|
@ -181,6 +252,8 @@ export default React.memo(function DAEItemCarte() {
|
||||||
};
|
};
|
||||||
}, [hasUserCoords, hasDefibCoords, coords, defib]);
|
}, [hasUserCoords, hasDefibCoords, coords, defib]);
|
||||||
|
|
||||||
|
const profileDefaultMode = profileDefaultModes[profile];
|
||||||
|
|
||||||
if (!defib) return null;
|
if (!defib) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -209,119 +282,151 @@ export default React.memo(function DAEItemCarte() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Route info bar */}
|
<Drawer
|
||||||
{routeInfo && (
|
type="overlay"
|
||||||
<View
|
tweenHandler={(ratio) => ({
|
||||||
style={[
|
main: { opacity: (2 - ratio) / 2 },
|
||||||
styles.routeInfoBar,
|
})}
|
||||||
{
|
tweenDuration={250}
|
||||||
backgroundColor: colors.surface,
|
openDrawerOffset={40}
|
||||||
borderBottomColor: colors.outlineVariant || colors.grey,
|
open={stepperIsOpened}
|
||||||
},
|
onOpen={stepperOnOpen}
|
||||||
]}
|
onClose={stepperOnClose}
|
||||||
>
|
tapToClose
|
||||||
<MaterialCommunityIcons
|
negotiatePan
|
||||||
name="walk"
|
content={
|
||||||
size={20}
|
<RoutingSteps
|
||||||
color={colors.primary}
|
setProfile={setProfile}
|
||||||
|
profile={profile}
|
||||||
|
closeStepper={closeStepper}
|
||||||
|
destinationName={destinationName}
|
||||||
|
distance={distance}
|
||||||
|
duration={duration}
|
||||||
|
instructions={instructions}
|
||||||
|
calculatingState={calculating}
|
||||||
|
titleA11yRef={routingSheetTitleA11yRef}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.routeInfoText}>
|
}
|
||||||
{formatDistance(routeInfo.distance)}
|
>
|
||||||
{routeInfo.duration
|
<View style={{ flex: 1 }}>
|
||||||
? ` · ${formatDuration(routeInfo.duration)}`
|
{/* A11y entry point for routing steps */}
|
||||||
: ""}
|
<IconTouchTarget
|
||||||
</Text>
|
ref={a11yStepsEntryRef}
|
||||||
{loadingRoute && (
|
accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
|
||||||
<Text
|
accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
|
||||||
style={[
|
onPress={() => openStepper(a11yStepsEntryRef)}
|
||||||
styles.routeInfoLoading,
|
style={({ pressed }) => ({
|
||||||
{ color: colors.onSurfaceVariant || colors.grey },
|
position: "absolute",
|
||||||
]}
|
top: 4,
|
||||||
>
|
left: 4,
|
||||||
Mise à jour…
|
zIndex: 10,
|
||||||
</Text>
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="format-list-bulleted"
|
||||||
|
size={24}
|
||||||
|
color={colors.onSurface}
|
||||||
|
/>
|
||||||
|
</IconTouchTarget>
|
||||||
|
|
||||||
|
<MapView
|
||||||
|
mapRef={mapRef}
|
||||||
|
compassViewPosition={1}
|
||||||
|
compassViewMargin={{ x: 10, y: 10 }}
|
||||||
|
>
|
||||||
|
<Camera
|
||||||
|
cameraKey={cameraKey}
|
||||||
|
setCameraKey={setCameraKey}
|
||||||
|
refreshCamera={refreshCamera}
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
followUserLocation={!bounds}
|
||||||
|
followUserMode={
|
||||||
|
bounds
|
||||||
|
? Maplibre.UserTrackingMode.None
|
||||||
|
: Maplibre.UserTrackingMode.Follow
|
||||||
|
}
|
||||||
|
followPitch={0}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
bounds={bounds}
|
||||||
|
detached={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Route line */}
|
||||||
|
{routeGeoJSON && (
|
||||||
|
<Maplibre.ShapeSource id="routeSource" shape={routeGeoJSON}>
|
||||||
|
<Maplibre.LineLayer
|
||||||
|
id="routeLineLayer"
|
||||||
|
style={{
|
||||||
|
lineColor: "rgba(49, 76, 205, 0.84)",
|
||||||
|
lineWidth: 4,
|
||||||
|
lineCap: "round",
|
||||||
|
lineJoin: "round",
|
||||||
|
lineOpacity: 0.84,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Maplibre.ShapeSource>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Defib marker */}
|
||||||
|
{defibGeoJSON && (
|
||||||
|
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
|
||||||
|
<Maplibre.CircleLayer
|
||||||
|
id="defibItemCircle"
|
||||||
|
style={{
|
||||||
|
circleRadius: 10,
|
||||||
|
circleColor: ["get", "color"],
|
||||||
|
circleStrokeColor: "#FFFFFF",
|
||||||
|
circleStrokeWidth: 2.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Maplibre.SymbolLayer
|
||||||
|
id="defibItemLabel"
|
||||||
|
aboveLayerID="defibItemCircle"
|
||||||
|
style={{
|
||||||
|
textField: ["get", "nom"],
|
||||||
|
textSize: 12,
|
||||||
|
textOffset: [0, 1.8],
|
||||||
|
textAnchor: "top",
|
||||||
|
textMaxWidth: 14,
|
||||||
|
textColor: colors.onSurface,
|
||||||
|
textHaloColor: colors.surface,
|
||||||
|
textHaloWidth: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Maplibre.ShapeSource>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User location */}
|
||||||
|
{isLastKnown && hasUserCoords ? (
|
||||||
|
<LastKnownLocationMarker
|
||||||
|
coordinates={coords}
|
||||||
|
timestamp={lastKnownTimestamp}
|
||||||
|
id="lastKnownLocation_daeItem"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Maplibre.UserLocation visible showsUserHeadingIndicator />
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* Head routing step overlay */}
|
||||||
|
{instructions.length > 0 && (
|
||||||
|
<MapHeadRouting
|
||||||
|
instructions={instructions}
|
||||||
|
distance={distance}
|
||||||
|
profileDefaultMode={profileDefaultMode}
|
||||||
|
openStepper={openStepper}
|
||||||
|
openStepperTriggerRef={mapHeadOpenRef}
|
||||||
|
seeAllStepsTriggerRef={mapHeadSeeAllRef}
|
||||||
|
calculatingState={calculating}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
</Drawer>
|
||||||
|
|
||||||
<MapView
|
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
|
||||||
mapRef={mapRef}
|
|
||||||
compassViewPosition={1}
|
|
||||||
compassViewMargin={{ x: 10, y: 10 }}
|
|
||||||
>
|
|
||||||
<Camera
|
|
||||||
cameraKey={cameraKey}
|
|
||||||
setCameraKey={setCameraKey}
|
|
||||||
refreshCamera={refreshCamera}
|
|
||||||
cameraRef={cameraRef}
|
|
||||||
followUserLocation={!bounds}
|
|
||||||
followUserMode={
|
|
||||||
bounds
|
|
||||||
? Maplibre.UserTrackingMode.None
|
|
||||||
: Maplibre.UserTrackingMode.Follow
|
|
||||||
}
|
|
||||||
followPitch={0}
|
|
||||||
zoomLevel={DEFAULT_ZOOM_LEVEL}
|
|
||||||
bounds={bounds}
|
|
||||||
detached={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Route line */}
|
|
||||||
{routeGeoJSON && (
|
|
||||||
<Maplibre.ShapeSource id="routeSource" shape={routeGeoJSON}>
|
|
||||||
<Maplibre.LineLayer
|
|
||||||
id="routeLineLayer"
|
|
||||||
style={{
|
|
||||||
lineColor: "rgba(49, 76, 205, 0.84)",
|
|
||||||
lineWidth: 4,
|
|
||||||
lineCap: "round",
|
|
||||||
lineJoin: "round",
|
|
||||||
lineOpacity: 0.84,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Maplibre.ShapeSource>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Defib marker */}
|
|
||||||
{defibGeoJSON && (
|
|
||||||
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
|
|
||||||
<Maplibre.CircleLayer
|
|
||||||
id="defibItemCircle"
|
|
||||||
style={{
|
|
||||||
circleRadius: 10,
|
|
||||||
circleColor: ["get", "color"],
|
|
||||||
circleStrokeColor: "#FFFFFF",
|
|
||||||
circleStrokeWidth: 2.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Maplibre.SymbolLayer
|
|
||||||
id="defibItemLabel"
|
|
||||||
aboveLayerID="defibItemCircle"
|
|
||||||
style={{
|
|
||||||
textField: ["get", "nom"],
|
|
||||||
textSize: 12,
|
|
||||||
textOffset: [0, 1.8],
|
|
||||||
textAnchor: "top",
|
|
||||||
textMaxWidth: 14,
|
|
||||||
textColor: colors.onSurface,
|
|
||||||
textHaloColor: colors.surface,
|
|
||||||
textHaloWidth: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Maplibre.ShapeSource>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User location */}
|
|
||||||
{isLastKnown && hasUserCoords ? (
|
|
||||||
<LastKnownLocationMarker
|
|
||||||
coordinates={coords}
|
|
||||||
timestamp={lastKnownTimestamp}
|
|
||||||
id="lastKnownLocation_daeItem"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Maplibre.UserLocation visible showsUserHeadingIndicator />
|
|
||||||
)}
|
|
||||||
</MapView>
|
|
||||||
|
|
||||||
{/* Route error */}
|
{/* Route error */}
|
||||||
{routeError && !loadingRoute && (
|
{routeError && !loadingRoute && (
|
||||||
|
|
@ -355,22 +460,6 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
routeInfoBar: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 10,
|
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
routeInfoText: {
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "600",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
routeInfoLoading: {
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
routeErrorOverlay: {
|
routeErrorOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { View, ScrollView, StyleSheet } from "react-native";
|
import {
|
||||||
import { Button } from "react-native-paper";
|
View,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { Button, Modal, Portal } from "react-native-paper";
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { getApps, showLocation } from "react-native-map-link";
|
||||||
|
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import { createStyles, useTheme } from "~/theme";
|
import { useTheme } from "~/theme";
|
||||||
import { useDefibsState } from "~/stores";
|
import { useDefibsState } from "~/stores";
|
||||||
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
|
|
||||||
|
|
@ -195,6 +202,15 @@ export default React.memo(function DAEItemInfos() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
|
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
|
||||||
|
const [navModalVisible, setNavModalVisible] = useState(false);
|
||||||
|
const [availableApps, setAvailableApps] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const result = await getApps({ alwaysIncludeGoogle: true });
|
||||||
|
setAvailableApps(result);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { status, label: availabilityLabel } = getDefibAvailability(
|
const { status, label: availabilityLabel } = getDefibAvailability(
|
||||||
defib?.horaires_std,
|
defib?.horaires_std,
|
||||||
|
|
@ -203,9 +219,82 @@ export default React.memo(function DAEItemInfos() {
|
||||||
|
|
||||||
const statusColor = STATUS_COLORS[status];
|
const statusColor = STATUS_COLORS[status];
|
||||||
|
|
||||||
|
const openNavModal = useCallback(() => {
|
||||||
|
setNavModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeNavModal = useCallback(() => {
|
||||||
|
setNavModalVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const goToCarte = useCallback(() => {
|
const goToCarte = useCallback(() => {
|
||||||
|
closeNavModal();
|
||||||
navigation.navigate("DAEItemCarte");
|
navigation.navigate("DAEItemCarte");
|
||||||
}, [navigation]);
|
}, [navigation, closeNavModal]);
|
||||||
|
|
||||||
|
const openExternalApp = useCallback(
|
||||||
|
(app) => {
|
||||||
|
closeNavModal();
|
||||||
|
if (defib?.latitude && defib?.longitude) {
|
||||||
|
showLocation({
|
||||||
|
latitude: defib.latitude,
|
||||||
|
longitude: defib.longitude,
|
||||||
|
app: app.id,
|
||||||
|
naverCallerName:
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? "com.alertesecours.alertesecours"
|
||||||
|
: "com.alertesecours",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defib, closeNavModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalStyles = useMemo(
|
||||||
|
() => ({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.onSurfaceVariant || colors.grey,
|
||||||
|
textAlign: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: 16,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: colors.outlineVariant || colors.grey,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors],
|
||||||
|
);
|
||||||
|
|
||||||
if (!defib) return null;
|
if (!defib) return null;
|
||||||
|
|
||||||
|
|
@ -262,7 +351,7 @@ export default React.memo(function DAEItemInfos() {
|
||||||
<View style={styles.itineraireContainer}>
|
<View style={styles.itineraireContainer}>
|
||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
onPress={goToCarte}
|
onPress={openNavModal}
|
||||||
icon={({ size, color }) => (
|
icon={({ size, color }) => (
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="navigation-variant"
|
name="navigation-variant"
|
||||||
|
|
@ -288,6 +377,65 @@ export default React.memo(function DAEItemInfos() {
|
||||||
Retour à la liste
|
Retour à la liste
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Navigation app chooser modal */}
|
||||||
|
<Portal>
|
||||||
|
<Modal
|
||||||
|
visible={navModalVisible}
|
||||||
|
onDismiss={closeNavModal}
|
||||||
|
contentContainerStyle={modalStyles.container}
|
||||||
|
>
|
||||||
|
<Text style={modalStyles.title}>Itinéraire</Text>
|
||||||
|
<Text style={modalStyles.subtitle}>
|
||||||
|
Quelle application souhaitez-vous utiliser ?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* In-app navigation option */}
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={goToCarte}
|
||||||
|
style={modalStyles.option}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="navigation-variant"
|
||||||
|
size={24}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={modalStyles.optionText}>
|
||||||
|
Naviguer dans l'application
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* External navigation apps */}
|
||||||
|
{availableApps.map((app) => (
|
||||||
|
<React.Fragment key={app.id}>
|
||||||
|
<View style={modalStyles.separator} />
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => openExternalApp(app)}
|
||||||
|
style={modalStyles.option}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="open-in-new"
|
||||||
|
size={24}
|
||||||
|
color={colors.onSurface}
|
||||||
|
/>
|
||||||
|
<Text style={modalStyles.optionText}>{app.name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
mode="text"
|
||||||
|
onPress={closeNavModal}
|
||||||
|
style={modalStyles.cancelButton}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import MapView from "~/containers/Map/MapView";
|
||||||
import Camera from "~/containers/Map/Camera";
|
import Camera from "~/containers/Map/Camera";
|
||||||
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
|
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
|
||||||
import { BoundType, DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
|
import { BoundType, DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
|
||||||
|
import StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
|
||||||
|
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import Loader from "~/components/Loader";
|
import Loader from "~/components/Loader";
|
||||||
|
|
@ -52,6 +53,23 @@ function defibsToGeoJSON(defibs) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingView({ message }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader containerProps={{ style: styles.loaderInner }} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.loadingText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyNoLocation() {
|
function EmptyNoLocation() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
return (
|
return (
|
||||||
|
|
@ -103,7 +121,7 @@ export default React.memo(function DAEListCarte() {
|
||||||
// Camera state — simple follow user
|
// Camera state — simple follow user
|
||||||
const [followUserLocation] = useState(true);
|
const [followUserLocation] = useState(true);
|
||||||
const [followUserMode] = useState(Maplibre.UserTrackingMode.Follow);
|
const [followUserMode] = useState(Maplibre.UserTrackingMode.Follow);
|
||||||
const [zoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
|
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
|
||||||
|
|
||||||
const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]);
|
const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]);
|
||||||
|
|
||||||
|
|
@ -126,8 +144,16 @@ export default React.memo(function DAEListCarte() {
|
||||||
return <EmptyNoLocation />;
|
return <EmptyNoLocation />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Waiting for location
|
||||||
|
if (!hasLocation && defibs.length === 0 && !hasCoords) {
|
||||||
|
return <LoadingView message="Recherche de votre position…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading defibs from database
|
||||||
if (loading && defibs.length === 0 && !hasCoords) {
|
if (loading && defibs.length === 0 && !hasCoords) {
|
||||||
return <Loader />;
|
return (
|
||||||
|
<LoadingView message="Chargement des défibrillateurs à proximité…" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -196,11 +222,27 @@ export default React.memo(function DAEListCarte() {
|
||||||
<Maplibre.UserLocation visible showsUserHeadingIndicator />
|
<Maplibre.UserLocation visible showsUserHeadingIndicator />
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
|
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
loaderInner: {
|
||||||
|
flex: 0,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
309
src/scenes/DAEList/DaeUpdateBanner.js
Normal file
309
src/scenes/DAEList/DaeUpdateBanner.js
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React, { useEffect, useCallback } from "react";
|
||||||
|
import { View, StyleSheet, TouchableOpacity } from "react-native";
|
||||||
|
import { ProgressBar, ActivityIndicator } from "react-native-paper";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions, useDefibsState } from "~/stores";
|
||||||
|
|
||||||
|
function formatDate(isoString) {
|
||||||
|
if (!isoString) return null;
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DaeUpdateBanner() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const {
|
||||||
|
daeUpdateState,
|
||||||
|
daeUpdateProgress,
|
||||||
|
daeUpdateError,
|
||||||
|
daeLastUpdatedAt,
|
||||||
|
} = useDefibsState([
|
||||||
|
"daeUpdateState",
|
||||||
|
"daeUpdateProgress",
|
||||||
|
"daeUpdateError",
|
||||||
|
"daeLastUpdatedAt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load persisted last-update date on mount
|
||||||
|
useEffect(() => {
|
||||||
|
defibsActions.loadLastDaeUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(() => {
|
||||||
|
defibsActions.triggerDaeUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismissError = useCallback(() => {
|
||||||
|
defibsActions.dismissDaeUpdateError();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
daeUpdateState === "checking" ||
|
||||||
|
daeUpdateState === "downloading" ||
|
||||||
|
daeUpdateState === "installing";
|
||||||
|
|
||||||
|
// Done state
|
||||||
|
if (daeUpdateState === "done") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.primary || "#4CAF50") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="check-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.primary || "#4CAF50"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#4CAF50" }]}
|
||||||
|
>
|
||||||
|
{"Base de donn\u00e9es mise \u00e0 jour !"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already up-to-date
|
||||||
|
if (daeUpdateState === "up-to-date") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(colors.onSurfaceVariant || colors.grey || "#666") + "10",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="check-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{"Donn\u00e9es d\u00e9j\u00e0 \u00e0 jour"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (daeUpdateState === "error") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.error || "#F44336") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="alert-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.errorText, { color: colors.error || "#F44336" }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{daeUpdateError || "Erreur lors de la mise \u00e0 jour"}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleUpdate}
|
||||||
|
style={styles.retryTouch}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="refresh"
|
||||||
|
size={20}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleDismissError}
|
||||||
|
style={styles.dismissTouch}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="close"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloading state
|
||||||
|
if (daeUpdateState === "downloading") {
|
||||||
|
const pct = Math.round(daeUpdateProgress * 100);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
styles.progressBanner,
|
||||||
|
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.progressHeader}>
|
||||||
|
<ActivityIndicator size={14} color={colors.primary} />
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
|
||||||
|
>
|
||||||
|
{`T\u00e9l\u00e9chargement\u2026 ${pct}%`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={daeUpdateProgress}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.progressBar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checking / Installing state
|
||||||
|
if (isActive) {
|
||||||
|
const label =
|
||||||
|
daeUpdateState === "checking"
|
||||||
|
? "V\u00e9rification\u2026"
|
||||||
|
: "Installation\u2026";
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size={14} color={colors.primary} />
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle state
|
||||||
|
const formattedDate = formatDate(daeLastUpdatedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(colors.onSurfaceVariant || colors.grey || "#666") + "08",
|
||||||
|
borderBottomColor: colors.outlineVariant || colors.grey,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="database-sync-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<View style={styles.idleTextContainer}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dateText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formattedDate
|
||||||
|
? `Derni\u00e8re mise \u00e0 jour : ${formattedDate}`
|
||||||
|
: "Donn\u00e9es int\u00e9gr\u00e9es \u00e0 l'application"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleUpdate}
|
||||||
|
style={[
|
||||||
|
styles.updateButton,
|
||||||
|
{ backgroundColor: colors.primary || "#2196F3" },
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="download" size={14} color="#fff" />
|
||||||
|
<Text style={styles.updateButtonText}>{"Mettre \u00e0 jour"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBanner: {
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
progressHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "500",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
retryTouch: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
dismissTouch: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
idleTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
updateButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
updateButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { View, FlatList, StyleSheet } from "react-native";
|
import { View, FlatList, StyleSheet } from "react-native";
|
||||||
import { Button } from "react-native-paper";
|
import { Button, Switch } from "react-native-paper";
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import Loader from "~/components/Loader";
|
import Loader from "~/components/Loader";
|
||||||
import { useTheme } from "~/theme";
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions } from "~/stores";
|
||||||
|
|
||||||
import useNearbyDefibs from "./useNearbyDefibs";
|
import useNearbyDefibs from "./useNearbyDefibs";
|
||||||
import DefibRow from "./DefibRow";
|
import DefibRow from "./DefibRow";
|
||||||
|
|
||||||
|
function LoadingView({ message }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader containerProps={{ style: styles.loaderInner }} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.loadingText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyNoLocation() {
|
function EmptyNoLocation() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
return (
|
return (
|
||||||
|
|
@ -89,10 +107,81 @@ function EmptyNoResults() {
|
||||||
|
|
||||||
const keyExtractor = (item) => item.id;
|
const keyExtractor = (item) => item.id;
|
||||||
|
|
||||||
|
function EmptyNoAvailable({ showUnavailable }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="heart-pulse"
|
||||||
|
size={56}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
style={styles.emptyIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>Aucun défibrillateur disponible</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Aucun défibrillateur actuellement ouvert dans un rayon de 10 km. Activez
|
||||||
|
l'option « Afficher les indisponibles » pour voir tous les
|
||||||
|
défibrillateurs.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvailabilityToggle({ showUnavailable, allCount, filteredCount }) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const onToggle = useCallback(() => {
|
||||||
|
defibsActions.setShowUnavailable(!showUnavailable);
|
||||||
|
}, [showUnavailable]);
|
||||||
|
|
||||||
|
const countLabel =
|
||||||
|
!showUnavailable && allCount > filteredCount
|
||||||
|
? ` (${allCount - filteredCount} masqués)`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.toggleRow,
|
||||||
|
{ borderBottomColor: colors.outlineVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.toggleLabelContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="eye-off-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.toggleLabel,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Afficher les indisponibles{countLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch value={showUnavailable} onValueChange={onToggle} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default React.memo(function DAEListListe() {
|
export default React.memo(function DAEListListe() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { defibs, loading, error, noLocation, hasLocation, reload } =
|
const {
|
||||||
useNearbyDefibs();
|
defibs,
|
||||||
|
allDefibs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
noLocation,
|
||||||
|
hasLocation,
|
||||||
|
reload,
|
||||||
|
showUnavailable,
|
||||||
|
} = useNearbyDefibs();
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []);
|
const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []);
|
||||||
|
|
||||||
|
|
@ -101,24 +190,35 @@ export default React.memo(function DAEListListe() {
|
||||||
return <EmptyNoLocation />;
|
return <EmptyNoLocation />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading initial data
|
// Waiting for location
|
||||||
if (loading && defibs.length === 0) {
|
if (!hasLocation && allDefibs.length === 0) {
|
||||||
return <Loader />;
|
return <LoadingView message="Recherche de votre position…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading defibs from database
|
||||||
|
if (loading && allDefibs.length === 0) {
|
||||||
|
return (
|
||||||
|
<LoadingView message="Chargement des défibrillateurs à proximité…" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state (non-blocking if we have stale data)
|
// Error state (non-blocking if we have stale data)
|
||||||
if (error && defibs.length === 0) {
|
if (error && allDefibs.length === 0) {
|
||||||
return <EmptyError error={error} onRetry={reload} />;
|
return <EmptyError error={error} onRetry={reload} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No results
|
// No results at all
|
||||||
if (!loading && defibs.length === 0 && hasLocation) {
|
if (!loading && allDefibs.length === 0 && hasLocation) {
|
||||||
return <EmptyNoResults />;
|
return <EmptyNoResults />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Has defibs but none available (filtered to empty)
|
||||||
|
const showEmptyAvailable =
|
||||||
|
!loading && defibs.length === 0 && allDefibs.length > 0 && !showUnavailable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
{error && defibs.length > 0 && (
|
{error && allDefibs.length > 0 && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.errorBanner,
|
styles.errorBanner,
|
||||||
|
|
@ -140,20 +240,44 @@ export default React.memo(function DAEListListe() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<FlatList
|
<AvailabilityToggle
|
||||||
data={defibs}
|
showUnavailable={showUnavailable}
|
||||||
keyExtractor={keyExtractor}
|
allCount={allDefibs.length}
|
||||||
renderItem={renderItem}
|
filteredCount={defibs.length}
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
initialNumToRender={15}
|
|
||||||
maxToRenderPerBatch={10}
|
|
||||||
windowSize={5}
|
|
||||||
/>
|
/>
|
||||||
|
{showEmptyAvailable ? (
|
||||||
|
<EmptyNoAvailable />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={defibs}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
initialNumToRender={15}
|
||||||
|
maxToRenderPerBatch={10}
|
||||||
|
windowSize={5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
loaderInner: {
|
||||||
|
flex: 0,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
@ -194,4 +318,21 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
toggleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
toggleLabelContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
toggleLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
|
@ -6,6 +7,7 @@ import { fontFamily, useTheme } from "~/theme";
|
||||||
|
|
||||||
import DAEListListe from "./Liste";
|
import DAEListListe from "./Liste";
|
||||||
import DAEListCarte from "./Carte";
|
import DAEListCarte from "./Carte";
|
||||||
|
import DaeUpdateBanner from "./DaeUpdateBanner";
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
|
@ -13,49 +15,63 @@ export default React.memo(function DAEList() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<View style={styles.container}>
|
||||||
screenOptions={{
|
<DaeUpdateBanner />
|
||||||
headerShown: false,
|
<View style={styles.tabContainer}>
|
||||||
tabBarActiveTintColor: colors.primary,
|
<Tab.Navigator
|
||||||
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
|
screenOptions={{
|
||||||
tabBarLabelStyle: {
|
headerShown: false,
|
||||||
fontFamily,
|
tabBarActiveTintColor: colors.primary,
|
||||||
fontSize: 12,
|
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
|
||||||
},
|
tabBarLabelStyle: {
|
||||||
tabBarStyle: {
|
fontFamily,
|
||||||
backgroundColor: colors.surface,
|
fontSize: 12,
|
||||||
borderTopColor: colors.outlineVariant || colors.grey,
|
},
|
||||||
},
|
tabBarStyle: {
|
||||||
}}
|
backgroundColor: colors.surface,
|
||||||
>
|
borderTopColor: colors.outlineVariant || colors.grey,
|
||||||
<Tab.Screen
|
},
|
||||||
name="DAEListListe"
|
}}
|
||||||
component={DAEListListe}
|
>
|
||||||
options={{
|
<Tab.Screen
|
||||||
tabBarLabel: "Liste",
|
name="DAEListListe"
|
||||||
tabBarIcon: ({ color, size }) => (
|
component={DAEListListe}
|
||||||
<MaterialCommunityIcons
|
options={{
|
||||||
name="format-list-bulleted"
|
tabBarLabel: "Liste",
|
||||||
color={color}
|
tabBarIcon: ({ color, size }) => (
|
||||||
size={size}
|
<MaterialCommunityIcons
|
||||||
/>
|
name="format-list-bulleted"
|
||||||
),
|
color={color}
|
||||||
}}
|
size={size}
|
||||||
/>
|
/>
|
||||||
<Tab.Screen
|
),
|
||||||
name="DAEListCarte"
|
}}
|
||||||
component={DAEListCarte}
|
/>
|
||||||
options={{
|
<Tab.Screen
|
||||||
tabBarLabel: "Carte",
|
name="DAEListCarte"
|
||||||
tabBarIcon: ({ color, size }) => (
|
component={DAEListCarte}
|
||||||
<MaterialCommunityIcons
|
options={{
|
||||||
name="map-marker-outline"
|
tabBarLabel: "Carte",
|
||||||
color={color}
|
tabBarIcon: ({ color, size }) => (
|
||||||
size={size}
|
<MaterialCommunityIcons
|
||||||
/>
|
name="map-marker-outline"
|
||||||
),
|
color={color}
|
||||||
}}
|
size={size}
|
||||||
/>
|
/>
|
||||||
</Tab.Navigator>
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,29 @@
|
||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||||
import useLocation from "~/hooks/useLocation";
|
import useLocation from "~/hooks/useLocation";
|
||||||
import { defibsActions, useDefibsState } from "~/stores";
|
import { defibsActions, useDefibsState } from "~/stores";
|
||||||
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
|
|
||||||
const RADIUS_METERS = 10_000;
|
const RADIUS_METERS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared hook: loads defibs near user and exposes location + loading state.
|
* Shared hook: loads defibs near user and exposes location + loading state.
|
||||||
* The results live in the zustand store so both Liste and Carte tabs share them.
|
* The results live in the zustand store so both Liste and Carte tabs share them.
|
||||||
|
* By default, only available (open) defibs are returned; toggle showUnavailable to see all.
|
||||||
*/
|
*/
|
||||||
export default function useNearbyDefibs() {
|
export default function useNearbyDefibs() {
|
||||||
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
|
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
|
||||||
const { nearUserDefibs, loadingNearUser, errorNearUser } = useDefibsState([
|
const {
|
||||||
|
nearUserDefibs,
|
||||||
|
loadingNearUser,
|
||||||
|
errorNearUser,
|
||||||
|
showUnavailable,
|
||||||
|
daeUpdateState,
|
||||||
|
} = useDefibsState([
|
||||||
"nearUserDefibs",
|
"nearUserDefibs",
|
||||||
"loadingNearUser",
|
"loadingNearUser",
|
||||||
"errorNearUser",
|
"errorNearUser",
|
||||||
|
"showUnavailable",
|
||||||
|
"daeUpdateState",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasLocation =
|
const hasLocation =
|
||||||
|
|
@ -38,6 +48,16 @@ export default function useNearbyDefibs() {
|
||||||
});
|
});
|
||||||
}, [hasLocation, coords]);
|
}, [hasLocation, coords]);
|
||||||
|
|
||||||
|
// After a successful DB update, reset the position cache so the next
|
||||||
|
// render re-queries the fresh database.
|
||||||
|
const prevUpdateState = useRef(daeUpdateState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevUpdateState.current !== "done" && daeUpdateState === "done") {
|
||||||
|
lastLoadedRef.current = null;
|
||||||
|
}
|
||||||
|
prevUpdateState.current = daeUpdateState;
|
||||||
|
}, [daeUpdateState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasLocation) {
|
if (hasLocation) {
|
||||||
setNoLocation(false);
|
setNoLocation(false);
|
||||||
|
|
@ -58,8 +78,17 @@ export default function useNearbyDefibs() {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [hasLocation]);
|
}, [hasLocation]);
|
||||||
|
|
||||||
|
const filteredDefibs = useMemo(() => {
|
||||||
|
if (showUnavailable) return nearUserDefibs;
|
||||||
|
return nearUserDefibs.filter((d) => {
|
||||||
|
const { status } = getDefibAvailability(d.horaires_std, d.disponible_24h);
|
||||||
|
return status === "open";
|
||||||
|
});
|
||||||
|
}, [nearUserDefibs, showUnavailable]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defibs: nearUserDefibs,
|
defibs: filteredDefibs,
|
||||||
|
allDefibs: nearUserDefibs,
|
||||||
loading: loadingNearUser,
|
loading: loadingNearUser,
|
||||||
error: errorNearUser,
|
error: errorNearUser,
|
||||||
hasLocation,
|
hasLocation,
|
||||||
|
|
@ -68,5 +97,6 @@ export default function useNearbyDefibs() {
|
||||||
lastKnownTimestamp,
|
lastKnownTimestamp,
|
||||||
coords,
|
coords,
|
||||||
reload: loadDefibs,
|
reload: loadDefibs,
|
||||||
|
showUnavailable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,5 @@ export const STORAGE_KEYS = {
|
||||||
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
||||||
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
||||||
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
||||||
|
DAE_DB_UPDATED_AT: registerAsyncStorageKey("@dae_db_updated_at"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,16 @@ import {
|
||||||
computeCorridorQueryRadiusMeters,
|
computeCorridorQueryRadiusMeters,
|
||||||
filterDefibsInCorridor,
|
filterDefibsInCorridor,
|
||||||
} from "~/utils/geo/corridor";
|
} from "~/utils/geo/corridor";
|
||||||
|
import { updateDaeDb } from "~/db/updateDaeDb";
|
||||||
|
import memoryAsyncStorage from "~/storage/memoryAsyncStorage";
|
||||||
|
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||||
|
|
||||||
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
|
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
|
||||||
const DEFAULT_CORRIDOR_M = 10_000;
|
const DEFAULT_CORRIDOR_M = 10_000;
|
||||||
const DEFAULT_LIMIT = 200;
|
const DEFAULT_LIMIT = 200;
|
||||||
|
|
||||||
|
const AUTO_DISMISS_DELAY = 4_000;
|
||||||
|
|
||||||
export default createAtom(({ merge, reset }) => {
|
export default createAtom(({ merge, reset }) => {
|
||||||
const actions = {
|
const actions = {
|
||||||
reset,
|
reset,
|
||||||
|
|
@ -26,6 +31,10 @@ export default createAtom(({ merge, reset }) => {
|
||||||
merge({ showDaeSuggestModal });
|
merge({ showDaeSuggestModal });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setShowUnavailable: (showUnavailable) => {
|
||||||
|
merge({ showUnavailable });
|
||||||
|
},
|
||||||
|
|
||||||
loadNearUser: async ({
|
loadNearUser: async ({
|
||||||
userLonLat,
|
userLonLat,
|
||||||
radiusMeters = DEFAULT_NEAR_USER_RADIUS_M,
|
radiusMeters = DEFAULT_NEAR_USER_RADIUS_M,
|
||||||
|
|
@ -94,6 +103,78 @@ export default createAtom(({ merge, reset }) => {
|
||||||
return { defibs: [], error };
|
return { defibs: [], error };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── DAE DB Over-the-Air Update ─────────────────────────────────────
|
||||||
|
|
||||||
|
loadLastDaeUpdate: async () => {
|
||||||
|
try {
|
||||||
|
const stored = await memoryAsyncStorage.getItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
);
|
||||||
|
if (stored) {
|
||||||
|
merge({ daeLastUpdatedAt: stored });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerDaeUpdate: async () => {
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "checking",
|
||||||
|
daeUpdateProgress: 0,
|
||||||
|
daeUpdateError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateDaeDb({
|
||||||
|
onPhase: (phase) => {
|
||||||
|
merge({ daeUpdateState: phase });
|
||||||
|
},
|
||||||
|
onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
|
||||||
|
const progress =
|
||||||
|
totalBytesExpectedToWrite > 0
|
||||||
|
? totalBytesWritten / totalBytesExpectedToWrite
|
||||||
|
: 0;
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "downloading",
|
||||||
|
daeUpdateProgress: progress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.alreadyUpToDate) {
|
||||||
|
merge({ daeUpdateState: "up-to-date" });
|
||||||
|
setTimeout(() => {
|
||||||
|
merge({ daeUpdateState: "idle" });
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "error",
|
||||||
|
daeUpdateError: result.error?.message || "Erreur inconnue",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: update stored timestamp and clear loaded defibs
|
||||||
|
// so the next query fetches from the fresh DB.
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "done",
|
||||||
|
daeLastUpdatedAt: result.updatedAt,
|
||||||
|
nearUserDefibs: [],
|
||||||
|
corridorDefibs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
merge({ daeUpdateState: "idle" });
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissDaeUpdateError: () => {
|
||||||
|
merge({ daeUpdateState: "idle", daeUpdateError: null });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -103,11 +184,18 @@ export default createAtom(({ merge, reset }) => {
|
||||||
showDefibsOnAlertMap: false,
|
showDefibsOnAlertMap: false,
|
||||||
selectedDefib: null,
|
selectedDefib: null,
|
||||||
showDaeSuggestModal: false,
|
showDaeSuggestModal: false,
|
||||||
|
showUnavailable: false,
|
||||||
|
|
||||||
loadingNearUser: false,
|
loadingNearUser: false,
|
||||||
loadingCorridor: false,
|
loadingCorridor: false,
|
||||||
errorNearUser: null,
|
errorNearUser: null,
|
||||||
errorCorridor: null,
|
errorCorridor: null,
|
||||||
|
|
||||||
|
// DAE DB update state
|
||||||
|
daeUpdateState: "idle", // "idle"|"checking"|"downloading"|"installing"|"done"|"error"|"up-to-date"
|
||||||
|
daeUpdateProgress: 0, // 0..1
|
||||||
|
daeUpdateError: null,
|
||||||
|
daeLastUpdatedAt: null,
|
||||||
},
|
},
|
||||||
actions,
|
actions,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,28 @@ export default createAtom(({ merge, get }) => {
|
||||||
wsLastHeartbeatDate: null,
|
wsLastHeartbeatDate: null,
|
||||||
wsLastRecoveryDate: null,
|
wsLastRecoveryDate: null,
|
||||||
triggerReload: false,
|
triggerReload: false,
|
||||||
|
reloadKind: null,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
hasInternetConnection: true,
|
hasInternetConnection: true,
|
||||||
|
transportGeneration: 0,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
triggerReload: () => {
|
triggerReload: (reloadKind = "full") => {
|
||||||
merge({
|
merge({
|
||||||
initialized: false,
|
|
||||||
triggerReload: true,
|
triggerReload: true,
|
||||||
|
reloadKind,
|
||||||
|
initialized: reloadKind === "transport" ? true : false,
|
||||||
|
transportGeneration:
|
||||||
|
reloadKind === "transport"
|
||||||
|
? get("transportGeneration") + 1
|
||||||
|
: get("transportGeneration"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onReload: () => {
|
onReload: () => {
|
||||||
merge({
|
merge({
|
||||||
initialized: true,
|
initialized: true,
|
||||||
triggerReload: false,
|
triggerReload: false,
|
||||||
|
reloadKind: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
WSConnected: () => {
|
WSConnected: () => {
|
||||||
|
|
|
||||||
Reference in a new issue