Compare commits

..

No commits in common. "93ba79bfa7108ca34cbe4b61447f574996dab3bb" and "ec49fef2f308aa3ec86a6a7043e7e1b316154b4d" have entirely different histories.

26 changed files with 269 additions and 1650 deletions

View file

@ -52,7 +52,6 @@
"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"

View file

@ -1,45 +0,0 @@
#!/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);
});

View file

@ -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 and tabs with spaces to keep one row per entry // Replace newlines with spaces to keep one row per entry
const str = String(value) const str = String(value)
.replace(/[\r\n\t]+/g, " ") .replace(/[\r\n]+/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\t]+/g, " ").trim(); const complt = (p.c_disp_complt || "").replace(/[\r\n]+/g, " ").trim();
if (!complt) { if (!complt) {
// No complement: just days + hours // No complement: just days + hours
@ -174,61 +174,15 @@ function buildHoraires(p) {
function formatAddress(p) { function formatAddress(p) {
const parts = []; const parts = [];
let num = (p.c_adr_num || "").trim(); const num = (p.c_adr_num || "").trim();
let street = (p.c_adr_voie || "") const street = (p.c_adr_voie || "").trim();
.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) {
// 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); 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) {
@ -296,84 +250,6 @@ 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...");
@ -405,20 +281,13 @@ for (const feature of features) {
continue; continue;
} }
const rawLat = p.c_lat_coor1; const lat = p.c_lat_coor1;
const rawLon = p.c_long_coor1; const lon = p.c_long_coor1;
if (rawLat == null || rawLon == null) { if (lat == null || lon == 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++;

View file

@ -5,11 +5,10 @@
"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 download && yarn json-to-csv && yarn csv-to-db" "build": "yarn json-to-csv && yarn csv-to-db"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",

View file

@ -17,11 +17,9 @@ const iconStyle = {
iconSize: 0.5, iconSize: 0.5,
}; };
const defibCircleStyle = { const defibIconStyle = {
circleRadius: 8, ...iconStyle,
circleColor: ["get", "defibColor"], iconAllowOverlap: true,
circleStrokeColor: "#FFFFFF",
circleStrokeWidth: 2,
}; };
const useStyles = createStyles(({ theme: { colors } }) => ({ const useStyles = createStyles(({ theme: { colors } }) => ({
@ -66,12 +64,12 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
/> />
{/* Defibrillators (DAE) separate layer (non-clustered) */} {/* Defibrillators (DAE) separate layer (non-clustered) */}
<Maplibre.CircleLayer <Maplibre.SymbolLayer
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={defibCircleStyle} style={defibIconStyle}
/> />
{children} {children}

View file

@ -50,28 +50,6 @@ 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.
* *

View file

@ -212,24 +212,6 @@ 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.
@ -238,7 +220,6 @@ 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,
}; };

View file

@ -1,213 +0,0 @@
// 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 };
}
}

View file

@ -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 = 30_000; const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000; const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
if ( if (
@ -239,15 +239,6 @@ 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;
@ -285,7 +276,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 + 2 && consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
) { ) {
const lastRecovery = wsLastRecoveryDateRef.current const lastRecovery = wsLastRecoveryDateRef.current
@ -319,7 +310,7 @@ export default function useLatestWithSubscription(
// ignore // ignore
} }
networkActions.triggerReload("transport"); networkActions.triggerReload();
} 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

View file

@ -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 = 30_000; const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000; const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
if ( if (
@ -281,15 +281,6 @@ 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;
@ -327,7 +318,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 + 2 && consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
) { ) {
const lastRecovery = wsLastRecoveryDateRef.current const lastRecovery = wsLastRecoveryDateRef.current
@ -361,7 +352,7 @@ export default function useStreamQueryWithSubscription(
// ignore // ignore
} }
networkActions.triggerReload("transport"); networkActions.triggerReload();
} 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

View file

@ -20,7 +20,6 @@ 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;
@ -29,23 +28,18 @@ 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();
if (apolloClient.cache !== sharedApolloCache) {
apolloClient.clearStore(); apolloClient.clearStore();
} }
}
network.apolloClient = createApolloClient({ network.apolloClient = createApolloClient({
store, store,
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();
@ -57,62 +51,34 @@ 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([ const networkState = useNetworkState(["initialized", "triggerReload"]);
"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);
if (isFullReload) {
setTransportClient(network.apolloClient);
setKey((prevKey) => prevKey + 1); 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, networkState.reloadKind]); }, [key]);
if (!networkState.initialized) { if (!networkState.initialized) {
return <Loader />; return <Loader />;
} }
const providers = [[ApolloProvider, { client: transportClient }]]; const providers = [[ApolloProvider, { client: network.apolloClient }]];
return ( return (
<ComposeComponents key={key} components={providers}> <ComposeComponents key={key} components={providers}>

View file

@ -19,7 +19,6 @@ 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();
@ -51,6 +50,8 @@ 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

View file

@ -20,6 +20,8 @@ export default function ControlButtons({
setZoomLevel, setZoomLevel,
detached, detached,
}) { }) {
// const styles = useStyles();
return ( return (
<> <>
<View <View

View file

@ -119,10 +119,8 @@ export default function useFeatures({
defib.horaires_std, defib.horaires_std,
defib.disponible_24h, defib.disponible_24h,
); );
// Only show available defibs on the alert navigation map const icon =
if (status !== "open") { status === "open" ? "green" : status === "closed" ? "red" : "grey";
return;
}
const id = `defib:${defib.id}`; const id = `defib:${defib.id}`;
features.push({ features.push({
@ -130,7 +128,7 @@ export default function useFeatures({
id, id,
properties: { properties: {
id, id,
defibColor: "#4CAF50", icon,
defib, defib,
isDefib: true, isDefib: true,
}, },

View file

@ -13,7 +13,6 @@ import {
useSessionState, useSessionState,
alertActions, alertActions,
useAggregatedMessagesState, useAggregatedMessagesState,
useDefibsState,
defibsActions, defibsActions,
} from "~/stores"; } from "~/stores";
import { getCurrentLocation } from "~/location"; import { getCurrentLocation } from "~/location";
@ -85,17 +84,9 @@ 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 toggleDefibsOnAlertMap = useCallback(async () => { const showDefibsOnAlertMap = useCallback(async () => {
if (defibsEnabled) {
defibsActions.setShowDefibsOnAlertMap(false);
return;
}
if (loadingDaeCorridor) { if (loadingDaeCorridor) {
return; return;
} }
@ -183,7 +174,7 @@ export default withConnectivity(
} finally { } finally {
setLoadingDaeCorridor(false); setLoadingDaeCorridor(false);
} }
}, [alert, defibsEnabled, loadingDaeCorridor, navigation, toast]); }, [alert, loadingDaeCorridor, navigation, toast]);
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION); const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
const notifyAround = useCallback(async () => { const notifyAround = useCallback(async () => {
@ -510,33 +501,21 @@ export default withConnectivity(
> >
<Button <Button
mode="contained" mode="contained"
loading={loadingDaeCorridor}
disabled={loadingDaeCorridor} disabled={loadingDaeCorridor}
icon={() => ( icon={() => (
<MaterialCommunityIcons <MaterialCommunityIcons
name={ name="heart-pulse"
loadingDaeCorridor
? "loading"
: defibsEnabled
? "heart-off"
: "heart-pulse"
}
style={[styles.actionIcon, styles.actionShowDefibsIcon]} style={[styles.actionIcon, styles.actionShowDefibsIcon]}
/> />
)} )}
style={[ style={[styles.actionButton, styles.actionShowDefibsButton]}
styles.actionButton, onPress={showDefibsOnAlertMap}
defibsEnabled
? styles.actionShowDefibsButtonActive
: styles.actionShowDefibsButton,
]}
onPress={toggleDefibsOnAlertMap}
> >
<Text <Text
style={[styles.actionText, styles.actionShowDefibsText]} style={[styles.actionText, styles.actionShowDefibsText]}
> >
{defibsEnabled Afficher les défibrillateurs
? "Ne plus afficher les défibrillateurs"
: "Afficher les défibrillateurs"}
</Text> </Text>
</Button> </Button>
</View> </View>

View file

@ -73,9 +73,6 @@ 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: {},

View file

@ -9,38 +9,20 @@ 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 Drawer from "react-native-drawer"; import { Button } from "react-native-paper";
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 IconTouchTarget from "~/components/IconTouchTarget"; import Loader from "~/components/Loader";
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 { import { osmProfileUrl } from "~/scenes/AlertCurMap/routing";
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",
@ -48,6 +30,21 @@ 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"]);
@ -57,7 +54,6 @@ 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(() => {
@ -69,13 +65,11 @@ 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 defaultProfile = "foot"; const profile = "foot"; // walking itinerary to defib
const [profile, setProfile] = useState(defaultProfile);
// Compute route // Compute route
useEffect(() => { useEffect(() => {
@ -93,7 +87,6 @@ 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}`;
@ -105,13 +98,15 @@ 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 fetchedRoute = result.routes[0]; const route = result.routes[0];
const decoded = polyline const decoded = polyline
.decode(fetchedRoute.geometry) .decode(route.geometry)
.map((p) => p.reverse()); .map((p) => p.reverse());
setRouteCoords(decoded); setRouteCoords(decoded);
setRoute(fetchedRoute); setRouteInfo({
setCalculating(STATE_CALCULATING_LOADED); distance: route.distance,
duration: route.duration,
});
} }
} catch (err) { } catch (err) {
if (err.name !== "AbortError") { if (err.name !== "AbortError") {
@ -137,72 +132,6 @@ 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;
@ -252,8 +181,6 @@ 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 (
@ -282,55 +209,40 @@ export default React.memo(function DAEItemCarte() {
</View> </View>
)} )}
<Drawer {/* Route info bar */}
type="overlay" {routeInfo && (
tweenHandler={(ratio) => ({ <View
main: { opacity: (2 - ratio) / 2 }, style={[
})} styles.routeInfoBar,
tweenDuration={250} {
openDrawerOffset={40}
open={stepperIsOpened}
onOpen={stepperOnOpen}
onClose={stepperOnClose}
tapToClose
negotiatePan
content={
<RoutingSteps
setProfile={setProfile}
profile={profile}
closeStepper={closeStepper}
destinationName={destinationName}
distance={distance}
duration={duration}
instructions={instructions}
calculatingState={calculating}
titleA11yRef={routingSheetTitleA11yRef}
/>
}
>
<View style={{ flex: 1 }}>
{/* A11y entry point for routing steps */}
<IconTouchTarget
ref={a11yStepsEntryRef}
accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
onPress={() => openStepper(a11yStepsEntryRef)}
style={({ pressed }) => ({
position: "absolute",
top: 4,
left: 4,
zIndex: 10,
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderRadius: 8, borderBottomColor: colors.outlineVariant || colors.grey,
opacity: pressed ? 0.7 : 1, },
})} ]}
> >
<MaterialCommunityIcons <MaterialCommunityIcons
name="format-list-bulleted" name="walk"
size={24} size={20}
color={colors.onSurface} color={colors.primary}
/> />
</IconTouchTarget> <Text style={styles.routeInfoText}>
{formatDistance(routeInfo.distance)}
{routeInfo.duration
? ` · ${formatDuration(routeInfo.duration)}`
: ""}
</Text>
{loadingRoute && (
<Text
style={[
styles.routeInfoLoading,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Mise à jour
</Text>
)}
</View>
)}
<MapView <MapView
mapRef={mapRef} mapRef={mapRef}
@ -349,7 +261,7 @@ export default React.memo(function DAEItemCarte() {
: Maplibre.UserTrackingMode.Follow : Maplibre.UserTrackingMode.Follow
} }
followPitch={0} followPitch={0}
zoomLevel={zoomLevel} zoomLevel={DEFAULT_ZOOM_LEVEL}
bounds={bounds} bounds={bounds}
detached={false} detached={false}
/> />
@ -411,23 +323,6 @@ export default React.memo(function DAEItemCarte() {
)} )}
</MapView> </MapView>
{/* Head routing step overlay */}
{instructions.length > 0 && (
<MapHeadRouting
instructions={instructions}
distance={distance}
profileDefaultMode={profileDefaultMode}
openStepper={openStepper}
openStepperTriggerRef={mapHeadOpenRef}
seeAllStepsTriggerRef={mapHeadSeeAllRef}
calculatingState={calculating}
/>
)}
</View>
</Drawer>
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
{/* Route error */} {/* Route error */}
{routeError && !loadingRoute && ( {routeError && !loadingRoute && (
<View style={styles.routeErrorOverlay}> <View style={styles.routeErrorOverlay}>
@ -460,6 +355,22 @@ 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,

View file

@ -1,18 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback } from "react";
import { import { View, ScrollView, StyleSheet } from "react-native";
View, import { Button } from "react-native-paper";
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 { useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import { useDefibsState } from "~/stores"; import { useDefibsState } from "~/stores";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
@ -202,15 +195,6 @@ 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,
@ -219,82 +203,9 @@ 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, closeNavModal]); }, [navigation]);
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;
@ -351,7 +262,7 @@ export default React.memo(function DAEItemInfos() {
<View style={styles.itineraireContainer}> <View style={styles.itineraireContainer}>
<Button <Button
mode="contained" mode="contained"
onPress={openNavModal} onPress={goToCarte}
icon={({ size, color }) => ( icon={({ size, color }) => (
<MaterialCommunityIcons <MaterialCommunityIcons
name="navigation-variant" name="navigation-variant"
@ -377,65 +288,6 @@ 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>
); );
}); });

View file

@ -14,7 +14,6 @@ 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";
@ -53,23 +52,6 @@ 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 (
@ -121,7 +103,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, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL); const [zoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]); const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]);
@ -144,16 +126,8 @@ 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 ( return <Loader />;
<LoadingView message="Chargement des défibrillateurs à proximité…" />
);
} }
return ( return (
@ -222,27 +196,11 @@ 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,
}, },

View file

@ -1,309 +0,0 @@
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",
},
});

View file

@ -1,33 +1,15 @@
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, Switch } from "react-native-paper"; import { Button } 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 (
@ -107,81 +89,10 @@ 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 { const { defibs, loading, error, noLocation, hasLocation, reload } =
defibs, useNearbyDefibs();
allDefibs,
loading,
error,
noLocation,
hasLocation,
reload,
showUnavailable,
} = useNearbyDefibs();
const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []); const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []);
@ -190,35 +101,24 @@ export default React.memo(function DAEListListe() {
return <EmptyNoLocation />; return <EmptyNoLocation />;
} }
// Waiting for location // Loading initial data
if (!hasLocation && allDefibs.length === 0) { if (loading && defibs.length === 0) {
return <LoadingView message="Recherche de votre position…" />; return <Loader />;
}
// 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 && allDefibs.length === 0) { if (error && defibs.length === 0) {
return <EmptyError error={error} onRetry={reload} />; return <EmptyError error={error} onRetry={reload} />;
} }
// No results at all // No results
if (!loading && allDefibs.length === 0 && hasLocation) { if (!loading && defibs.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 && allDefibs.length > 0 && ( {error && defibs.length > 0 && (
<View <View
style={[ style={[
styles.errorBanner, styles.errorBanner,
@ -240,14 +140,6 @@ export default React.memo(function DAEListListe() {
</Text> </Text>
</View> </View>
)} )}
<AvailabilityToggle
showUnavailable={showUnavailable}
allCount={allDefibs.length}
filteredCount={defibs.length}
/>
{showEmptyAvailable ? (
<EmptyNoAvailable />
) : (
<FlatList <FlatList
data={defibs} data={defibs}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
@ -257,27 +149,11 @@ export default React.memo(function DAEListListe() {
maxToRenderPerBatch={10} maxToRenderPerBatch={10}
windowSize={5} 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,
}, },
@ -318,21 +194,4 @@ 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,
},
}); });

View file

@ -1,5 +1,4 @@
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";
@ -7,7 +6,6 @@ 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();
@ -15,9 +13,6 @@ export default React.memo(function DAEList() {
const { colors } = useTheme(); const { colors } = useTheme();
return ( return (
<View style={styles.container}>
<DaeUpdateBanner />
<View style={styles.tabContainer}>
<Tab.Navigator <Tab.Navigator
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@ -62,16 +57,5 @@ export default React.memo(function DAEList() {
}} }}
/> />
</Tab.Navigator> </Tab.Navigator>
</View>
</View>
); );
}); });
const styles = StyleSheet.create({
container: {
flex: 1,
},
tabContainer: {
flex: 1,
},
});

View file

@ -1,29 +1,19 @@
import { useEffect, useRef, useCallback, useMemo, useState } from "react"; import { useEffect, useRef, useCallback, 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 { const { nearUserDefibs, loadingNearUser, errorNearUser } = useDefibsState([
nearUserDefibs,
loadingNearUser,
errorNearUser,
showUnavailable,
daeUpdateState,
} = useDefibsState([
"nearUserDefibs", "nearUserDefibs",
"loadingNearUser", "loadingNearUser",
"errorNearUser", "errorNearUser",
"showUnavailable",
"daeUpdateState",
]); ]);
const hasLocation = const hasLocation =
@ -48,16 +38,6 @@ 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);
@ -78,17 +58,8 @@ 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: filteredDefibs, defibs: nearUserDefibs,
allDefibs: nearUserDefibs,
loading: loadingNearUser, loading: loadingNearUser,
error: errorNearUser, error: errorNearUser,
hasLocation, hasLocation,
@ -97,6 +68,5 @@ export default function useNearbyDefibs() {
lastKnownTimestamp, lastKnownTimestamp,
coords, coords,
reload: loadDefibs, reload: loadDefibs,
showUnavailable,
}; };
} }

View file

@ -81,5 +81,4 @@ 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"),
}; };

View file

@ -5,16 +5,11 @@ 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,
@ -31,10 +26,6 @@ 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,
@ -103,78 +94,6 @@ 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 {
@ -184,18 +103,11 @@ 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,
}; };

View file

@ -9,28 +9,20 @@ 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: (reloadKind = "full") => { triggerReload: () => {
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: () => {