feat: first darft mvp

This commit is contained in:
devthejo 2026-03-07 07:53:08 +01:00
parent 7ce0daa7ae
commit a3a522c74a
50 changed files with 3732 additions and 69 deletions

View file

@ -4,6 +4,7 @@ module.exports = {
root: true, root: true,
env: { env: {
"react-native/react-native": true, "react-native/react-native": true,
jest: true,
}, },
extends: [ extends: [
"plugin:prettier/recommended", "plugin:prettier/recommended",
@ -37,6 +38,12 @@ module.exports = {
}, },
"import/ignore": ["react-native"], "import/ignore": ["react-native"],
"import/resolver": { "import/resolver": {
// Ensure ESLint can resolve regular JS packages under Yarn PnP as well.
// Without this, some deps (ex: expo-sqlite) may be incorrectly flagged
// by import/no-unresolved even though they're present.
node: {
extensions: [".js", ".jsx", ".ts", ".tsx", ".json"],
},
typescript: {}, typescript: {},
}, },
}, },

View file

@ -147,6 +147,13 @@ android {
} }
} }
packagingOptions { packagingOptions {
// Resolve duplicate native libs shipped by multiple dependencies (e.g. op-sqlite + react-android).
// Needed for debug flavors too (merge<Variant>NativeLibs).
pickFirsts += [
'lib/**/libjsi.so',
'lib/**/libreactnative.so',
]
jniLibs { jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
} }

View file

@ -2,6 +2,35 @@
## Recently Completed Features ## Recently Completed Features
### DAE v1 (Tasks 19) — 2026-03-06
1. Embedded DAE DB + safe open path:
- ✅ Embedded DB asset: `src/assets/db/geodae.db`
- ✅ Safe open path + repository: `src/db/openDb.js`, `src/db/defibsRepo.js`
2. Utilities + tests:
- ✅ Corridor/geo utils: `src/utils/geo/corridor.js`
- ✅ DAE helpers: `src/utils/dae/getDefibAvailability.js`, `src/utils/dae/subjectSuggestsDefib.js`
- ✅ Jest config: `jest.config.js`
3. Store:
- ✅ Defibrillators store: `src/stores/defibs.js`
4. Screens + navigation:
- ✅ DAE list + item screens: `src/scenes/DAEList/index.js`, `src/scenes/DAEItem/index.js`
- ✅ Navigation wiring: `src/navigation/Drawer.js`, `src/navigation/RootStack.js`
5. Alert integration:
- ✅ Alert overview + map hooks: `src/scenes/AlertCurOverview/index.js`, `src/scenes/AlertCurMap/useFeatures.js`, `src/scenes/AlertCurMap/useOnPress.js`
6. Persistent suggestion modal:
- ✅ `src/containers/DaeSuggestModal/index.js` mounted in `src/layout/LayoutProviders.js`
7. New asset:
- ✅ Marker icon: `src/assets/img/marker-grey.png`
8. Verification:
- ✅ `yarn lint` and `yarn test` passing
### Push Notification Improvements ### Push Notification Improvements
1. Background Notification Fixes: 1. Background Notification Fixes:
- ✅ Added required Android permissions - ✅ Added required Android permissions

12
jest.config.js Normal file
View file

@ -0,0 +1,12 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
testMatch: ["<rootDir>/src/**/*.test.js"],
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/e2e/"],
transformIgnorePatterns: [
"node_modules/(?!(@react-native|react-native|expo)/)",
],
testEnvironment: "node",
moduleNameMapper: {
"^~/(.*)$": "<rootDir>/src/$1",
},
};

View file

@ -88,6 +88,7 @@
"@mapbox/polyline": "^1.2.1", "@mapbox/polyline": "^1.2.1",
"@maplibre/maplibre-react-native": "10.0.0-alpha.23", "@maplibre/maplibre-react-native": "10.0.0-alpha.23",
"@notifee/react-native": "^9.1.8", "@notifee/react-native": "^9.1.8",
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "2.1.2", "@react-native-async-storage/async-storage": "2.1.2",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-native-community/slider": "4.5.6", "@react-native-community/slider": "4.5.6",
@ -129,6 +130,7 @@
"expo-contacts": "~14.2.5", "expo-contacts": "~14.2.5",
"expo-dev-client": "~5.2.4", "expo-dev-client": "~5.2.4",
"expo-device": "~7.1.4", "expo-device": "~7.1.4",
"expo-file-system": "~18.1.11",
"expo-gradle-ext-vars": "^0.1.1", "expo-gradle-ext-vars": "^0.1.1",
"expo-linear-gradient": "~14.1.5", "expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.7", "expo-linking": "~7.1.7",
@ -138,6 +140,7 @@
"expo-secure-store": "~14.2.4", "expo-secure-store": "~14.2.4",
"expo-sensors": "~14.1.4", "expo-sensors": "~14.1.4",
"expo-splash-screen": "~0.30.10", "expo-splash-screen": "~0.30.10",
"expo-sqlite": "^55.0.10",
"expo-status-bar": "~2.2.3", "expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.11", "expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6", "expo-task-manager": "~13.1.6",
@ -148,6 +151,7 @@
"google-libphonenumber": "^3.2.32", "google-libphonenumber": "^3.2.32",
"graphql": "^16.10.0", "graphql": "^16.10.0",
"graphql-ws": "^6.0.4", "graphql-ws": "^6.0.4",
"h3-js": "^4.4.0",
"hash.js": "^1.1.7", "hash.js": "^1.1.7",
"i18next": "^23.2.10", "i18next": "^23.2.10",
"immer": "^10.0.2", "immer": "^10.0.2",
@ -285,4 +289,4 @@
} }
}, },
"packageManager": "yarn@4.5.3" "packageManager": "yarn@4.5.3"
} }

View file

@ -15,7 +15,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 with spaces to keep one row per entry
const str = String(value).replace(/[\r\n]+/g, " ").trim(); const str = String(value)
.replace(/[\r\n]+/g, " ")
.trim();
if (str.includes('"') || str.includes(",")) { if (str.includes('"') || str.includes(",")) {
return '"' + str.replace(/"/g, '""') + '"'; return '"' + str.replace(/"/g, '""') + '"';
} }
@ -70,13 +72,11 @@ function formatDays(arr) {
// Detect consecutive range // Detect consecutive range
const indices = sorted.map((d) => DAY_ORDER.indexOf(d)); const indices = sorted.map((d) => DAY_ORDER.indexOf(d));
const isConsecutive = indices.every( const isConsecutive = indices.every(
(idx, i) => i === 0 || idx === indices[i - 1] + 1 (idx, i) => i === 0 || idx === indices[i - 1] + 1,
); );
if (isConsecutive && sorted.length >= 2) { if (isConsecutive && sorted.length >= 2) {
return ( return DAY_ABBREV[sorted[0]] + "-" + DAY_ABBREV[sorted[sorted.length - 1]];
DAY_ABBREV[sorted[0]] + "-" + DAY_ABBREV[sorted[sorted.length - 1]]
);
} }
return sorted.map((d) => DAY_ABBREV[d] || d).join(", "); return sorted.map((d) => DAY_ABBREV[d] || d).join(", ");
@ -91,7 +91,7 @@ function formatHours(arr) {
(h) => (h) =>
h && h &&
h.toLowerCase() !== "non renseigné" && h.toLowerCase() !== "non renseigné" &&
h.toLowerCase() !== "non renseigne" h.toLowerCase() !== "non renseigne",
); );
return cleaned.join(" + "); return cleaned.join(" + ");
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,73 @@
import React, { useMemo } from "react";
import { View } from "react-native";
import { Button, Modal, Portal } from "react-native-paper";
import Text from "~/components/Text";
import { useRootNav } from "~/navigation/Context";
import { defibsActions, useDefibsState } from "~/stores";
import { useTheme } from "~/theme";
export default function DaeSuggestModal() {
const { showDaeSuggestModal } = useDefibsState(["showDaeSuggestModal"]);
const navigationRef = useRootNav();
const { colors } = useTheme();
const styles = useMemo(
() => ({
container: { backgroundColor: colors.surface, padding: 20 },
title: { fontSize: 20, fontWeight: "bold" },
paragraph: { marginTop: 10, fontSize: 16 },
actionsRow: {
marginTop: 18,
flexDirection: "row",
justifyContent: "space-between",
},
action: { flex: 1 },
actionLeft: { marginRight: 12 },
}),
[colors.surface],
);
const dismiss = () => {
defibsActions.setShowDaeSuggestModal(false);
};
const goToDaeList = () => {
dismiss();
// DAEList is inside the Drawer navigator which is the RootStack "Main" screen.
// Using the root navigation ref makes this modal independent from current route.
navigationRef?.current?.navigate("Main", {
screen: "DAEList",
});
};
return (
<Portal>
<Modal
visible={!!showDaeSuggestModal}
onDismiss={dismiss}
contentContainerStyle={styles.container}
>
<Text style={styles.title}>Défibrillateur à proximité</Text>
<Text style={styles.paragraph}>
Votre alerte semble concerner un malaise grave / arrêt cardiaque.
Recherchez rapidement un défibrillateur (DAE) près de vous.
</Text>
<View style={styles.actionsRow}>
<Button
style={[styles.action, styles.actionLeft]}
mode="contained"
onPress={goToDaeList}
>
Chercher un défibrillateur
</Button>
<Button style={styles.action} mode="outlined" onPress={dismiss}>
Non merci
</Button>
</View>
</Modal>
</Portal>
);
}

View file

@ -25,7 +25,12 @@ export default function AlertSymbolLayer({ level, isDisabled }) {
return ( return (
<Maplibre.SymbolLayer <Maplibre.SymbolLayer
filter={["==", ["get", "icon"], icon]} filter={[
"all",
["==", ["get", "icon"], icon],
// Exclude DAE overlay markers (v1: separate non-clustered layer)
["!=", ["get", "isDefib"], true],
]}
key={key} key={key}
id={key} id={key}
belowLayerID={belowLayerID} belowLayerID={belowLayerID}

View file

@ -5,6 +5,7 @@ import Maplibre from "@maplibre/maplibre-react-native";
import markerRed from "~/assets/img/marker-red.png"; import markerRed from "~/assets/img/marker-red.png";
import markerYellow from "~/assets/img/marker-yellow.png"; import markerYellow from "~/assets/img/marker-yellow.png";
import markerGreen from "~/assets/img/marker-green.png"; import markerGreen from "~/assets/img/marker-green.png";
import markerGrey from "~/assets/img/marker-grey.png";
import markerRedDisabled from "~/assets/img/marker-red-disabled.png"; import markerRedDisabled from "~/assets/img/marker-red-disabled.png";
import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png"; import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png"; import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
@ -14,6 +15,7 @@ const images = {
red: markerRed, red: markerRed,
yellow: markerYellow, yellow: markerYellow,
green: markerGreen, green: markerGreen,
grey: markerGrey,
redDisabled: markerRedDisabled, redDisabled: markerRedDisabled,
yellowDisabled: markerYellowDisabled, yellowDisabled: markerYellowDisabled,
greenDisabled: markerGreenDisabled, greenDisabled: markerGreenDisabled,

View file

@ -17,6 +17,11 @@ const iconStyle = {
iconSize: 0.5, iconSize: 0.5,
}; };
const defibIconStyle = {
...iconStyle,
iconAllowOverlap: true,
};
const useStyles = createStyles(({ theme: { colors } }) => ({ const useStyles = createStyles(({ theme: { colors } }) => ({
clusterCount: { clusterCount: {
textField: "{point_count_abbreviated}", textField: "{point_count_abbreviated}",
@ -58,6 +63,15 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
style={iconStyle} style={iconStyle}
/> />
{/* Defibrillators (DAE) separate layer (non-clustered) */}
<Maplibre.SymbolLayer
filter={["==", ["get", "isDefib"], true]}
key="points-defib"
id="points-defib"
aboveLayerID="points-origin"
style={defibIconStyle}
/>
{children} {children}
</Maplibre.ShapeSource> </Maplibre.ShapeSource>
); );

View file

@ -53,7 +53,16 @@ export default async function getNearbyDefibs({
}); });
} catch (err) { } catch (err) {
// Fallback to bbox if H3 fails (e.g. missing h3-js on a platform) // Fallback to bbox if H3 fails (e.g. missing h3-js on a platform)
console.warn("H3 query failed, falling back to bbox:", err.message); console.warn("[DAE_DB] H3 query failed, falling back to bbox raw:", err);
console.warn(
"[DAE_DB] H3 query failed, falling back to bbox message:",
err?.message,
);
if (err?.stack) {
console.warn(
`[DAE_DB] H3 query failed, falling back to bbox stack:\n${err.stack}`,
);
}
return getNearbyDefibsBbox({ return getNearbyDefibsBbox({
lat, lat,
lon, lon,

View file

@ -1,13 +1,13 @@
// Defibrillator repository — nearby queries with H3 geo-indexing. // Defibrillator repository — nearby queries with H3 geo-indexing.
import { latLngToCell, gridDisk } from "h3-js"; import { latLngToCell, gridDisk } from "~/lib/h3";
import getDb from "./openDb"; import { getDbSafe } from "./openDb";
import haversine from "~/utils/geo/haversine"; import haversine from "~/utils/geo/haversine";
// H3 average edge lengths in meters per resolution (0..15). // H3 average edge lengths in meters per resolution (0..15).
const H3_EDGE_M = [ const H3_EDGE_M = [
1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24, 1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24, 9,
9, 3, 1, 0.5, 3, 1, 0.5,
]; ];
const H3_RES = 8; const H3_RES = 8;
@ -29,8 +29,7 @@ function bboxClause(lat, lon, radiusMeters) {
// 1 degree longitude shrinks with cos(lat) // 1 degree longitude shrinks with cos(lat)
const dLon = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180)); const dLon = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180));
return { return {
clause: clause: "latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
"latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon], params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon],
}; };
} }
@ -75,11 +74,22 @@ export async function getNearbyDefibs({
disponible24hOnly = false, disponible24hOnly = false,
progressive = false, progressive = false,
}) { }) {
const db = await getDb(); const { db, error } = await getDbSafe();
if (!db) {
throw error || new Error("DAE DB unavailable");
}
const maxK = kForRadius(radiusMeters); const maxK = kForRadius(radiusMeters);
if (progressive) { if (progressive) {
return progressiveSearch(db, lat, lon, radiusMeters, limit, disponible24hOnly, maxK); return progressiveSearch(
db,
lat,
lon,
radiusMeters,
limit,
disponible24hOnly,
maxK,
);
} }
// One-shot: compute full disk and query // One-shot: compute full disk and query
@ -89,7 +99,15 @@ export async function getNearbyDefibs({
} }
// Progressive expansion: start at k=1, expand until enough results or maxK. // Progressive expansion: start at k=1, expand until enough results or maxK.
async function progressiveSearch(db, lat, lon, radiusMeters, limit, dispo24h, maxK) { async function progressiveSearch(
db,
lat,
lon,
radiusMeters,
limit,
dispo24h,
maxK,
) {
let allCandidates = []; let allCandidates = [];
const seenIds = new Set(); const seenIds = new Set();
@ -106,7 +124,13 @@ async function progressiveSearch(db, lat, lon, radiusMeters, limit, dispo24h, ma
// Early exit: if we already have more candidates than limit, rank and check // Early exit: if we already have more candidates than limit, rank and check
if (allCandidates.length >= limit) { if (allCandidates.length >= limit) {
const ranked = rankAndFilter(allCandidates, lat, lon, radiusMeters, limit); const ranked = rankAndFilter(
allCandidates,
lat,
lon,
radiusMeters,
limit,
);
if (ranked.length >= limit) return ranked; if (ranked.length >= limit) return ranked;
} }
} }
@ -180,7 +204,10 @@ export async function getNearbyDefibsBbox({
limit, limit,
disponible24hOnly = false, disponible24hOnly = false,
}) { }) {
const db = await getDb(); const { db, error } = await getDbSafe();
if (!db) {
throw error || new Error("DAE DB unavailable");
}
const { clause, params } = bboxClause(lat, lon, radiusMeters); const { clause, params } = bboxClause(lat, lon, radiusMeters);
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h

123
src/db/ensureEmbeddedDb.js Normal file
View file

@ -0,0 +1,123 @@
// Ensure the embedded pre-populated geodae.db is available on-device.
//
// This copies the bundled asset into Expo's SQLite directory:
// FileSystem.documentDirectory + 'SQLite/' + DB_NAME
//
// Both backends (expo-sqlite and op-sqlite) can open the DB from that location.
//
// IMPORTANT:
// - All native requires must stay inside functions so this file can be loaded
// in Jest/node without crashing.
const DEFAULT_DB_NAME = "geodae.db";
function stripFileScheme(uri) {
return typeof uri === "string" && uri.startsWith("file://")
? uri.slice("file://".length)
: uri;
}
/**
* @typedef {Object} EnsureEmbeddedDbResult
* @property {string} dbName
* @property {string} sqliteDirUri
* @property {string} dbUri
* @property {boolean} copied
*/
/**
* Copy the embedded DB asset into the Expo SQLite directory (idempotent).
*
* @param {Object} [options]
* @param {string} [options.dbName]
* @param {any} [options.assetModule] - Optional override for testing.
* @param {boolean} [options.overwrite]
* @returns {Promise<EnsureEmbeddedDbResult>}
*/
async function ensureEmbeddedDb(options = {}) {
const {
dbName = DEFAULT_DB_NAME,
assetModule = null,
overwrite = false,
} = options;
// Lazy require: keeps Jest/node stable.
// eslint-disable-next-line global-require
const FileSystemModule = require("expo-file-system");
const FileSystem = FileSystemModule?.default ?? FileSystemModule;
// eslint-disable-next-line global-require
const ExpoAssetModule = require("expo-asset");
const ExpoAsset = ExpoAssetModule?.default ?? ExpoAssetModule;
const { Asset } = ExpoAsset;
if (!FileSystem?.documentDirectory) {
throw new Error(
"[DAE_DB] expo-file-system unavailable (documentDirectory missing) — cannot stage embedded DB",
);
}
if (!Asset?.fromModule) {
throw new Error(
"[DAE_DB] expo-asset unavailable (Asset.fromModule missing) — cannot stage embedded DB",
);
}
const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`;
const dbUri = `${sqliteDirUri}/${dbName}`;
// Ensure SQLite directory exists.
const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(sqliteDirUri, { intermediates: true });
}
const fileInfo = await FileSystem.getInfoAsync(dbUri);
const shouldCopy =
overwrite ||
!fileInfo.exists ||
(typeof fileInfo.size === "number" && fileInfo.size === 0);
if (shouldCopy) {
let moduleId = assetModule;
if (moduleId == null) {
try {
// Bundled asset (must exist in repo/build output).
// Path is relative to src/db/
// eslint-disable-next-line global-require
moduleId = require("../assets/db/geodae.db");
} catch (e) {
const err = new Error(
"[DAE_DB] Embedded DB asset not found at src/assets/db/geodae.db. " +
"Run `yarn dae:build` (or ensure the asset is committed) and rebuild the dev client.",
);
err.cause = e;
throw err;
}
}
const asset = Asset.fromModule(moduleId);
await asset.downloadAsync();
if (!asset.localUri) {
throw new Error(
"[DAE_DB] DAE DB asset missing localUri after Asset.downloadAsync()",
);
}
// Defensive: expo-asset returns file:// URIs; copyAsync wants URIs.
await FileSystem.copyAsync({ from: asset.localUri, to: dbUri });
console.warn(
"[DAE_DB] Staged embedded geodae.db into SQLite directory:",
stripFileScheme(dbUri),
);
return { dbName, sqliteDirUri, dbUri, copied: true };
}
return { dbName, sqliteDirUri, dbUri, copied: false };
}
module.exports = {
__esModule: true,
DEFAULT_DB_NAME,
ensureEmbeddedDb,
stripFileScheme,
};

View file

@ -0,0 +1,98 @@
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
describe("db/ensureEmbeddedDb", () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
test("copies asset into documentDirectory/SQLite when file is missing", async () => {
const calls = {
makeDirectoryAsync: [],
copyAsync: [],
getInfoAsync: [],
};
jest.doMock(
"expo-file-system",
() => ({
documentDirectory: "file:///docs/",
getInfoAsync: jest.fn(async (uri) => {
calls.getInfoAsync.push(uri);
if (uri === "file:///docs/SQLite") return { exists: false };
if (uri === "file:///docs/SQLite/geodae.db") return { exists: false };
return { exists: false };
}),
makeDirectoryAsync: jest.fn(async (uri) => {
calls.makeDirectoryAsync.push(uri);
}),
copyAsync: jest.fn(async (args) => {
calls.copyAsync.push(args);
}),
}),
{ virtual: true },
);
const downloadAsync = jest.fn(async () => undefined);
jest.doMock(
"expo-asset",
() => ({
Asset: {
fromModule: jest.fn(() => ({
downloadAsync,
localUri: "file:///bundle/geodae.db",
})),
},
}),
{ virtual: true },
);
const res = await ensureEmbeddedDb({ assetModule: 123 });
expect(res.dbUri).toBe("file:///docs/SQLite/geodae.db");
expect(res.copied).toBe(true);
expect(calls.makeDirectoryAsync).toEqual(["file:///docs/SQLite"]);
expect(calls.copyAsync).toEqual([
{ from: "file:///bundle/geodae.db", to: "file:///docs/SQLite/geodae.db" },
]);
expect(downloadAsync).toHaveBeenCalled();
});
test("does not copy when destination already exists and is non-empty", async () => {
const calls = { copyAsync: [] };
jest.doMock(
"expo-file-system",
() => ({
documentDirectory: "file:///docs/",
getInfoAsync: jest.fn(async (uri) => {
if (uri === "file:///docs/SQLite") return { exists: true };
if (uri === "file:///docs/SQLite/geodae.db") {
return { exists: true, size: 42 };
}
return { exists: true };
}),
makeDirectoryAsync: jest.fn(async () => undefined),
copyAsync: jest.fn(async (args) => {
calls.copyAsync.push(args);
}),
}),
{ virtual: true },
);
jest.doMock(
"expo-asset",
() => ({
Asset: {
fromModule: jest.fn(() => ({
downloadAsync: jest.fn(async () => undefined),
localUri: "file:///bundle/geodae.db",
})),
},
}),
{ virtual: true },
);
const res = await ensureEmbeddedDb({ assetModule: 123 });
expect(res.copied).toBe(false);
expect(calls.copyAsync).toEqual([]);
});
});

View file

@ -1,41 +1,337 @@
// Open the pre-built geodae SQLite database (Expo variant). // Open the pre-built geodae SQLite database.
// Requires: expo-sqlite, expo-file-system, expo-asset //
import * as SQLite from "expo-sqlite"; // IMPORTANT: This module must not crash at load time when a native SQLite
import * as FileSystem from "expo-file-system"; // backend is missing (Hermes: "Cannot find native module 'ExpoSQLite'").
import { Asset } from "expo-asset"; //
// Strategy:
// 1) Prefer @op-engineering/op-sqlite (bare RN) via ./openDbOpSqlite
// 2) Fallback to expo-sqlite (Expo) ONLY if it can be required
// 3) If nothing works, callers should use getDbSafe() and handle { db: null }
const DB_NAME = "geodae.db"; const DB_NAME = "geodae.db";
let _dbPromise = null; let _dbPromise = null;
let _backendPromise = null;
let _selectedBackendName = null;
function describeModuleShape(mod) {
const t = typeof mod;
const keys =
mod && (t === "object" || t === "function") ? Object.keys(mod) : [];
return { type: t, keys };
}
function pickOpener(mod, name) {
// Deterministic picking to reduce CJS/ESM/Metro export-shape ambiguity.
// Priority is explicit and matches wrapper contract.
const opener =
mod?.openDbOpSqlite ??
mod?.openDbExpoSqlite ??
mod?.openDb ??
mod?.default ??
mod;
if (typeof opener === "function") return opener;
const { type, keys } = describeModuleShape(mod);
throw new TypeError(
[
`Backend module did not export a callable opener (backend=${name}).`,
`module typeof=${type} keys=[${keys.join(", ")}].`,
`picked typeof=${typeof opener}.`,
].join(" "),
);
}
export default function getDb() { export default function getDb() {
if (!_dbPromise) { if (!_dbPromise) {
_dbPromise = initDb(); _dbPromise = getDbImpl();
} }
return _dbPromise; return _dbPromise;
} }
async function initDb() { /**
const sqliteDir = `${FileSystem.documentDirectory}SQLite`; * Non-throwing DB opener.
const dbPath = `${sqliteDir}/${DB_NAME}`; *
* v1 requirement: DB open failures must not crash the app. Downstream UI can
* display an error/empty state and keep overlays disabled.
*
* @returns {Promise<{ db: import('expo-sqlite').SQLiteDatabase | null, error: Error | null }>}
*/
export async function getDbSafe() {
try {
const db = await getDb();
return { db, error: null };
} catch (error) {
// Actionable runtime logging — include backend attempts + underlying error/stack.
// Keep behavior unchanged: do not crash, keep returning { db: null, error }.
const prefix = "[DAE_DB] Failed to open embedded DAE DB";
// Ensure the SQLite directory exists const logErrorDetails = (label, err) => {
const dirInfo = await FileSystem.getInfoAsync(sqliteDir); if (!err) {
if (!dirInfo.exists) { console.warn(`${prefix} ${label} <no error object>`);
await FileSystem.makeDirectoryAsync(sqliteDir, { intermediates: true }); return;
}
const msg = err?.message;
const stack = err?.stack;
// Log the raw error object first (best for Hermes / native errors).
console.warn(`${prefix} ${label} raw:`, err);
console.warn(`${prefix} ${label} message:`, msg);
if (stack) console.warn(`${prefix} ${label} stack:\n${stack}`);
const cause = err?.cause;
if (cause) {
console.warn(`${prefix} ${label} cause raw:`, cause);
console.warn(`${prefix} ${label} cause message:`, cause?.message);
if (cause?.stack) {
console.warn(`${prefix} ${label} cause stack:\n${cause.stack}`);
}
}
};
// Primary error thrown by getDb()/selectBackend.
if (_selectedBackendName) {
console.warn(`${prefix} selected backend:`, _selectedBackendName);
}
logErrorDetails("(primary)", error);
// Nested backend selection errors (attached by selectBackend()).
const backends = error?.backends;
if (Array.isArray(backends) && backends.length > 0) {
for (const entry of backends) {
const backend = entry?.backend ?? "<unknown-backend>";
console.warn(`${prefix} backend attempted:`, backend);
logErrorDetails(`(backend=${backend})`, entry?.error);
}
}
return { db: null, error };
} }
}
// Copy asset DB on first launch (or after app update clears documents)
const fileInfo = await FileSystem.getInfoAsync(dbPath); async function getDbImpl() {
if (!fileInfo.exists) { const backend = await selectBackend();
const asset = Asset.fromModule(require("../assets/db/geodae.db")); return backend.getDb();
await asset.downloadAsync(); }
await FileSystem.copyAsync({ from: asset.localUri, to: dbPath });
} async function selectBackend() {
if (_backendPromise) return _backendPromise;
const db = await SQLite.openDatabaseAsync(DB_NAME);
// Read-only optimizations _backendPromise = (async () => {
await db.execAsync("PRAGMA journal_mode = WAL"); const errors = [];
await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB
return db; // 1) Prefer op-sqlite backend when available.
try {
let opBackendModule;
try {
console.warn(
"[DAE_DB] op-sqlite: requiring backend module ./openDbOpSqlite...",
);
// eslint-disable-next-line global-require
opBackendModule = require("./openDbOpSqlite");
const opModuleType = typeof opBackendModule;
const opModuleKeys =
opBackendModule &&
(typeof opBackendModule === "object" ||
typeof opBackendModule === "function")
? Object.keys(opBackendModule)
: [];
console.warn(
"[DAE_DB] op-sqlite: require ./openDbOpSqlite success",
`type=${opModuleType} keys=[${opModuleKeys.join(", ")}]`,
);
} catch (requireError) {
console.warn(
"[DAE_DB] op-sqlite: require ./openDbOpSqlite FAILED:",
requireError?.message,
);
const err = new Error("Failed to require ./openDbOpSqlite");
// Preserve the underlying Metro/Hermes resolution failure.
err.cause = requireError;
throw err;
}
if (opBackendModule == null) {
throw new TypeError(
"./openDbOpSqlite required successfully but returned null/undefined",
);
}
const openDbOp = pickOpener(opBackendModule, "op-sqlite");
console.warn(
"[DAE_DB] op-sqlite: picked opener",
`typeof=${typeof openDbOp}`,
);
const db = await openDbOp(); // validates open + schema
if (!db) throw new Error("op-sqlite backend returned a null DB instance");
_selectedBackendName = "op-sqlite";
return { name: "op-sqlite", getDb: () => db };
} catch (error) {
errors.push({ backend: "op-sqlite", error });
}
// 2) Fallback to expo-sqlite backend ONLY if it can be required.
try {
const expoBackend = createExpoSqliteBackend();
// Validate open; createExpoSqliteBackend() is already safe to call.
await expoBackend.getDb();
_selectedBackendName = expoBackend?.name ?? "expo-sqlite";
return expoBackend;
} catch (error) {
errors.push({ backend: "expo-sqlite", error });
}
const err = new Error(
"No SQLite backend available (tried: @op-engineering/op-sqlite, expo-sqlite)",
);
// Attach details for debugging; callers should treat this as non-fatal.
// (Avoid AggregateError for broader Hermes compatibility.)
err.backends = errors;
throw err;
})();
return _backendPromise;
}
function createExpoSqliteBackend() {
// All requires are inside the factory so a missing ExpoSQLite native module
// does not crash at module evaluation time.
let openDbExpoSqlite;
let wrapperModule;
try {
// Expo SQLite wrapper uses static imports to make Metro/Hermes interop stable.
// eslint-disable-next-line global-require
wrapperModule = require("./openDbExpoSqlite");
const expoModuleType = typeof wrapperModule;
const expoModuleKeys =
wrapperModule &&
(typeof wrapperModule === "object" || typeof wrapperModule === "function")
? Object.keys(wrapperModule)
: [];
console.warn(
"[DAE_DB] expo-sqlite: require ./openDbExpoSqlite success",
`type=${expoModuleType} keys=[${expoModuleKeys.join(", ")}]`,
);
openDbExpoSqlite = pickOpener(wrapperModule, "expo-sqlite");
} catch (requireError) {
const err = new Error("Failed to require ./openDbExpoSqlite");
err.cause = requireError;
throw err;
}
// Log what we actually picked (helps confirm Metro export shapes in the wild).
if (wrapperModule != null) {
console.warn(
"[DAE_DB] expo-sqlite: picked opener",
`typeof=${typeof openDbExpoSqlite}`,
);
}
let _expoDbPromise = null;
function createLegacyAsyncFacade(legacyDb) {
const execSqlAsync = (sql, params = []) =>
new Promise((resolve, reject) => {
const runner =
typeof legacyDb.readTransaction === "function"
? legacyDb.readTransaction.bind(legacyDb)
: legacyDb.transaction.bind(legacyDb);
runner((tx) => {
tx.executeSql(
sql,
params,
() => resolve(),
(_tx, err) => {
reject(err);
return true;
},
);
});
});
const queryAllAsync = (sql, params = []) =>
new Promise((resolve, reject) => {
const runner =
typeof legacyDb.readTransaction === "function"
? legacyDb.readTransaction.bind(legacyDb)
: legacyDb.transaction.bind(legacyDb);
runner((tx) => {
tx.executeSql(
sql,
params,
(_tx, result) => {
const rows = [];
const len = result?.rows?.length ?? 0;
for (let i = 0; i < len; i++) {
rows.push(result.rows.item(i));
}
resolve(rows);
},
(_tx, err) => {
reject(err);
return true;
},
);
});
});
return {
// Methods used by this repo
execAsync(sql) {
return execSqlAsync(sql);
},
getAllAsync(sql, params) {
return queryAllAsync(sql, params);
},
async getFirstAsync(sql, params) {
const rows = await queryAllAsync(sql, params);
return rows[0] ?? null;
},
// Keep a reference to the underlying legacy DB for debugging.
_legacyDb: legacyDb,
};
}
async function initDbExpo() {
// eslint-disable-next-line global-require
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
// Stage the DB into the Expo SQLite dir before opening.
await ensureEmbeddedDb({ dbName: DB_NAME });
let db;
// openDbExpoSqlite() can be async (openDatabaseAsync) or sync (openDatabase).
db = await openDbExpoSqlite(DB_NAME);
// Expo Go / older expo-sqlite: provide an async facade compatible with
// the subset of methods used in this repo (execAsync + getAllAsync).
if (db && typeof db.execAsync !== "function") {
db = createLegacyAsyncFacade(db);
}
// Read-only optimizations
await db.execAsync("PRAGMA journal_mode = WAL");
await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB
// eslint-disable-next-line global-require
const { assertDbHasTable } = require("./validateDbSchema");
await assertDbHasTable(db, "defibs");
return db;
}
return {
name: "expo-sqlite",
getDb() {
if (!_expoDbPromise) {
_expoDbPromise = initDbExpo();
}
return _expoDbPromise;
},
};
} }

View file

@ -1,17 +0,0 @@
// Open the pre-built geodae SQLite database (Bare RN variant).
// Requires: @op-engineering/op-sqlite
// Install: npm install @op-engineering/op-sqlite
// Place geodae.db in:
// Android: android/app/src/main/assets/geodae.db
// iOS: add geodae.db to Xcode project "Copy Bundle Resources"
import { open } from "@op-engineering/op-sqlite";
let _db = null;
export default function getDb() {
if (!_db) {
_db = open({ name: "geodae.db", readOnly: true });
_db.execute("PRAGMA cache_size = -8000"); // 8 MB
}
return _db;
}

View file

@ -0,0 +1,87 @@
// Expo SQLite wrapper (require-safe, Metro/Hermes friendly).
//
// Requirements from runtime backend selection:
// - Must NOT crash at module evaluation time when ExpoSQLite native module is missing.
// - Must be safe to load via `require('./openDbExpoSqlite')` under Metro/Hermes.
// - Must export a callable `openDbExpoSqlite()` function via CommonJS exports.
//
// IMPORTANT: Do NOT use top-level `import` from expo-sqlite here.
function describeKeys(x) {
if (!x) return [];
const t = typeof x;
if (t !== "object" && t !== "function") return [];
try {
return Object.keys(x);
} catch {
return [];
}
}
function requireExpoSqlite() {
// Lazily require so missing native module does not crash at module evaluation time.
// eslint-disable-next-line global-require
const mod = require("expo-sqlite");
const candidate = mod?.default ?? mod;
const openDatabaseAsync =
candidate?.openDatabaseAsync ??
mod?.openDatabaseAsync ??
mod?.default?.openDatabaseAsync;
const openDatabase =
candidate?.openDatabase ?? mod?.openDatabase ?? mod?.default?.openDatabase;
return {
mod,
candidate,
openDatabaseAsync,
openDatabase,
};
}
/**
* Open an expo-sqlite database using whichever API exists.
*
* @param {string} dbName
* @returns {Promise<any>} SQLiteDatabase (new API) or legacy Database (sync open)
*/
async function openDbExpoSqlite(dbName) {
const api = requireExpoSqlite();
if (typeof api.openDatabaseAsync === "function") {
return api.openDatabaseAsync(dbName);
}
if (typeof api.openDatabase === "function") {
// Legacy expo-sqlite API (sync open)
return api.openDatabase(dbName);
}
const modKeys = describeKeys(api.mod);
const defaultKeys = describeKeys(api.mod?.default);
const candidateKeys = describeKeys(api.candidate);
const err = new TypeError(
[
"expo-sqlite require() did not expose openDatabaseAsync nor openDatabase.",
`module typeof=${typeof api.mod} keys=[${modKeys.join(", ")}].`,
`default typeof=${typeof api.mod?.default} keys=[${defaultKeys.join(
", ",
)}].`,
`candidate typeof=${typeof api.candidate} keys=[${candidateKeys.join(
", ",
)}].`,
].join(" "),
);
err.expoSqliteModuleKeys = modKeys;
err.expoSqliteDefaultKeys = defaultKeys;
err.expoSqliteCandidateKeys = candidateKeys;
throw err;
}
// Explicit CommonJS export shape (so require() always returns a non-null object).
module.exports = {
__esModule: true,
openDbExpoSqlite,
openDb: openDbExpoSqlite,
default: openDbExpoSqlite,
};

225
src/db/openDbOpSqlite.js Normal file
View file

@ -0,0 +1,225 @@
// Open the pre-built geodae SQLite database (Bare RN variant).
// Requires: @op-engineering/op-sqlite
// Install: npm install @op-engineering/op-sqlite
// Place geodae.db in:
// Android: android/app/src/main/assets/geodae.db
// iOS: add geodae.db to Xcode project "Copy Bundle Resources"
//
// NOTE: This module is intentionally written in CommonJS to make Metro/Hermes
// `require('./openDbOpSqlite')` resolution stable across CJS/ESM interop.
function requireOpSqliteOpen() {
// Lazy require to keep this module loadable in Jest/node.
// (op-sqlite ships a node/ dist that is ESM and not Jest-CJS friendly.)
// eslint-disable-next-line global-require
const mod = require("@op-engineering/op-sqlite");
const open = mod?.open ?? mod?.default?.open;
if (typeof open !== "function") {
const keys =
mod && (typeof mod === "object" || typeof mod === "function")
? Object.keys(mod)
: [];
throw new TypeError(
`[DAE_DB] op-sqlite require() did not expose an open() function (keys=[${keys.join(
", ",
)}])`,
);
}
return open;
}
const DB_NAME = "geodae.db";
let _rawDb = null;
let _dbPromise = null;
function describeDbShape(db) {
if (!db) return { type: typeof db, keys: [] };
const t = typeof db;
if (t !== "object" && t !== "function") return { type: t, keys: [] };
try {
return { type: t, keys: Object.keys(db) };
} catch {
return { type: t, keys: [] };
}
}
/**
* Adapt an op-sqlite DB instance to the async API expected by repo code.
*
* Required interface (subset of expo-sqlite modern API):
* - execAsync(sql)
* - getAllAsync(sql, params)
* - getFirstAsync(sql, params)
*
* op-sqlite exposes: execute()/executeAsync() returning { rows: [...] }.
*
* @param {any} opDb
*/
function adaptDbToRepoInterface(opDb) {
if (!opDb) {
throw new TypeError(
"[DAE_DB] op-sqlite adapter: DB instance is null/undefined",
);
}
// Idempotency: if caller already passes an expo-sqlite-like DB, keep it.
if (
typeof opDb.execAsync === "function" &&
typeof opDb.getAllAsync === "function" &&
typeof opDb.getFirstAsync === "function"
) {
return opDb;
}
const executeAsync =
(typeof opDb.executeAsync === "function" && opDb.executeAsync.bind(opDb)) ||
(typeof opDb.execute === "function" && opDb.execute.bind(opDb));
const executeSync =
typeof opDb.executeSync === "function" ? opDb.executeSync.bind(opDb) : null;
if (!executeAsync && !executeSync) {
const shape = describeDbShape(opDb);
throw new TypeError(
[
"[DAE_DB] op-sqlite adapter: cannot adapt DB.",
"Expected executeAsync()/execute() or executeSync() methods.",
`db typeof=${shape.type} keys=[${shape.keys.join(", ")}]`,
].join(" "),
);
}
const runQueryAsync = async (sql, params) => {
try {
if (executeAsync) {
return params != null
? await executeAsync(sql, params)
: await executeAsync(sql);
}
// Sync fallback (best effort): wrap in a Promise for repo compatibility.
return params != null ? executeSync(sql, params) : executeSync(sql);
} catch (e) {
// Make it actionable for end users/devs.
const err = new Error(
`[DAE_DB] Query failed (op-sqlite). ${e?.message ?? String(e)}`,
);
err.cause = e;
err.sql = sql;
err.params = params;
throw err;
}
};
return {
async execAsync(sql) {
await runQueryAsync(sql);
},
async getAllAsync(sql, params = []) {
const res = await runQueryAsync(sql, params);
const rows = res?.rows;
// op-sqlite returns rows as array of objects.
if (Array.isArray(rows)) return rows;
// Defensive: if a driver returns no rows field for non-SELECT.
return [];
},
async getFirstAsync(sql, params = []) {
const rows = await this.getAllAsync(sql, params);
return rows[0] ?? null;
},
// Keep a reference to the underlying DB for debugging / escape hatches.
_opDb: opDb,
};
}
async function openDbOpSqlite() {
if (_dbPromise) return _dbPromise;
_dbPromise = (async () => {
// Stage the embedded DB in the Expo SQLite dir first.
// This prevents op-sqlite from creating/opening an empty DB.
let sqliteDirUri;
try {
// eslint-disable-next-line global-require
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
const { sqliteDirUri: dir } = await ensureEmbeddedDb({ dbName: DB_NAME });
sqliteDirUri = dir;
} catch (e) {
const err = new Error(
"[DAE_DB] Failed to stage embedded DB before opening (op-sqlite)",
);
err.cause = e;
throw err;
}
// NOTE: op-sqlite open() params are not identical to expo-sqlite.
// Pass only supported keys to avoid native-side strict validation.
const open = requireOpSqliteOpen();
_rawDb = open({ name: DB_NAME, location: sqliteDirUri });
if (!_rawDb) {
throw new Error("op-sqlite open() returned a null DB instance");
}
// Read-only-ish optimizations.
// Prefer executeSync when available.
try {
if (typeof _rawDb.executeSync === "function") {
_rawDb.executeSync("PRAGMA cache_size = -8000"); // 8 MB
_rawDb.executeSync("PRAGMA query_only = ON");
} else if (typeof _rawDb.execute === "function") {
// Fire-and-forget; adapter methods will still work regardless.
_rawDb.execute("PRAGMA cache_size = -8000");
_rawDb.execute("PRAGMA query_only = ON");
}
} catch {
// Non-fatal: keep DB usable even if pragmas fail.
}
const db = adaptDbToRepoInterface(_rawDb);
// Runtime guard: fail fast with a clear message if adapter didn't produce the expected API.
if (
!db ||
typeof db.execAsync !== "function" ||
typeof db.getAllAsync !== "function" ||
typeof db.getFirstAsync !== "function"
) {
const shape = describeDbShape(db);
throw new TypeError(
[
"[DAE_DB] op-sqlite adapter produced an invalid DB facade.",
`typeof=${shape.type} keys=[${shape.keys.join(", ")}]`,
].join(" "),
);
}
// Validate schema early to avoid later "no such table" runtime errors.
// eslint-disable-next-line global-require
const { assertDbHasTable } = require("./validateDbSchema");
await assertDbHasTable(db, "defibs");
// Helpful for debugging in the wild.
try {
if (typeof _rawDb.getDbPath === "function") {
console.warn("[DAE_DB] op-sqlite opened DB path:", _rawDb.getDbPath());
}
} catch {
// Non-fatal.
}
return db;
})();
return _dbPromise;
}
// Exports (CJS + ESM-ish):
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
// cannot hand back a nullish / unexpected callable export shape.
module.exports = {
__esModule: true,
openDbOpSqlite,
openDb: openDbOpSqlite,
default: openDbOpSqlite,
// Named export for unit tests.
adaptDbToRepoInterface,
};

View file

@ -0,0 +1,32 @@
const { adaptDbToRepoInterface } = require("./openDbOpSqlite");
describe("db/openDbOpSqlite adapter", () => {
test("creates execAsync/getAllAsync/getFirstAsync from executeAsync", async () => {
const executeAsync = jest.fn(async (sql, params) => {
if (sql === "SELECT 1") return { rows: [{ a: 1 }] };
if (sql === "SELECT empty") return { rows: [] };
return { rows: [] };
});
const db = adaptDbToRepoInterface({ executeAsync });
expect(typeof db.execAsync).toBe("function");
expect(typeof db.getAllAsync).toBe("function");
expect(typeof db.getFirstAsync).toBe("function");
await db.execAsync("PRAGMA cache_size = -8000");
expect(executeAsync).toHaveBeenCalled();
const rows = await db.getAllAsync("SELECT 1", []);
expect(rows).toEqual([{ a: 1 }]);
const first = await db.getFirstAsync("SELECT empty", []);
expect(first).toBe(null);
});
test("throws a clear error when no execute method exists", () => {
expect(() => adaptDbToRepoInterface({})).toThrow(
/op-sqlite adapter: cannot adapt DB/i,
);
});
});

View file

@ -0,0 +1,34 @@
// Schema validation for the embedded geodae.db.
/**
* Validate that the embedded DB looks like the pre-populated database.
*
* This is a cheap query and catches cases where we accidentally opened a new/
* empty DB file (which then fails later with "no such table: defibs").
*
* @param {Object} db
* @param {string} [tableName]
*/
async function assertDbHasTable(db, tableName = "defibs") {
if (!db || typeof db.getFirstAsync !== "function") {
throw new TypeError(
"[DAE_DB] Cannot validate schema: db.getFirstAsync() missing",
);
}
const row = await db.getFirstAsync(
"SELECT name FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
[tableName],
);
if (!row || row.name !== tableName) {
throw new Error(
`[DAE_DB] Embedded DB missing ${tableName} table (likely opened empty DB)`,
);
}
}
module.exports = {
__esModule: true,
assertDbHasTable,
};

View file

@ -0,0 +1,19 @@
const { assertDbHasTable } = require("./validateDbSchema");
describe("db/validateDbSchema", () => {
test("passes when table exists", async () => {
const db = {
getFirstAsync: jest.fn(async () => ({ name: "defibs" })),
};
await expect(assertDbHasTable(db, "defibs")).resolves.toBeUndefined();
});
test("throws a clear error when table is missing", async () => {
const db = {
getFirstAsync: jest.fn(async () => null),
};
await expect(assertDbHasTable(db, "defibs")).rejects.toThrow(
/missing defibs table/i,
);
});
});

View file

@ -15,6 +15,8 @@ import {
Dark as NavigationDarkTheme, Dark as NavigationDarkTheme,
} from "~/theme/navigation"; } from "~/theme/navigation";
import DaeSuggestModal from "~/containers/DaeSuggestModal";
// import { navActions } from "~/stores"; // import { navActions } from "~/stores";
// const linking = { // const linking = {
@ -86,6 +88,9 @@ export default function LayoutProviders({ layoutKey, setLayoutKey, children }) {
> >
{children} {children}
</NavigationContainer> </NavigationContainer>
{/* Global persistent modal: mounted outside navigation tree, but can navigate via RootNav ref */}
<DaeSuggestModal />
</ComposeComponents> </ComposeComponents>
</> </>
); );

63
src/lib/h3/index.js Normal file
View file

@ -0,0 +1,63 @@
// Hermes-safe H3 wrapper.
//
// Why this exists:
// - `h3-js`'s default entry (`dist/h3-js.js`) is a Node-oriented Emscripten build.
// - Metro (React Native) does not reliably honor the package.json `browser` field,
// so RN/Hermes may resolve the Node build, which relies on Node Buffer encodings
// (e.g. "utf-16le") and crashes under Hermes.
//
// This wrapper forces the browser bundle when running under Hermes.
/* eslint-disable global-require */
function isHermes() {
// https://reactnative.dev/docs/hermes
return typeof global === "object" && !!global.HermesInternal;
}
function supportsUtf16leTextDecoder() {
if (typeof global !== "object" || typeof global.TextDecoder !== "function") {
return false;
}
try {
// Hermes' built-in TextDecoder historically supports only utf-8.
// `h3-js` bundles try to instantiate a UTF-16LE decoder at module init.
// If unsupported, Hermes throws: RangeError: Unknown encoding: utf-16le
// Detect support and fall back to the non-TextDecoder path when needed.
// eslint-disable-next-line no-new
new global.TextDecoder("utf-16le");
return true;
} catch {
return false;
}
}
// Keep the choice static at module init so exports are stable.
let h3;
if (isHermes()) {
// Force browser bundle (no Node fs/path/Buffer branches).
// Additionally, if Hermes' TextDecoder doesn't support utf-16le, temporarily
// hide it so h3-js uses its pure-JS decoding fallback instead.
const hasUtf16 = supportsUtf16leTextDecoder();
const originalTextDecoder = global.TextDecoder;
if (!hasUtf16) {
global.TextDecoder = undefined;
}
try {
h3 = require("h3-js/dist/browser/h3-js");
} finally {
if (!hasUtf16) {
global.TextDecoder = originalTextDecoder;
}
}
} else {
// Jest/node tests can keep using the default build.
h3 = require("h3-js");
}
export const latLngToCell = h3.latLngToCell;
export const gridDisk = h3.gridDisk;
// Export the full namespace for any other future usage.
export default h3;

View file

@ -23,6 +23,8 @@ import AlertAggListArchived from "~/scenes/AlertAggListArchived";
import About from "~/scenes/About"; import About from "~/scenes/About";
import Contribute from "~/scenes/Contribute"; import Contribute from "~/scenes/Contribute";
import Location from "~/scenes/Location"; import Location from "~/scenes/Location";
import DAEList from "~/scenes/DAEList";
import DAEItem from "~/scenes/DAEItem";
import Developer from "~/scenes/Developer"; import Developer from "~/scenes/Developer";
import HelpSignal from "~/scenes/HelpSignal"; import HelpSignal from "~/scenes/HelpSignal";
@ -366,6 +368,22 @@ export default React.memo(function DrawerNav() {
}} }}
listeners={{}} listeners={{}}
/> />
<Drawer.Screen
name="DAEList"
component={DAEList}
options={{
drawerLabel: "Défibrillateurs",
drawerIcon: ({ focused }) => (
<MaterialCommunityIcons
name="heart-pulse"
{...iconProps}
{...(focused ? iconFocusedProps : {})}
/>
),
unmountOnBlur: true,
}}
listeners={{}}
/>
<Drawer.Screen <Drawer.Screen
name="Links" name="Links"
component={Links} component={Links}
@ -503,6 +521,14 @@ export default React.memo(function DrawerNav() {
}} }}
component={SendAlertFinder} component={SendAlertFinder}
/> />
<Drawer.Screen
name="DAEItem"
component={DAEItem}
options={{
hidden: true,
unmountOnBlur: true,
}}
/>
{devModeEnabled && ( {devModeEnabled && (
<Drawer.Screen <Drawer.Screen
name="Developer" name="Developer"

View file

@ -57,6 +57,11 @@ function getHeaderTitle(route) {
case "SendAlertFinder": case "SendAlertFinder":
return "Par mot-clé"; return "Par mot-clé";
case "DAEList":
return "Défibrillateurs";
case "DAEItem":
return "Défibrillateur";
case "ConnectivityError": case "ConnectivityError":
return "Non connecté"; return "Non connecté";

View file

@ -2,6 +2,14 @@ export default function prioritizeFeatures(features) {
return features return features
.filter(({ properties }) => !properties.isUserLocation) .filter(({ properties }) => !properties.isUserLocation)
.sort(({ properties: x }, { properties: y }) => { .sort(({ properties: x }, { properties: y }) => {
// DAE features should win (easy to tap)
if (x.isDefib && !y.isDefib) {
return -1;
}
if (!x.isDefib && y.isDefib) {
return 1;
}
// if both cluster priority is given to higher level // if both cluster priority is given to higher level
if (x.cluster && y.cluster) { if (x.cluster && y.cluster) {
return x.x_max_level_num < y.x_max_level_num ? 1 : -1; return x.x_max_level_num < y.x_max_level_num ? 1 : -1;

View file

@ -4,6 +4,8 @@ import Supercluster from "supercluster";
import useShallowMemo from "~/hooks/useShallowMemo"; import useShallowMemo from "~/hooks/useShallowMemo";
import useShallowEffect from "~/hooks/useShallowEffect"; import useShallowEffect from "~/hooks/useShallowEffect";
import { deepEqual } from "fast-equals"; import { deepEqual } from "fast-equals";
import { useDefibsState } from "~/stores";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
export default function useFeatures({ export default function useFeatures({
clusterFeature, clusterFeature,
@ -13,6 +15,11 @@ export default function useFeatures({
route, route,
alertCoords, alertCoords,
}) { }) {
const { showDefibsOnAlertMap, corridorDefibs } = useDefibsState([
"showDefibsOnAlertMap",
"corridorDefibs",
]);
// Check if we have valid coordinates // Check if we have valid coordinates
const hasUserCoords = const hasUserCoords =
userCoords && userCoords.longitude !== null && userCoords.latitude !== null; userCoords && userCoords.longitude !== null && userCoords.latitude !== null;
@ -95,15 +102,57 @@ export default function useFeatures({
} }
}); });
// Add defibs (DAE) as separate, non-clustered features
if (showDefibsOnAlertMap && Array.isArray(corridorDefibs)) {
corridorDefibs.forEach((defib) => {
const lon = defib.longitude;
const lat = defib.latitude;
if (
lon === null ||
lat === null ||
lon === undefined ||
lat === undefined
) {
return;
}
const { status } = getDefibAvailability(
defib.horaires_std,
defib.disponible_24h,
);
const icon =
status === "open" ? "green" : status === "closed" ? "red" : "grey";
const id = `defib:${defib.id}`;
features.push({
type: "Feature",
id,
properties: {
id,
icon,
defib,
isDefib: true,
},
geometry: {
type: "Point",
coordinates: [lon, lat],
},
});
});
}
return { return {
type: "FeatureCollection", type: "FeatureCollection",
features, features,
}; };
}, [list]); }, [list, showDefibsOnAlertMap, corridorDefibs]);
const superCluster = useShallowMemo(() => { const superCluster = useShallowMemo(() => {
const cluster = new Supercluster({ radius: 40, maxZoom: 16 }); const cluster = new Supercluster({ radius: 40, maxZoom: 16 });
cluster.load(featureCollection.features); // Do not cluster defibs in v1
const clusterable = featureCollection.features.filter(
(f) => !f?.properties?.isDefib,
);
cluster.load(clusterable);
return cluster; return cluster;
}, [featureCollection.features]); }, [featureCollection.features]);
// console.log({ superCluster: JSON.stringify(superCluster) }); // console.log({ superCluster: JSON.stringify(superCluster) });
@ -123,6 +172,15 @@ export default function useFeatures({
const userCoordinates = [userCoords.longitude, userCoords.latitude]; const userCoordinates = [userCoords.longitude, userCoords.latitude];
const features = [...clusterFeature]; const features = [...clusterFeature];
// Ensure defibs are always present even if they are not part of the clustered set
if (showDefibsOnAlertMap && Array.isArray(featureCollection.features)) {
featureCollection.features.forEach((f) => {
if (f?.properties?.isDefib) {
features.push(f);
}
});
}
// Only add route line if we have valid route data // Only add route line if we have valid route data
const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0; const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0;
const hasValidAlertCoords = const hasValidAlertCoords =
@ -157,6 +215,8 @@ export default function useFeatures({
}, [ }, [
setShape, setShape,
clusterFeature, clusterFeature,
featureCollection.features,
showDefibsOnAlertMap,
userCoords, userCoords,
hasUserCoords, hasUserCoords,
routeCoords, routeCoords,

View file

@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native";
import { ANIMATION_DURATION } from "~/containers/Map/constants"; import { ANIMATION_DURATION } from "~/containers/Map/constants";
import { alertActions } from "~/stores"; import { alertActions, defibsActions } from "~/stores";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes"; import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";
@ -29,6 +29,12 @@ export default function useOnPress({
const [feature] = features; const [feature] = features;
const { properties } = feature; const { properties } = feature;
if (properties?.isDefib && properties?.defib) {
defibsActions.setSelectedDefib(properties.defib);
navigation.navigate("DAEItem");
return;
}
if (properties.cluster) { if (properties.cluster) {
// center and expand to cluster's points // center and expand to cluster's points
const { current: camera } = cameraRef; const { current: camera } = cameraRef;

View file

@ -6,14 +6,17 @@ import { MaterialCommunityIcons } from "@expo/vector-icons";
import { deepEqual } from "fast-equals"; import { deepEqual } from "fast-equals";
import withConnectivity from "~/hoc/withConnectivity"; import withConnectivity from "~/hoc/withConnectivity";
import { useToast } from "~/lib/toast-notifications";
import { import {
useAlertState, useAlertState,
useSessionState, useSessionState,
alertActions, alertActions,
useAggregatedMessagesState, useAggregatedMessagesState,
defibsActions,
} from "~/stores"; } from "~/stores";
import { getCurrentLocation } from "~/location"; import { getCurrentLocation } from "~/location";
import { getStoredLocation } from "~/location/storage";
import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.png"; import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.png";
import alertBigButtonBgMapGrey from "~/assets/img/alert-big-button-bg-map-grey.png"; import alertBigButtonBgMapGrey from "~/assets/img/alert-big-button-bg-map-grey.png";
@ -79,6 +82,99 @@ export default withConnectivity(
const isSent = userId === sessionUserId; const isSent = userId === sessionUserId;
const navigation = useNavigation(); const navigation = useNavigation();
const toast = useToast();
const [loadingDaeCorridor, setLoadingDaeCorridor] = useState(false);
const showDefibsOnAlertMap = useCallback(async () => {
if (loadingDaeCorridor) {
return;
}
setLoadingDaeCorridor(true);
try {
const alertLonLat = alert?.location?.coordinates;
const hasAlertLonLat =
Array.isArray(alertLonLat) &&
alertLonLat.length === 2 &&
alertLonLat[0] !== null &&
alertLonLat[1] !== null;
if (!hasAlertLonLat) {
toast.show("Position de l'alerte indisponible", {
placement: "top",
duration: 4000,
hideOnPress: true,
});
return;
}
// 1) Current coords if possible
let coords = await getCurrentLocation();
// 2) Fallback to last-known coords if needed
const hasCoords =
coords && coords.latitude !== null && coords.longitude !== null;
if (!hasCoords) {
const lastKnown = await getStoredLocation();
coords = lastKnown?.coords || coords;
}
const hasFinalCoords =
coords && coords.latitude !== null && coords.longitude !== null;
if (!hasFinalCoords) {
toast.show(
"Localisation indisponible : activez la géolocalisation pour afficher les défibrillateurs.",
{
placement: "top",
duration: 6000,
hideOnPress: true,
},
);
return;
}
const userLonLat = [coords.longitude, coords.latitude];
const { error } = await defibsActions.loadCorridor({
userLonLat,
alertLonLat,
});
if (error) {
defibsActions.setShowDefibsOnAlertMap(false);
toast.show(
"Impossible de charger les défibrillateurs (base hors-ligne indisponible).",
{
placement: "top",
duration: 6000,
hideOnPress: true,
},
);
return;
}
defibsActions.setShowDefibsOnAlertMap(true);
navigation.navigate("Main", {
screen: "AlertCur",
params: {
screen: "AlertCurTab",
params: {
screen: "AlertCurMap",
},
},
});
} catch (error) {
defibsActions.setShowDefibsOnAlertMap(false);
toast.show("Erreur lors du chargement des défibrillateurs", {
placement: "top",
duration: 6000,
hideOnPress: true,
});
} finally {
setLoadingDaeCorridor(false);
}
}, [alert, loadingDaeCorridor, navigation, toast]);
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION); const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
const notifyAround = useCallback(async () => { const notifyAround = useCallback(async () => {
@ -398,6 +494,33 @@ export default withConnectivity(
</View> </View>
)} )}
{isOpen && alert.location?.coordinates && (
<View
key="show-defibs"
style={[styles.actionContainer, styles.actionShowDefibs]}
>
<Button
mode="contained"
loading={loadingDaeCorridor}
disabled={loadingDaeCorridor}
icon={() => (
<MaterialCommunityIcons
name="heart-pulse"
style={[styles.actionIcon, styles.actionShowDefibsIcon]}
/>
)}
style={[styles.actionButton, styles.actionShowDefibsButton]}
onPress={showDefibsOnAlertMap}
>
<Text
style={[styles.actionText, styles.actionShowDefibsText]}
>
Afficher les défibrillateurs
</Text>
</Button>
</View>
)}
{!isSent && alert.location?.coordinates && ( {!isSent && alert.location?.coordinates && (
<MapLinksButton coordinates={alert.location.coordinates} /> <MapLinksButton coordinates={alert.location.coordinates} />
)} )}

View file

@ -70,6 +70,11 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
actionComingHelpButton: {}, actionComingHelpButton: {},
actionComingHelpText: {}, actionComingHelpText: {},
actionComingHelpIcon: {}, actionComingHelpIcon: {},
actionShowDefibsButton: {
backgroundColor: colors.blue,
},
actionShowDefibsText: {},
actionShowDefibsIcon: {},
actionSmsButton: {}, actionSmsButton: {},
actionSmsText: {}, actionSmsText: {},
actionSmsIcon: {}, actionSmsIcon: {},

385
src/scenes/DAEItem/Carte.js Normal file
View file

@ -0,0 +1,385 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View, StyleSheet } from "react-native";
import Maplibre from "@maplibre/maplibre-react-native";
import polyline from "@mapbox/polyline";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Button } from "react-native-paper";
import MapView from "~/containers/Map/MapView";
import Camera from "~/containers/Map/Camera";
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
import Text from "~/components/Text";
import Loader from "~/components/Loader";
import { useTheme } from "~/theme";
import { useDefibsState, useNetworkState } from "~/stores";
import useLocation from "~/hooks/useLocation";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
import { osmProfileUrl } from "~/scenes/AlertCurMap/routing";
const STATUS_COLORS = {
open: "#4CAF50",
closed: "#F44336",
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() {
const { colors } = useTheme();
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
const { hasInternetConnection } = useNetworkState(["hasInternetConnection"]);
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
const mapRef = useRef();
const cameraRef = useRef();
const [cameraKey, setCameraKey] = useState(1);
const abortControllerRef = useRef(null);
const refreshCamera = useCallback(() => {
setCameraKey(`${Date.now()}`);
}, []);
const hasUserCoords =
coords && coords.latitude !== null && coords.longitude !== null;
const hasDefibCoords = defib && defib.latitude && defib.longitude;
const [routeCoords, setRouteCoords] = useState(null);
const [routeInfo, setRouteInfo] = useState(null);
const [routeError, setRouteError] = useState(null);
const [loadingRoute, setLoadingRoute] = useState(false);
const profile = "foot"; // walking itinerary to defib
// Compute route
useEffect(() => {
if (!hasUserCoords || !hasDefibCoords || !hasInternetConnection) {
return;
}
// Abort any previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
const fetchRoute = async () => {
setLoadingRoute(true);
setRouteError(null);
try {
const origin = `${coords.longitude},${coords.latitude}`;
const target = `${defib.longitude},${defib.latitude}`;
const osrmUrl = osmProfileUrl[profile] || osmProfileUrl.foot;
const url = `${osrmUrl}/route/v1/${profile}/${origin};${target}?overview=full&steps=true`;
const res = await fetch(url, { signal: controller.signal });
const result = await res.json();
if (result.routes && result.routes.length > 0) {
const route = result.routes[0];
const decoded = polyline
.decode(route.geometry)
.map((p) => p.reverse());
setRouteCoords(decoded);
setRouteInfo({
distance: route.distance,
duration: route.duration,
});
}
} catch (err) {
if (err.name !== "AbortError") {
console.warn("Route calculation failed:", err.message);
setRouteError(err);
}
} finally {
setLoadingRoute(false);
}
};
fetchRoute();
return () => {
controller.abort();
};
}, [
hasUserCoords,
hasDefibCoords,
hasInternetConnection,
coords,
defib,
profile,
]);
// Defib marker GeoJSON
const defibGeoJSON = useMemo(() => {
if (!hasDefibCoords) return null;
const { status } = getDefibAvailability(
defib.horaires_std,
defib.disponible_24h,
);
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Point",
coordinates: [defib.longitude, defib.latitude],
},
properties: {
id: defib.id,
nom: defib.nom || "Défibrillateur",
color: STATUS_COLORS[status],
},
},
],
};
}, [defib, hasDefibCoords]);
// Route line GeoJSON
const routeGeoJSON = useMemo(() => {
if (!routeCoords || routeCoords.length < 2) return null;
return {
type: "Feature",
geometry: {
type: "LineString",
coordinates: routeCoords,
},
};
}, [routeCoords]);
// Camera bounds to show both user + defib
const bounds = useMemo(() => {
if (!hasUserCoords || !hasDefibCoords) return null;
const lats = [coords.latitude, defib.latitude];
const lons = [coords.longitude, defib.longitude];
return {
ne: [Math.max(...lons), Math.max(...lats)],
sw: [Math.min(...lons), Math.min(...lats)],
};
}, [hasUserCoords, hasDefibCoords, coords, defib]);
if (!defib) return null;
return (
<View style={styles.container}>
{/* Offline banner */}
{!hasInternetConnection && (
<View
style={[
styles.offlineBanner,
{ backgroundColor: (colors.error || "#F44336") + "15" },
]}
>
<MaterialCommunityIcons
name="wifi-off"
size={18}
color={colors.error || "#F44336"}
/>
<Text
style={[
styles.offlineBannerText,
{ color: colors.error || "#F44336" },
]}
>
Hors ligne l'itinéraire n'est pas disponible
</Text>
</View>
)}
{/* Route info bar */}
{routeInfo && (
<View
style={[
styles.routeInfoBar,
{
backgroundColor: colors.surface,
borderBottomColor: colors.outlineVariant || colors.grey,
},
]}
>
<MaterialCommunityIcons
name="walk"
size={20}
color={colors.primary}
/>
<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
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 */}
{routeError && !loadingRoute && (
<View style={styles.routeErrorOverlay}>
<Text
style={[
styles.routeErrorText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Impossible de calculer l'itinéraire
</Text>
</View>
)}
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
offlineBanner: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 10,
gap: 8,
},
offlineBannerText: {
fontSize: 13,
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: {
position: "absolute",
bottom: 16,
left: 16,
right: 16,
alignItems: "center",
},
routeErrorText: {
fontSize: 13,
textAlign: "center",
},
});

385
src/scenes/DAEItem/Infos.js Normal file
View file

@ -0,0 +1,385 @@
import React, { useCallback } from "react";
import { View, ScrollView, StyleSheet } from "react-native";
import { Button } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import Text from "~/components/Text";
import { createStyles, useTheme } from "~/theme";
import { useDefibsState } from "~/stores";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
const STATUS_COLORS = {
open: "#4CAF50",
closed: "#F44336",
unknown: "#9E9E9E",
};
const STATUS_ICONS = {
open: "check-circle",
closed: "close-circle",
unknown: "help-circle",
};
const DAY_LABELS = {
1: "Lundi",
2: "Mardi",
3: "Mercredi",
4: "Jeudi",
5: "Vendredi",
6: "Samedi",
7: "Dimanche",
};
function formatDistance(meters) {
if (meters == null) return "Distance inconnue";
if (meters < 1000) return `${Math.round(meters)} m`;
return `${(meters / 1000).toFixed(1)} km`;
}
function InfoRow({ icon, label, value, valueStyle }) {
const { colors } = useTheme();
if (!value) return null;
return (
<View
style={[
styles.infoRow,
{ borderBottomColor: colors.outlineVariant || colors.grey },
]}
>
<MaterialCommunityIcons
name={icon}
size={20}
color={colors.primary}
style={styles.infoIcon}
/>
<View style={styles.infoContent}>
<Text
style={[
styles.infoLabel,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{label}
</Text>
<Text style={[styles.infoValue, valueStyle]}>{value}</Text>
</View>
</View>
);
}
function ScheduleSection({ defib }) {
const { colors } = useTheme();
const h = defib.horaires_std;
// If we have structured schedule info, render it
if (h && typeof h === "object") {
const parts = [];
if (h.is24h) {
parts.push(
<Text key="24h" style={styles.scheduleItem}>
Ouvert 24h/24
</Text>,
);
}
if (h.businessHours) {
parts.push(
<Text key="bh" style={styles.scheduleItem}>
Heures ouvrables (Lun-Ven 08h-18h)
</Text>,
);
}
if (h.nightHours) {
parts.push(
<Text key="nh" style={styles.scheduleItem}>
Heures de nuit (20h-08h)
</Text>,
);
}
if (h.events) {
parts.push(
<Text key="ev" style={styles.scheduleItem}>
Selon événements
</Text>,
);
}
if (Array.isArray(h.days) && h.days.length > 0) {
const dayStr = h.days
.sort((a, b) => a - b)
.map((d) => DAY_LABELS[d] || `Jour ${d}`)
.join(", ");
parts.push(
<Text key="days" style={styles.scheduleItem}>
Jours : {dayStr}
</Text>,
);
}
if (Array.isArray(h.slots) && h.slots.length > 0) {
const slotsStr = h.slots
.map((s) => `${s.open || "?"} ${s.close || "?"}`)
.join(", ");
parts.push(
<Text key="slots" style={styles.scheduleItem}>
Créneaux : {slotsStr}
</Text>,
);
}
if (h.notes) {
parts.push(
<Text
key="notes"
style={[
styles.scheduleItem,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{h.notes}
</Text>,
);
}
if (parts.length > 0) {
return (
<View style={styles.scheduleContainer}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons
name="clock-outline"
size={20}
color={colors.primary}
style={styles.infoIcon}
/>
<Text style={styles.sectionTitle}>Horaires détaillés</Text>
</View>
<View style={styles.scheduleParts}>{parts}</View>
</View>
);
}
}
// Fallback to raw horaires string
if (defib.horaires) {
return (
<View style={styles.scheduleContainer}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons
name="clock-outline"
size={20}
color={colors.primary}
style={styles.infoIcon}
/>
<Text style={styles.sectionTitle}>Horaires</Text>
</View>
<Text
style={[
styles.scheduleRaw,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{defib.horaires}
</Text>
</View>
);
}
return null;
}
export default React.memo(function DAEItemInfos() {
const { colors } = useTheme();
const navigation = useNavigation();
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
const { status, label: availabilityLabel } = getDefibAvailability(
defib?.horaires_std,
defib?.disponible_24h,
);
const statusColor = STATUS_COLORS[status];
const goToCarte = useCallback(() => {
navigation.navigate("DAEItemCarte");
}, [navigation]);
if (!defib) return null;
return (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.contentContainer}
>
{/* Header with availability */}
<View
style={[
styles.availabilityCard,
{ backgroundColor: statusColor + "12" },
]}
>
<MaterialCommunityIcons
name={STATUS_ICONS[status]}
size={36}
color={statusColor}
/>
<View style={styles.availabilityInfo}>
<Text style={[styles.availabilityStatus, { color: statusColor }]}>
{status === "open"
? "Disponible"
: status === "closed"
? "Indisponible"
: "Disponibilité inconnue"}
</Text>
<Text
style={[
styles.availabilityLabel,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{availabilityLabel}
</Text>
</View>
</View>
{/* Basic info */}
<InfoRow icon="heart-pulse" label="Nom" value={defib.nom} />
<InfoRow icon="map-marker" label="Adresse" value={defib.adresse} />
<InfoRow icon="door-open" label="Accès" value={defib.acces} />
<InfoRow
icon="map-marker-distance"
label="Distance"
value={formatDistance(defib.distanceMeters)}
/>
{/* Schedule section */}
<ScheduleSection defib={defib} />
{/* Itinéraire button */}
<View style={styles.itineraireContainer}>
<Button
mode="contained"
onPress={goToCarte}
icon={({ size, color }) => (
<MaterialCommunityIcons
name="navigation-variant"
size={size}
color={color}
/>
)}
style={styles.itineraireButton}
contentStyle={styles.itineraireButtonContent}
>
Itinéraire
</Button>
</View>
{/* Back to list */}
<View style={styles.backContainer}>
<Button
mode="outlined"
onPress={() => navigation.navigate("DAEList")}
icon="arrow-left"
style={styles.backButton}
>
Retour à la liste
</Button>
</View>
</ScrollView>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
padding: 16,
paddingBottom: 32,
},
availabilityCard: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderRadius: 12,
marginBottom: 20,
},
availabilityInfo: {
marginLeft: 12,
flex: 1,
},
availabilityStatus: {
fontSize: 18,
fontWeight: "700",
},
availabilityLabel: {
fontSize: 13,
marginTop: 2,
},
infoRow: {
flexDirection: "row",
alignItems: "flex-start",
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoContent: {
flex: 1,
},
infoLabel: {
fontSize: 12,
marginBottom: 2,
},
infoValue: {
fontSize: 15,
},
scheduleContainer: {
marginTop: 20,
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
sectionTitle: {
fontSize: 16,
fontWeight: "600",
},
scheduleParts: {
paddingLeft: 32,
gap: 4,
},
scheduleItem: {
fontSize: 14,
lineHeight: 20,
},
scheduleRaw: {
paddingLeft: 32,
fontSize: 14,
lineHeight: 20,
},
itineraireContainer: {
marginTop: 24,
alignItems: "center",
},
itineraireButton: {
minWidth: 200,
borderRadius: 24,
},
itineraireButtonContent: {
paddingVertical: 6,
},
backContainer: {
marginTop: 16,
alignItems: "center",
},
backButton: {
minWidth: 200,
},
});

130
src/scenes/DAEItem/index.js Normal file
View file

@ -0,0 +1,130 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { Button } from "react-native-paper";
import { fontFamily, useTheme } from "~/theme";
import { useDefibsState } from "~/stores";
import Text from "~/components/Text";
import DAEItemInfos from "./Infos";
import DAEItemCarte from "./Carte";
const Tab = createBottomTabNavigator();
function EmptyState() {
const { colors } = useTheme();
const navigation = useNavigation();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="heart-off"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Aucun défibrillateur sélectionné</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Sélectionnez un défibrillateur depuis la liste pour voir ses détails.
</Text>
<Button
mode="contained"
onPress={() => navigation.navigate("DAEList")}
style={styles.backButton}
icon="arrow-left"
>
Retour à la liste
</Button>
</View>
);
}
export default React.memo(function DAEItem() {
const { colors } = useTheme();
const { selectedDefib } = useDefibsState(["selectedDefib"]);
if (!selectedDefib) {
return <EmptyState />;
}
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
tabBarLabelStyle: {
fontFamily,
fontSize: 12,
},
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.outlineVariant || colors.grey,
},
}}
>
<Tab.Screen
name="DAEItemInfos"
component={DAEItemInfos}
options={{
tabBarLabel: "Infos",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="information-outline"
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name="DAEItemCarte"
component={DAEItemCarte}
options={{
tabBarLabel: "Carte",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="map-marker-outline"
color={color}
size={size}
/>
),
}}
/>
</Tab.Navigator>
);
});
const styles = StyleSheet.create({
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 32,
},
emptyIcon: {
marginBottom: 16,
},
emptyTitle: {
fontSize: 18,
fontWeight: "600",
textAlign: "center",
marginBottom: 8,
},
emptyText: {
fontSize: 14,
textAlign: "center",
lineHeight: 20,
marginBottom: 16,
},
backButton: {
marginTop: 8,
},
});

227
src/scenes/DAEList/Carte.js Normal file
View file

@ -0,0 +1,227 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { View, StyleSheet } from "react-native";
import Maplibre from "@maplibre/maplibre-react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import MapView from "~/containers/Map/MapView";
import Camera from "~/containers/Map/Camera";
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
import { BoundType, DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
import Text from "~/components/Text";
import Loader from "~/components/Loader";
import { useTheme } from "~/theme";
import { defibsActions } from "~/stores";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
import useNearbyDefibs from "./useNearbyDefibs";
const STATUS_COLORS = {
open: "#4CAF50",
closed: "#F44336",
unknown: "#9E9E9E",
};
function defibsToGeoJSON(defibs) {
return {
type: "FeatureCollection",
features: defibs.map((d) => {
const { status } = getDefibAvailability(d.horaires_std, d.disponible_24h);
return {
type: "Feature",
id: d.id,
geometry: {
type: "Point",
coordinates: [d.longitude, d.latitude],
},
properties: {
id: d.id,
nom: d.nom || "Défibrillateur",
status,
color: STATUS_COLORS[status],
},
};
}),
};
}
function EmptyNoLocation() {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="crosshairs-off"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Localisation indisponible</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Activez la géolocalisation pour afficher les défibrillateurs sur la
carte.
</Text>
</View>
);
}
export default React.memo(function DAEListCarte() {
const { colors } = useTheme();
const navigation = useNavigation();
const {
defibs,
loading,
noLocation,
hasLocation,
isLastKnown,
lastKnownTimestamp,
coords,
} = useNearbyDefibs();
const mapRef = useRef();
const cameraRef = useRef();
const [cameraKey, setCameraKey] = useState(1);
const refreshCamera = useCallback(() => {
setCameraKey(`${Date.now()}`);
}, []);
const hasCoords =
coords && coords.latitude !== null && coords.longitude !== null;
// Camera state — simple follow user
const [followUserLocation] = useState(true);
const [followUserMode] = useState(Maplibre.UserTrackingMode.Follow);
const [zoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]);
const onMarkerPress = useCallback(
(e) => {
const feature = e?.features?.[0];
if (!feature) return;
const defibId = feature.properties?.id;
const defib = defibs.find((d) => d.id === defibId);
if (defib) {
defibsActions.setSelectedDefib(defib);
navigation.navigate("DAEItem");
}
},
[defibs, navigation],
);
if (noLocation && !hasLocation) {
return <EmptyNoLocation />;
}
if (loading && defibs.length === 0 && !hasCoords) {
return <Loader />;
}
return (
<View style={styles.container}>
<MapView
mapRef={mapRef}
compassViewPosition={1}
compassViewMargin={{ x: 10, y: 10 }}
>
<Camera
cameraKey={cameraKey}
setCameraKey={setCameraKey}
refreshCamera={refreshCamera}
cameraRef={cameraRef}
followUserLocation={followUserLocation}
followUserMode={followUserMode}
followPitch={0}
zoomLevel={zoomLevel}
bounds={null}
detached={false}
/>
{geoJSON.features.length > 0 && (
<Maplibre.ShapeSource
id="defibSource"
shape={geoJSON}
onPress={onMarkerPress}
>
<Maplibre.CircleLayer
id="defibCircleLayer"
style={{
circleRadius: 8,
circleColor: ["get", "color"],
circleStrokeColor: "#FFFFFF",
circleStrokeWidth: 2,
}}
/>
<Maplibre.SymbolLayer
id="defibSymbolLayer"
aboveLayerID="defibCircleLayer"
style={{
iconImage: "heart-pulse",
iconSize: 0.6,
iconAllowOverlap: true,
textField: ["get", "nom"],
textSize: 11,
textOffset: [0, 1.5],
textAnchor: "top",
textMaxWidth: 12,
textColor: colors.onSurface,
textHaloColor: colors.surface,
textHaloWidth: 1,
textOptional: true,
}}
/>
</Maplibre.ShapeSource>
)}
{isLastKnown && hasCoords ? (
<LastKnownLocationMarker
coordinates={coords}
timestamp={lastKnownTimestamp}
id="lastKnownLocation_daeList"
/>
) : (
<Maplibre.UserLocation visible showsUserHeadingIndicator />
)}
</MapView>
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 32,
},
emptyIcon: {
marginBottom: 16,
},
emptyTitle: {
fontSize: 18,
fontWeight: "600",
textAlign: "center",
marginBottom: 8,
},
emptyText: {
fontSize: 14,
textAlign: "center",
lineHeight: 20,
},
});

View file

@ -0,0 +1,166 @@
import React, { useCallback } from "react";
import { View, StyleSheet } from "react-native";
import { TouchableRipple } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import Text from "~/components/Text";
import { useTheme } from "~/theme";
import { defibsActions } from "~/stores";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
function formatDistance(meters) {
if (meters == null) return "";
if (meters < 1000) return `${Math.round(meters)} m`;
return `${(meters / 1000).toFixed(1)} km`;
}
const STATUS_COLORS = {
open: "#4CAF50",
closed: "#F44336",
unknown: "#9E9E9E",
};
const STATUS_ICONS = {
open: "check-circle",
closed: "close-circle",
unknown: "help-circle",
};
function DefibRow({ defib }) {
const { colors } = useTheme();
const navigation = useNavigation();
const { status, label } = getDefibAvailability(
defib.horaires_std,
defib.disponible_24h,
);
const statusColor = STATUS_COLORS[status];
const onPress = useCallback(() => {
defibsActions.setSelectedDefib(defib);
navigation.navigate("DAEItem");
}, [defib, navigation]);
return (
<TouchableRipple
onPress={onPress}
style={[
styles.row,
{ borderBottomColor: colors.outlineVariant || colors.grey },
]}
accessibilityRole="button"
accessibilityLabel={`${defib.nom || "Défibrillateur"}, ${formatDistance(
defib.distanceMeters,
)}, ${label}`}
accessibilityHint="Ouvrir le détail de ce défibrillateur"
>
<View style={styles.rowInner}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
name={STATUS_ICONS[status]}
size={28}
color={statusColor}
/>
</View>
<View style={styles.content}>
<Text style={styles.name} numberOfLines={1}>
{defib.nom || "Défibrillateur"}
</Text>
<Text
style={[
styles.address,
{ color: colors.onSurfaceVariant || colors.grey },
]}
numberOfLines={1}
>
{defib.adresse || "Adresse non renseignée"}
</Text>
<View style={styles.meta}>
<View
style={[
styles.statusBadge,
{ backgroundColor: statusColor + "20" },
]}
>
<Text style={[styles.statusText, { color: statusColor }]}>
{label}
</Text>
</View>
</View>
</View>
<View style={styles.distanceContainer}>
<MaterialCommunityIcons
name="map-marker-distance"
size={16}
color={colors.onSurfaceVariant || colors.grey}
/>
<Text
style={[
styles.distance,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{formatDistance(defib.distanceMeters)}
</Text>
</View>
</View>
</TouchableRipple>
);
}
export default React.memo(DefibRow);
const styles = StyleSheet.create({
row: {
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 12,
paddingHorizontal: 16,
},
rowInner: {
flexDirection: "row",
alignItems: "center",
},
iconContainer: {
marginRight: 12,
justifyContent: "center",
alignItems: "center",
},
content: {
flex: 1,
marginRight: 8,
},
name: {
fontSize: 15,
fontWeight: "600",
},
address: {
fontSize: 13,
marginTop: 2,
},
meta: {
flexDirection: "row",
alignItems: "center",
marginTop: 4,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
statusText: {
fontSize: 11,
fontWeight: "600",
},
distanceContainer: {
alignItems: "center",
justifyContent: "center",
minWidth: 50,
},
distance: {
fontSize: 12,
marginTop: 2,
textAlign: "center",
},
});

197
src/scenes/DAEList/Liste.js Normal file
View file

@ -0,0 +1,197 @@
import React, { useCallback } from "react";
import { View, FlatList, StyleSheet } from "react-native";
import { Button } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Text from "~/components/Text";
import Loader from "~/components/Loader";
import { useTheme } from "~/theme";
import useNearbyDefibs from "./useNearbyDefibs";
import DefibRow from "./DefibRow";
function EmptyNoLocation() {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="crosshairs-off"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Localisation indisponible</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Activez la géolocalisation pour trouver les défibrillateurs à proximité.
Vérifiez les paramètres de localisation de votre appareil.
</Text>
</View>
);
}
function EmptyError({ error, onRetry }) {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="alert-circle-outline"
size={56}
color={colors.error || "#F44336"}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Erreur de chargement</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Impossible de charger les défibrillateurs.{"\n"}
{error?.message || "Veuillez réessayer."}
</Text>
{onRetry && (
<Button mode="contained" onPress={onRetry} style={styles.retryButton}>
Réessayer
</Button>
)}
</View>
);
}
function EmptyNoResults() {
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</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Aucun défibrillateur trouvé dans un rayon de 10 km autour de votre
position.
</Text>
</View>
);
}
const keyExtractor = (item) => item.id;
export default React.memo(function DAEListListe() {
const { colors } = useTheme();
const { defibs, loading, error, noLocation, hasLocation, reload } =
useNearbyDefibs();
const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []);
// No location available
if (noLocation && !hasLocation) {
return <EmptyNoLocation />;
}
// Loading initial data
if (loading && defibs.length === 0) {
return <Loader />;
}
// Error state (non-blocking if we have stale data)
if (error && defibs.length === 0) {
return <EmptyError error={error} onRetry={reload} />;
}
// No results
if (!loading && defibs.length === 0 && hasLocation) {
return <EmptyNoResults />;
}
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
{error && defibs.length > 0 && (
<View
style={[
styles.errorBanner,
{ backgroundColor: (colors.error || "#F44336") + "15" },
]}
>
<MaterialCommunityIcons
name="alert-circle-outline"
size={16}
color={colors.error || "#F44336"}
/>
<Text
style={[
styles.errorBannerText,
{ color: colors.error || "#F44336" },
]}
>
Erreur de mise à jour données potentiellement obsolètes
</Text>
</View>
)}
<FlatList
data={defibs}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.list}
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={5}
/>
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
flexGrow: 1,
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 32,
},
emptyIcon: {
marginBottom: 16,
},
emptyTitle: {
fontSize: 18,
fontWeight: "600",
textAlign: "center",
marginBottom: 8,
},
emptyText: {
fontSize: 14,
textAlign: "center",
lineHeight: 20,
},
retryButton: {
marginTop: 20,
},
errorBanner: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 8,
gap: 8,
},
errorBannerText: {
fontSize: 12,
flex: 1,
},
});

View file

@ -0,0 +1,61 @@
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { fontFamily, useTheme } from "~/theme";
import DAEListListe from "./Liste";
import DAEListCarte from "./Carte";
const Tab = createBottomTabNavigator();
export default React.memo(function DAEList() {
const { colors } = useTheme();
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
tabBarLabelStyle: {
fontFamily,
fontSize: 12,
},
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.outlineVariant || colors.grey,
},
}}
>
<Tab.Screen
name="DAEListListe"
component={DAEListListe}
options={{
tabBarLabel: "Liste",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="format-list-bulleted"
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name="DAEListCarte"
component={DAEListCarte}
options={{
tabBarLabel: "Carte",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="map-marker-outline"
color={color}
size={size}
/>
),
}}
/>
</Tab.Navigator>
);
});

View file

@ -0,0 +1,72 @@
import { useEffect, useRef, useCallback, useState } from "react";
import useLocation from "~/hooks/useLocation";
import { defibsActions, useDefibsState } from "~/stores";
const RADIUS_METERS = 10_000;
/**
* 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.
*/
export default function useNearbyDefibs() {
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
const { nearUserDefibs, loadingNearUser, errorNearUser } = useDefibsState([
"nearUserDefibs",
"loadingNearUser",
"errorNearUser",
]);
const hasLocation =
coords && coords.latitude !== null && coords.longitude !== null;
// Track whether we've already triggered a load for these coords
const lastLoadedRef = useRef(null);
const [noLocation, setNoLocation] = useState(false);
const loadDefibs = useCallback(async () => {
if (!hasLocation) {
return;
}
const key = `${coords.latitude.toFixed(4)},${coords.longitude.toFixed(4)}`;
if (lastLoadedRef.current === key) {
return; // skip duplicate loads for same position
}
lastLoadedRef.current = key;
await defibsActions.loadNearUser({
userLonLat: [coords.longitude, coords.latitude],
radiusMeters: RADIUS_METERS,
});
}, [hasLocation, coords]);
useEffect(() => {
if (hasLocation) {
setNoLocation(false);
loadDefibs();
}
}, [hasLocation, loadDefibs]);
// After a timeout, if we still have no location, set the flag
useEffect(() => {
if (hasLocation) {
return;
}
const timer = setTimeout(() => {
if (!hasLocation) {
setNoLocation(true);
}
}, 8000);
return () => clearTimeout(timer);
}, [hasLocation]);
return {
defibs: nearUserDefibs,
loading: loadingNearUser,
error: errorNearUser,
hasLocation,
noLocation,
isLastKnown,
lastKnownTimestamp,
coords,
reload: loadDefibs,
};
}

View file

@ -10,7 +10,8 @@ import {
} from "react-native-gesture-handler"; } from "react-native-gesture-handler";
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { format, fr } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql"; import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql";
import { import {

View file

@ -6,11 +6,18 @@ import uuidGenerator from "react-native-uuid";
import { phoneCallEmergency } from "~/lib/phone-call"; import { phoneCallEmergency } from "~/lib/phone-call";
import network from "~/network"; import network from "~/network";
import { getSessionState, alertActions, useParamsState } from "~/stores"; import {
getSessionState,
alertActions,
defibsActions,
useParamsState,
} from "~/stores";
import { getCurrentLocation } from "~/location"; import { getCurrentLocation } from "~/location";
import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency"; import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency";
import subjectSuggestsDefib from "~/utils/dae/subjectSuggestsDefib";
import { SEND_ALERT_MUTATION } from "./gql"; import { SEND_ALERT_MUTATION } from "./gql";
export default function useOnSubmit() { export default function useOnSubmit() {
@ -125,6 +132,13 @@ async function onSubmit(args, context) {
}); });
alertActions.setNavAlertCur({ alert }); alertActions.setNavAlertCur({ alert });
// Task 9 (DAE v1): keyword detection based on subject only.
// Must be independent of network; we trigger purely from the subject and persist state in store.
if (subjectSuggestsDefib(subject)) {
defibsActions.setShowDaeSuggestModal(true);
}
navigation.navigate("Main", { navigation.navigate("Main", {
screen: "AlertCur", screen: "AlertCur",
params: { params: {

114
src/stores/defibs.js Normal file
View file

@ -0,0 +1,114 @@
import { createAtom } from "~/lib/atomic-zustand";
import getNearbyDefibs from "~/data/getNearbyDefibs";
import {
computeCorridorQueryRadiusMeters,
filterDefibsInCorridor,
} from "~/utils/geo/corridor";
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
const DEFAULT_CORRIDOR_M = 10_000;
const DEFAULT_LIMIT = 200;
export default createAtom(({ merge, reset }) => {
const actions = {
reset,
setShowDefibsOnAlertMap: (showDefibsOnAlertMap) => {
merge({ showDefibsOnAlertMap });
},
setSelectedDefib: (selectedDefib) => {
merge({ selectedDefib });
},
setShowDaeSuggestModal: (showDaeSuggestModal) => {
merge({ showDaeSuggestModal });
},
loadNearUser: async ({
userLonLat,
radiusMeters = DEFAULT_NEAR_USER_RADIUS_M,
}) => {
merge({ loadingNearUser: true, errorNearUser: null });
try {
const [lon, lat] = userLonLat;
const nearUserDefibs = await getNearbyDefibs({
lat,
lon,
radiusMeters,
limit: DEFAULT_LIMIT,
progressive: true,
});
merge({ nearUserDefibs, loadingNearUser: false });
return { defibs: nearUserDefibs, error: null };
} catch (error) {
merge({
nearUserDefibs: [],
loadingNearUser: false,
errorNearUser: error,
});
return { defibs: [], error };
}
},
loadCorridor: async ({
userLonLat,
alertLonLat,
corridorMeters = DEFAULT_CORRIDOR_M,
}) => {
merge({ loadingCorridor: true, errorCorridor: null });
try {
const radiusMeters = computeCorridorQueryRadiusMeters({
userLonLat,
alertLonLat,
corridorMeters,
});
const midLon = (userLonLat[0] + alertLonLat[0]) / 2;
const midLat = (userLonLat[1] + alertLonLat[1]) / 2;
const candidates = await getNearbyDefibs({
lat: midLat,
lon: midLon,
radiusMeters,
limit: DEFAULT_LIMIT,
progressive: true,
});
const corridorDefibs = filterDefibsInCorridor({
defibs: candidates,
userLonLat,
alertLonLat,
corridorMeters,
}).slice(0, DEFAULT_LIMIT);
merge({ corridorDefibs, loadingCorridor: false });
return { defibs: corridorDefibs, error: null };
} catch (error) {
merge({
corridorDefibs: [],
loadingCorridor: false,
errorCorridor: error,
});
return { defibs: [], error };
}
},
};
return {
default: {
nearUserDefibs: [],
corridorDefibs: [],
showDefibsOnAlertMap: false,
selectedDefib: null,
showDaeSuggestModal: false,
loadingNearUser: false,
loadingCorridor: false,
errorNearUser: null,
errorCorridor: null,
},
actions,
};
});

View file

@ -13,6 +13,7 @@ import params from "./params";
import notifications from "./notifications"; import notifications from "./notifications";
import permissionWizard from "./permissionWizard"; import permissionWizard from "./permissionWizard";
import aggregatedMessages from "./aggregatedMessages"; import aggregatedMessages from "./aggregatedMessages";
import defibs from "./defibs";
const store = createStore({ const store = createStore({
tree, tree,
@ -28,6 +29,7 @@ const store = createStore({
permissionWizard, permissionWizard,
notifications, notifications,
aggregatedMessages, aggregatedMessages,
defibs,
}); });
// console.log("store", JSON.stringify(Object.keys(store), null, 2)); // console.log("store", JSON.stringify(Object.keys(store), null, 2));
@ -100,4 +102,9 @@ export const {
getAggregatedMessagesState, getAggregatedMessagesState,
subscribeAggregatedMessagesState, subscribeAggregatedMessagesState,
aggregatedMessagesActions, aggregatedMessagesActions,
useDefibsState,
getDefibsState,
subscribeDefibsState,
defibsActions,
} = store; } = store;

View file

@ -0,0 +1,174 @@
/**
* @typedef {{
* days: number[]|null,
* slots: {open: string, close: string}[]|null,
* is24h?: boolean,
* businessHours?: boolean,
* nightHours?: boolean,
* events?: boolean,
* notes?: string,
* }} HorairesStd
*/
/**
* @typedef {{ status: "open"|"closed"|"unknown", label: string }} DefibAvailability
*/
function pad2(n) {
return String(n).padStart(2, "0");
}
function minutesSinceMidnight(date) {
return date.getHours() * 60 + date.getMinutes();
}
function parseHHMM(str) {
if (typeof str !== "string") return null;
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(str.trim());
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
}
// ISO 8601 day number: 1=Mon ... 7=Sun
function isoDayNumber(date) {
const js = date.getDay(); // 0=Sun..6=Sat
return js === 0 ? 7 : js;
}
const DAY_LABELS = [null, "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
function daysLabel(days) {
if (!Array.isArray(days) || days.length === 0) return "";
const uniq = Array.from(new Set(days)).filter((d) => d >= 1 && d <= 7);
uniq.sort((a, b) => a - b);
if (uniq.length === 1) return DAY_LABELS[uniq[0]];
return `${DAY_LABELS[uniq[0]]}-${DAY_LABELS[uniq[uniq.length - 1]]}`;
}
function formatTimeFromMinutes(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${pad2(h)}:${pad2(m)}`;
}
function isWithinSlot(nowMin, openMin, closeMin) {
if (openMin == null || closeMin == null) return false;
if (openMin === closeMin) return true;
// Cross-midnight slot (e.g. 20:00-08:00)
if (closeMin < openMin) {
return nowMin >= openMin || nowMin < closeMin;
}
return nowMin >= openMin && nowMin < closeMin;
}
/**
* Determine availability for a given defib schedule.
* Priority logic per PLAN_DAE-merged.md.
*
* @param {HorairesStd|null|undefined} horaires_std
* @param {number|boolean|null|undefined} disponible_24h
* @param {Date} [now]
* @returns {DefibAvailability}
*/
export function getDefibAvailability(
horaires_std,
disponible_24h,
now = new Date(),
) {
if (disponible_24h === 1 || disponible_24h === true) {
return { status: "open", label: "24h/24 7j/7" };
}
/** @type {HorairesStd} */
const h =
horaires_std && typeof horaires_std === "object" ? horaires_std : null;
if (!h) {
return { status: "unknown", label: "Horaires non renseignés" };
}
const today = isoDayNumber(now);
const nowMin = minutesSinceMidnight(now);
const days = Array.isArray(h.days) ? h.days : null;
const hasToday = Array.isArray(days) ? days.includes(today) : null;
// 2. is24h + today
if (h.is24h === true && hasToday === true) {
return { status: "open", label: "24h/24" };
}
// 3. days known and today not included
if (Array.isArray(days) && hasToday === false) {
const label = daysLabel(days);
return { status: "closed", label: label || "Fermé aujourd'hui" };
}
// 4. explicit slots for today
if (hasToday === true && Array.isArray(h.slots) && h.slots.length > 0) {
let isOpen = false;
let nextBoundaryLabel = "";
for (const slot of h.slots) {
const openMin = parseHHMM(slot.open);
const closeMin = parseHHMM(slot.close);
if (openMin == null || closeMin == null) continue;
if (isWithinSlot(nowMin, openMin, closeMin)) {
isOpen = true;
nextBoundaryLabel = `Jusqu'à ${formatTimeFromMinutes(closeMin)}`;
break;
}
}
if (isOpen) {
return { status: "open", label: nextBoundaryLabel || "Ouvert" };
}
// Not within any slot: show next opening time if any (same-day).
const opens = h.slots
.map((s) => parseHHMM(s.open))
.filter((m) => typeof m === "number")
.sort((a, b) => a - b);
const nextOpen = opens.find((m) => m > nowMin);
if (typeof nextOpen === "number") {
return {
status: "closed",
label: `Ouvre à ${formatTimeFromMinutes(nextOpen)}`,
};
}
return { status: "closed", label: "Fermé" };
}
// 5. business hours approximation (Mon-Fri 08:00-18:00)
if (h.businessHours === true) {
const isWeekday = today >= 1 && today <= 5;
const openMin = 8 * 60;
const closeMin = 18 * 60;
const isOpen = isWeekday && nowMin >= openMin && nowMin < closeMin;
return {
status: isOpen ? "open" : "closed",
label: isOpen ? "Heures ouvrables" : "Fermé (heures ouvrables)",
};
}
// 6. night hours approximation (20:00-08:00)
if (h.nightHours === true) {
const openMin = 20 * 60;
const closeMin = 8 * 60;
const isOpen = isWithinSlot(nowMin, openMin, closeMin);
return {
status: isOpen ? "open" : "closed",
label: isOpen ? "Heures de nuit" : "Fermé (heures de nuit)",
};
}
// 7. events
if (h.events === true) {
return { status: "unknown", label: "Selon événements" };
}
// 8. fallback
const notes = typeof h.notes === "string" ? h.notes.trim() : "";
return { status: "unknown", label: notes || "Horaires non renseignés" };
}

View file

@ -0,0 +1,60 @@
import { getDefibAvailability } from "./getDefibAvailability";
function makeLocalDate(y, m, d, hh, mm) {
// Note: uses local time on purpose, because getDefibAvailability relies on
// Date#getDay() / Date#getHours() which are locale/timezone dependent.
return new Date(y, m - 1, d, hh, mm, 0, 0);
}
describe("dae/getDefibAvailability", () => {
test("disponible_24h=1 always open", () => {
const res = getDefibAvailability(null, 1, makeLocalDate(2026, 3, 1, 3, 0));
expect(res).toEqual({ status: "open", label: "24h/24 7j/7" });
});
test("is24h + days includes today => open", () => {
// 2026-03-02 is Monday (ISO=1)
const now = makeLocalDate(2026, 3, 2, 12, 0);
const res = getDefibAvailability(
{ days: [1], slots: null, is24h: true },
0,
now,
);
expect(res.status).toBe("open");
});
test("days excludes today => closed", () => {
// Monday
const now = makeLocalDate(2026, 3, 2, 12, 0);
const res = getDefibAvailability({ days: [2, 3], slots: null }, 0, now);
expect(res.status).toBe("closed");
});
test("slots determine open/closed", () => {
// Monday 09:00
const now = makeLocalDate(2026, 3, 2, 9, 0);
const res = getDefibAvailability(
{
days: [1],
slots: [{ open: "08:00", close: "10:00" }],
},
0,
now,
);
expect(res.status).toBe("open");
});
test("events => unknown", () => {
const now = makeLocalDate(2026, 3, 2, 9, 0);
const res = getDefibAvailability(
{
days: null,
slots: null,
events: true,
},
0,
now,
);
expect(res).toEqual({ status: "unknown", label: "Selon événements" });
});
});

View file

@ -0,0 +1,76 @@
/**
* v1 keyword detection for cardiac / defibrillator-related alert subjects.
*
* Requirements:
* - normalize: lowercase + remove diacritics
* - regex list (no fuzzy lib)
* - pure helper with unit tests
*/
function normalizeSubjectText(subject) {
if (typeof subject !== "string") {
return "";
}
// NFD splits accents into combining marks, then we strip them.
// Using explicit unicode range for broad RN JS compatibility.
return (
subject
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
// Common French ligatures that aren't removed by diacritics stripping
// (e.g. "cœur" => "coeur").
.replace(/œ/g, "oe")
.replace(/æ/g, "ae")
);
}
// NOTE: operate on normalized (diacritics-stripped) text.
const DEFIB_SUGGESTION_REGEXES = [
// Cardiac keywords
/\bcardiaqu\w*\b/, // cardiaque, cardiaques...
/\bcardiac\w*\b/, // cardiac, cardiacs...
/\bcardiqu\w*\b/, // cardique (common typo)
/\bcoeur\b/, // coeur (after normalization also matches cœur)
// Malaise common typos
/\bmalaise\b/,
/\bmailaise\b/,
/\bmallaise\b/,
// Unconsciousness
/\binconscient\w*\b/, // inconscient, inconsciente...
/\bevanoui\w*\b/, // evanoui, evanouie, evanouissement...
// Arrest
/\barret\b/, // arret (after normalization also matches arrêt)
/\barret\s+cardiaqu\w*\b/, // arrêt cardiaque (strong signal)
// Defibrillator
/\bdefibrillat\w*\b/, // defibrillateur, defibrillation...
// CPR / resuscitation
/\breanimat\w*\b/, // reanimation, reanimer...
/\bmassage\s+cardiaqu\w*\b/, // massage cardiaque
// Not breathing
/\bne\s+respire\s+plus\b/,
/\brespire\s+plus\b/,
];
export function subjectSuggestsDefib(subject) {
const text = normalizeSubjectText(subject);
if (!text) {
return false;
}
return DEFIB_SUGGESTION_REGEXES.some((re) => re.test(text));
}
export const __private__ = {
normalizeSubjectText,
DEFIB_SUGGESTION_REGEXES,
};
export default subjectSuggestsDefib;

View file

@ -0,0 +1,40 @@
import { subjectSuggestsDefib } from "./subjectSuggestsDefib";
describe("dae/subjectSuggestsDefib", () => {
test("returns false for non-string input", () => {
expect(subjectSuggestsDefib(null)).toBe(false);
expect(subjectSuggestsDefib(undefined)).toBe(false);
expect(subjectSuggestsDefib(123)).toBe(false);
});
test("matches cardiac keywords (case-insensitive)", () => {
expect(subjectSuggestsDefib("ARRET CARDIAQUE")).toBe(true);
expect(subjectSuggestsDefib("cardiaque")).toBe(true);
expect(subjectSuggestsDefib("cardiac arrest")).toBe(true);
expect(subjectSuggestsDefib("cardique")).toBe(true);
});
test("matches diacritics variants", () => {
expect(subjectSuggestsDefib("Arrêt cardiaque")).toBe(true);
expect(subjectSuggestsDefib("Cœur")).toBe(true);
expect(subjectSuggestsDefib("Évanoui")).toBe(true);
expect(subjectSuggestsDefib("Réanimation")).toBe(true);
expect(subjectSuggestsDefib("Défibrillateur")).toBe(true);
});
test("matches common typos", () => {
expect(subjectSuggestsDefib("mallaise")).toBe(true);
expect(subjectSuggestsDefib("mailaise")).toBe(true);
});
test("matches CPR / breathing phrases", () => {
expect(subjectSuggestsDefib("massage cardiaque en cours")).toBe(true);
expect(subjectSuggestsDefib("ne respire plus")).toBe(true);
expect(subjectSuggestsDefib("il respire plus")).toBe(true);
});
test("does not match unrelated subject", () => {
expect(subjectSuggestsDefib("mal au dos")).toBe(false);
expect(subjectSuggestsDefib("panne de voiture")).toBe(false);
});
});

79
src/utils/geo/corridor.js Normal file
View file

@ -0,0 +1,79 @@
import { point, lineString } from "@turf/helpers";
import nearestPointOnLine from "@turf/nearest-point-on-line";
import distance from "@turf/distance";
const distanceOpts = { units: "meters", method: "geodesic" };
/**
* @typedef {[number, number]} LonLat
*/
/**
* Normalize a {latitude, longitude} object into a Turf-friendly [lon, lat] tuple.
*
* @param {{ latitude: number, longitude: number }} coords
* @returns {LonLat}
*/
export function toLonLat({ latitude, longitude }) {
return [longitude, latitude];
}
/**
* Compute a radius (meters) for a single DB query around the segment midpoint.
* Strategy: radius = segmentLength/2 + corridorMeters.
*
* @param {Object} params
* @param {LonLat} params.userLonLat
* @param {LonLat} params.alertLonLat
* @param {number} params.corridorMeters
* @returns {number}
*/
export function computeCorridorQueryRadiusMeters({
userLonLat,
alertLonLat,
corridorMeters,
}) {
const segmentMeters = distance(
point(userLonLat),
point(alertLonLat),
distanceOpts,
);
return Math.max(0, segmentMeters / 2 + corridorMeters);
}
/**
* Filter defibs to those within a corridor around the useralert segment.
* Corridor definition: distance(point, lineSegment(useralert)) <= corridorMeters.
*
* @template T
* @param {Object} params
* @param {T[]} params.defibs
* @param {LonLat} params.userLonLat
* @param {LonLat} params.alertLonLat
* @param {number} params.corridorMeters
* @returns {T[]}
*/
export function filterDefibsInCorridor({
defibs,
userLonLat,
alertLonLat,
corridorMeters,
}) {
const line = lineString([userLonLat, alertLonLat]);
const filtered = [];
for (const defib of defibs) {
const lon = defib.longitude;
const lat = defib.latitude;
if (typeof lon !== "number" || typeof lat !== "number") continue;
const p = point([lon, lat]);
const snapped = nearestPointOnLine(line, p, distanceOpts);
const distToLine = snapped?.properties?.dist;
if (typeof distToLine === "number" && distToLine <= corridorMeters) {
filtered.push(defib);
}
}
return filtered;
}

View file

@ -0,0 +1,48 @@
import {
toLonLat,
computeCorridorQueryRadiusMeters,
filterDefibsInCorridor,
} from "./corridor";
describe("geo/corridor", () => {
test("toLonLat returns [lon, lat]", () => {
expect(toLonLat({ latitude: 48.1, longitude: 2.2 })).toEqual([2.2, 48.1]);
});
test("computeCorridorQueryRadiusMeters matches segment/2 + corridor", () => {
const user = [0, 0];
const alert = [0, 1];
const corridorMeters = 10_000;
const radius = computeCorridorQueryRadiusMeters({
userLonLat: user,
alertLonLat: alert,
corridorMeters,
});
// 1° latitude is ~111km. Half is ~55.5km, plus corridor.
expect(radius).toBeGreaterThan(60_000);
expect(radius).toBeLessThan(70_000);
});
test("filterDefibsInCorridor keeps points close to the segment", () => {
const userLonLat = [0, 0];
const alertLonLat = [0, 1];
const corridorMeters = 10_000;
const defibs = [
// on the line
{ id: "on", latitude: 0.5, longitude: 0 },
// ~0.1° lon at lat 0.5 is ~11km => outside 10km
{ id: "off", latitude: 0.5, longitude: 0.1 },
];
const filtered = filterDefibsInCorridor({
defibs,
userLonLat,
alertLonLat,
corridorMeters,
});
expect(filtered.map((d) => d.id).sort()).toEqual(["on"]);
});
});

View file

@ -5044,6 +5044,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@op-engineering/op-sqlite@npm:^15.2.5":
version: 15.2.5
resolution: "@op-engineering/op-sqlite@npm:15.2.5"
peerDependencies:
react: "*"
react-native: "*"
checksum: 10/e37163e99b5959fdb93076a929f1b2b40db8bb981c996a6342262105a3f387a2cf01d95bd4944cfe4c59424603054fbeeda3179184619b417cd6094a3759b037
languageName: node
linkType: hard
"@pkgjs/parseargs@npm:^0.11.0": "@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0 version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0" resolution: "@pkgjs/parseargs@npm:0.11.0"
@ -7013,6 +7023,7 @@ __metadata:
"@mapbox/polyline": "npm:^1.2.1" "@mapbox/polyline": "npm:^1.2.1"
"@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23" "@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23"
"@notifee/react-native": "npm:^9.1.8" "@notifee/react-native": "npm:^9.1.8"
"@op-engineering/op-sqlite": "npm:^15.2.5"
"@react-native-async-storage/async-storage": "npm:2.1.2" "@react-native-async-storage/async-storage": "npm:2.1.2"
"@react-native-community/cli": "npm:^18.0.0" "@react-native-community/cli": "npm:^18.0.0"
"@react-native-community/netinfo": "npm:^11.4.1" "@react-native-community/netinfo": "npm:^11.4.1"
@ -7094,6 +7105,7 @@ __metadata:
expo-secure-store: "npm:~14.2.4" expo-secure-store: "npm:~14.2.4"
expo-sensors: "npm:~14.1.4" expo-sensors: "npm:~14.1.4"
expo-splash-screen: "npm:~0.30.10" expo-splash-screen: "npm:~0.30.10"
expo-sqlite: "npm:^55.0.10"
expo-status-bar: "npm:~2.2.3" expo-status-bar: "npm:~2.2.3"
expo-system-ui: "npm:~5.0.11" expo-system-ui: "npm:~5.0.11"
expo-task-manager: "npm:~13.1.6" expo-task-manager: "npm:~13.1.6"
@ -7104,6 +7116,7 @@ __metadata:
google-libphonenumber: "npm:^3.2.32" google-libphonenumber: "npm:^3.2.32"
graphql: "npm:^16.10.0" graphql: "npm:^16.10.0"
graphql-ws: "npm:^6.0.4" graphql-ws: "npm:^6.0.4"
h3-js: "npm:^4.4.0"
hash.js: "npm:^1.1.7" hash.js: "npm:^1.1.7"
husky: "npm:^9.0.11" husky: "npm:^9.0.11"
i18next: "npm:^23.2.10" i18next: "npm:^23.2.10"
@ -7544,6 +7557,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"await-lock@npm:^2.2.2":
version: 2.2.2
resolution: "await-lock@npm:2.2.2"
checksum: 10/feb11f36768a8545879ed2d214b46aae484e6564ffa466af9212d5782897203770795cae01f813de04a46f66c0b8ee6bc690a0c435b04e00cad5a18ef0842e25
languageName: node
linkType: hard
"axe-core@npm:^4.6.2": "axe-core@npm:^4.6.2":
version: 4.7.2 version: 4.7.2
resolution: "axe-core@npm:4.7.2" resolution: "axe-core@npm:4.7.2"
@ -10889,6 +10909,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expo-sqlite@npm:^55.0.10":
version: 55.0.10
resolution: "expo-sqlite@npm:55.0.10"
dependencies:
await-lock: "npm:^2.2.2"
peerDependencies:
expo: "*"
react: "*"
react-native: "*"
checksum: 10/abdc55a33d58bf357d895864756f6196c1951dae9013d9ceb2ac2b2051686c916ef431474c9004369bb8e315ef3ce030a8030abe193c3d436a56f4a40ae0584d
languageName: node
linkType: hard
"expo-status-bar@npm:~2.2.3": "expo-status-bar@npm:~2.2.3":
version: 2.2.3 version: 2.2.3
resolution: "expo-status-bar@npm:2.2.3" resolution: "expo-status-bar@npm:2.2.3"
@ -11960,6 +11993,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"h3-js@npm:^4.4.0":
version: 4.4.0
resolution: "h3-js@npm:4.4.0"
checksum: 10/6db6888f143ed6a1e3ca10506f15c35679afd181e24b71bcdc90259206e3f02637bab38e2a35382d51f17151ea193dfab69c01ff3e31bf0e86abfb1957692576
languageName: node
linkType: hard
"handlebars@npm:^4.7.7": "handlebars@npm:^4.7.7":
version: 4.7.8 version: 4.7.8
resolution: "handlebars@npm:4.7.8" resolution: "handlebars@npm:4.7.8"