feat: first darft mvp
This commit is contained in:
parent
751dc4426c
commit
c12e4cfcde
54 changed files with 4825 additions and 70 deletions
|
|
@ -6,7 +6,21 @@
|
|||
"Bash(node csv-to-sqlite.mjs --input ../.data/geodae.csv --output ../src/assets/db/geodae.db)",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(*\\) FROM defibs; SELECT * FROM defibs LIMIT 3; SELECT count\\(DISTINCT h3\\) FROM defibs;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT id, nom, latitude, longitude FROM defibs WHERE h3 = ''881fb542d3fffff'' LIMIT 5;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT h3, count\\(*\\) as cnt FROM defibs GROUP BY h3 ORDER BY cnt DESC LIMIT 5;\")"
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT h3, count\\(*\\) as cnt FROM defibs GROUP BY h3 ORDER BY cnt DESC LIMIT 5;\")",
|
||||
"Bash(pnpm --version)",
|
||||
"Bash(find /home/jo/lab/alerte-secours/apps/as-app/src/scenes/AlertCur* -type f -name \"*.js\" -o -name \"*.jsx\")",
|
||||
"Bash(find /home/jo/lab/alerte-secours/apps/as-app/src/scenes/SendAlert* -type f -name \"*.js\" -o -name \"*.jsx\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires != '''' ORDER BY horaires LIMIT 80;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires <> '''' ORDER BY horaires LIMIT 80;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(*\\) FROM defibs WHERE horaires <> ''''; SELECT count\\(*\\) FROM defibs WHERE horaires = '''';\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(DISTINCT horaires\\) FROM defibs WHERE horaires <> '''';\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, count\\(*\\) as cnt FROM defibs WHERE horaires <> '''' GROUP BY horaires ORDER BY cnt DESC LIMIT 30;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires <> '''' ORDER BY horaires LIMIT 80 OFFSET 80;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''Lun%'' AND horaires <> ''Lun-Ven heures ouvrables'' AND horaires <> ''Lun-Sam heures ouvrables'' ORDER BY horaires LIMIT 50;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''%événements%'' OR horaires LIKE ''%evene%'' ORDER BY horaires LIMIT 20;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, count\\(*\\) as cnt FROM defibs WHERE horaires <> '''' GROUP BY horaires ORDER BY cnt DESC LIMIT 50;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''%heures de nuit%'' LIMIT 10;\")",
|
||||
"Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, horaires_std FROM defibs WHERE horaires = ''Lun-Ven heures ouvrables'' LIMIT 1;\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ module.exports = {
|
|||
root: true,
|
||||
env: {
|
||||
"react-native/react-native": true,
|
||||
jest: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:prettier/recommended",
|
||||
|
|
@ -37,6 +38,12 @@ module.exports = {
|
|||
},
|
||||
"import/ignore": ["react-native"],
|
||||
"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: {},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -147,6 +147,13 @@ android {
|
|||
}
|
||||
}
|
||||
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 {
|
||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,35 @@
|
|||
|
||||
## Recently Completed Features
|
||||
|
||||
### DAE v1 (Tasks 1–9) — 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
|
||||
1. Background Notification Fixes:
|
||||
- ✅ Added required Android permissions
|
||||
|
|
|
|||
12
jest.config.js
Normal file
12
jest.config.js
Normal 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",
|
||||
},
|
||||
};
|
||||
|
|
@ -88,6 +88,7 @@
|
|||
"@mapbox/polyline": "^1.2.1",
|
||||
"@maplibre/maplibre-react-native": "10.0.0-alpha.23",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@op-engineering/op-sqlite": "^15.2.5",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-community/slider": "4.5.6",
|
||||
|
|
@ -129,6 +130,7 @@
|
|||
"expo-contacts": "~14.2.5",
|
||||
"expo-dev-client": "~5.2.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-file-system": "~18.1.11",
|
||||
"expo-gradle-ext-vars": "^0.1.1",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-linking": "~7.1.7",
|
||||
|
|
@ -138,6 +140,7 @@
|
|||
"expo-secure-store": "~14.2.4",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-splash-screen": "~0.30.10",
|
||||
"expo-sqlite": "^55.0.10",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.11",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
|
|
@ -148,6 +151,7 @@
|
|||
"google-libphonenumber": "^3.2.32",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-ws": "^6.0.4",
|
||||
"h3-js": "^4.4.0",
|
||||
"hash.js": "^1.1.7",
|
||||
"i18next": "^23.2.10",
|
||||
"immer": "^10.0.2",
|
||||
|
|
@ -285,4 +289,4 @@
|
|||
}
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
313
plans/PLAN_DAE-claude.md
Normal file
313
plans/PLAN_DAE-claude.md
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
# DAE (Défibrillateur) Feature - Implementation Plan
|
||||
|
||||
## Common Context Prefix (include at the top of every task prompt)
|
||||
|
||||
```
|
||||
## Project Context - DAE Feature Integration
|
||||
|
||||
You are working on the React Native app "Alerte Secours" at `/home/jo/lab/alerte-secours/apps/as-app/`.
|
||||
Branch: `feat/dae`
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
- **Framework:** React Native with Expo v53
|
||||
- **Navigation:** React Navigation v6 (Stack → Drawer → Tabs)
|
||||
- `RootStack.js` (Stack) → `Drawer.js` (Drawer) → `Main/index.js` (Material Top Tabs)
|
||||
- Each drawer screen can be a standalone scene or use a Bottom Tab Navigator
|
||||
- **State Management:** Zustand v5 with custom atomic wrapper (`~/lib/atomic-zustand`)
|
||||
- Stores defined in `~/stores/index.js`, each atom in `~/stores/{name}.js`
|
||||
- Usage: `const { foo } = useFooState(["foo"])` or `fooActions.doSomething()`
|
||||
- **Maps:** MapLibre React Native (`@maplibre/maplibre-react-native`)
|
||||
- Map components in `~/containers/Map/` (Camera, MapView, FeatureImages, ShapePoints, etc.)
|
||||
- Route calculation via OSRM in `~/scenes/AlertCurMap/routing.js`
|
||||
- Clustering via supercluster in `useFeatures` hooks
|
||||
- **Styling:** React Native Paper v5 + custom `createStyles()`/`useStyles()` from `~/theme`
|
||||
- **Icons:** `@expo/vector-icons` (MaterialCommunityIcons, MaterialIcons, Entypo)
|
||||
- **Forms:** React Hook Form
|
||||
- **GraphQL:** Apollo Client v3
|
||||
- **Module alias:** `~` maps to `src/`
|
||||
|
||||
### Key Existing Patterns
|
||||
|
||||
**Drawer Screen Registration** (`src/navigation/Drawer.js`):
|
||||
- Screens are `<Drawer.Screen name="..." component={...} options={{drawerLabel, drawerIcon, ...}} />`
|
||||
- Hidden screens use `options={{ hidden: true }}`
|
||||
- Menu item sections are organized by index ranges in `DrawerItemList.js` (indices 0-4 = "Alerter", 5-8 = "Mon compte", 9+ = "Infos pratiques")
|
||||
|
||||
**Header Titles** (`src/navigation/RootStack.js`):
|
||||
- `getHeaderTitle(route)` switch-case maps route names to display titles
|
||||
|
||||
**Bottom Tab Navigator Pattern** (reference: `src/scenes/AlertAgg/index.js`):
|
||||
- `createBottomTabNavigator()` with `Tab.Screen` entries
|
||||
- `screenOptions={{ headerShown: false, tabBarLabelStyle: { fontSize: 13 }, lazy: true, unmountOnBlur: true }}`
|
||||
- Tab icons use MaterialCommunityIcons
|
||||
|
||||
**Action Buttons Pattern** (reference: `src/scenes/AlertCurOverview/index.js`):
|
||||
- `<Button mode="contained" icon={() => <MaterialCommunityIcons ... />} style={[styles.actionButton, ...]} onPress={handler}>`
|
||||
|
||||
**Alert Map Navigation Pattern** (reference: `src/scenes/AlertCurMap/index.js`):
|
||||
- Uses route calculation via OSRM, user location tracking, polyline rendering
|
||||
- Camera management via `useMapInit()`, features via `useFeatures()`
|
||||
- Control buttons, routing steps drawer, profile selection (car/bike/pedestrian)
|
||||
|
||||
**Navigation to nested screens:**
|
||||
```js
|
||||
navigation.navigate("Main", {
|
||||
screen: "AlertCur",
|
||||
params: { screen: "AlertCurTab", params: { screen: "AlertCurMap" } },
|
||||
});
|
||||
```
|
||||
|
||||
### DAE Data Layer (already implemented)
|
||||
|
||||
- `src/data/getNearbyDefibs.js` - Main API: `getNearbyDefibs({ lat, lon, radiusMeters, limit, disponible24hOnly })`
|
||||
- `src/db/defibsRepo.js` - SQLite queries using H3 spatial index
|
||||
- `src/db/openDb.js` - SQLite database initialization
|
||||
- `src/assets/db/geodae.db` - Pre-built SQLite database with defibrillator data
|
||||
|
||||
**DefibResult type:**
|
||||
```js
|
||||
{ id, latitude, longitude, nom, adresse, horaires, acces, disponible_24h, distanceMeters }
|
||||
```
|
||||
|
||||
### Files You Should Reference for Patterns
|
||||
- Drawer registration: `src/navigation/Drawer.js`
|
||||
- Menu sections: `src/navigation/DrawerNav/DrawerItemList.js`
|
||||
- Header titles: `src/navigation/RootStack.js` (getHeaderTitle)
|
||||
- Bottom tabs: `src/scenes/AlertAgg/index.js`
|
||||
- List view: `src/scenes/AlertAggList/index.js`
|
||||
- Map view: `src/scenes/AlertAggMap/index.js`
|
||||
- Alert navigation map: `src/scenes/AlertCurMap/index.js`
|
||||
- Alert situation view: `src/scenes/AlertCurOverview/index.js`
|
||||
- Alert posting: `src/scenes/SendAlertConfirm/useOnSubmit.js`
|
||||
- Store definition: `src/stores/index.js`
|
||||
- Store atom example: `src/stores/alert.js`
|
||||
- Modal pattern: uses `Portal` + `Modal` from `react-native-paper`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create the DAE Zustand Store
|
||||
|
||||
**Goal:** Create a Zustand store atom for DAE state management.
|
||||
|
||||
**Create `src/stores/dae.js`:**
|
||||
- State:
|
||||
- `daeList: []` — array of DefibResult objects currently loaded
|
||||
- `daeDisplayEnabled: false` — whether DAE markers should show on alert map
|
||||
- `daeAlertFilter: null` — when set to `{ userCoords, alertCoords }`, filters DAE within 10km of the axis between user and alert
|
||||
- `selectedDae: null` — currently selected DAE item (for DAEItem view)
|
||||
- Actions:
|
||||
- `setDaeList(list)`
|
||||
- `setDaeDisplayEnabled(enabled)`
|
||||
- `setDaeAlertFilter(filter)`
|
||||
- `setSelectedDae(dae)`
|
||||
- `reset()`
|
||||
|
||||
**Update `src/stores/index.js`:**
|
||||
- Import the new `dae` atom
|
||||
- Add it to `createStore()` call
|
||||
- Export `useDaeState`, `getDaeState`, `subscribeDaeState`, `daeActions`
|
||||
|
||||
**Reference:** Follow the exact pattern of `src/stores/alert.js` for atom structure.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create DAEItem Scene (Single DAE Detail View)
|
||||
|
||||
**Goal:** Create the DAEItem view that shows details of a single defibrillator with bottom tabs (Infos + Map).
|
||||
|
||||
**Create `src/scenes/DAEItem/index.js`:**
|
||||
- Bottom Tab Navigator with 2 tabs:
|
||||
- `DAEItemInfos` (left, default) — Info tab
|
||||
- `DAEItemMap` (right) — Map tab with navigation to DAE
|
||||
- Follow the `AlertAgg/index.js` tab pattern exactly
|
||||
- Tab icons: `information-outline` for Infos, `map-marker-outline` for Map
|
||||
|
||||
**Create `src/scenes/DAEItemInfos/index.js`:**
|
||||
- Display selected DAE info from `useDaeState(["selectedDae"])`
|
||||
- Show: nom, adresse, horaires, acces, disponible_24h, distance
|
||||
- Use a ScrollView with info lines
|
||||
- Style following the app's pattern (createStyles, useStyles)
|
||||
- Include a "Itinéraire" button that switches to the Map tab
|
||||
|
||||
**Create `src/scenes/DAEItemMap/index.js`:**
|
||||
- Map with route to the DAE location (the DAE is the destination)
|
||||
- Mimic `src/scenes/AlertCurMap/index.js` implementation:
|
||||
- Same OSRM route calculation
|
||||
- Same camera management, polyline rendering
|
||||
- Same control buttons, routing steps
|
||||
- Same profile selection (car/bike/pedestrian)
|
||||
- The destination is `{ latitude: dae.latitude, longitude: dae.longitude }` instead of alert location
|
||||
- Use `useDaeState(["selectedDae"])` to get the DAE coordinates
|
||||
- Reuse as much as possible from the existing map containers (`~/containers/Map/*`)
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create DAEList Scene (List + Map of Nearby DAEs)
|
||||
|
||||
**Goal:** Create the DAEList view with bottom tabs showing a list and map of nearby defibrillators.
|
||||
|
||||
**Create `src/scenes/DAEList/index.js`:**
|
||||
- Bottom Tab Navigator with 2 tabs:
|
||||
- `DAEListList` (left, default) — List of DAEs
|
||||
- `DAEListMap` (right) — Map of DAEs
|
||||
- Follow `AlertAgg/index.js` pattern
|
||||
- Tab icons: `format-list-bulleted` for List, `map-marker-outline` for Map
|
||||
|
||||
**Create `src/scenes/DAEListList/index.js`:**
|
||||
- Load DAEs using `getNearbyDefibs()` with user location from `useLocationState`
|
||||
- Parameters: `radiusMeters: 10000` (10km), `limit: 100`
|
||||
- Filter out DAEs not available for current day and hour:
|
||||
- Parse the `horaires` field to determine availability
|
||||
- Keep DAEs where `disponible_24h === 1` (always available)
|
||||
- For others, parse opening hours and check against current day/time
|
||||
- If `horaires` is empty or unparseable, keep the DAE (err on the side of showing)
|
||||
- Sort by `distanceMeters` ascending (nearest first)
|
||||
- Display as a ScrollView/FlatList with rows showing:
|
||||
- DAE name (nom), address (adresse), distance (formatted), availability indicator
|
||||
- On press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
|
||||
|
||||
**Create `src/scenes/DAEListMap/index.js`:**
|
||||
- Map showing all nearby DAEs as markers
|
||||
- Mimic `src/scenes/AlertAggMap/index.js` pattern for marker clustering and display
|
||||
- Use DAE-specific marker icon (defibrillator icon)
|
||||
- On marker press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
|
||||
- Camera bounds should fit all visible DAE markers
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Register DAEList and DAEItem in Navigation
|
||||
|
||||
**Goal:** Wire up DAEList and DAEItem screens into the app navigation.
|
||||
|
||||
**Update `src/navigation/Drawer.js`:**
|
||||
- Import DAEList and DAEItem scenes
|
||||
- Add `<Drawer.Screen name="DAEList">` with:
|
||||
- `drawerLabel: "Défibrillateurs"`
|
||||
- `drawerIcon`: use `MaterialCommunityIcons` with name `"heart-pulse"` (or `"medical-bag"`)
|
||||
- `unmountOnBlur: true`
|
||||
- Add `<Drawer.Screen name="DAEItem">` with:
|
||||
- `hidden: true` (not shown in drawer menu, navigated to programmatically)
|
||||
- `unmountOnBlur: true`
|
||||
- Place `DAEList` in the "Infos pratiques" section (after existing items, before About or Sheets)
|
||||
|
||||
**Update `src/navigation/DrawerNav/DrawerItemList.js`:**
|
||||
- Adjust the index constants (`index1`, `index2`) if needed to account for the new drawer screen
|
||||
- Ensure DAEList appears in the "Infos pratiques" section
|
||||
|
||||
**Update `src/navigation/RootStack.js`:**
|
||||
- Add cases to `getHeaderTitle()`:
|
||||
- `case "DAEList": return "Défibrillateurs";`
|
||||
- `case "DAEItem": return "Défibrillateur";`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add "Afficher les défibrillateurs" Button in AlertCurOverview
|
||||
|
||||
**Goal:** Add a button in the Alert Situation view to enable DAE display and navigate to the alert map.
|
||||
|
||||
**Update `src/scenes/AlertCurOverview/index.js`:**
|
||||
- Import `daeActions` from `~/stores`
|
||||
- Import `getNearbyDefibs` from `~/data/getNearbyDefibs`
|
||||
- Import `useLocationState` from `~/stores` (or use `getCurrentLocation`)
|
||||
- Add a new action button "Afficher les défibrillateurs" in the `containerActions` section:
|
||||
- Position it after the "Je viens vous aider" / "Coming help" button area (visible for both sender and receiver, when alert is open)
|
||||
- Icon: `MaterialCommunityIcons` name `"heart-pulse"`
|
||||
- On press:
|
||||
1. Get user coords (from store or `getCurrentLocation()`)
|
||||
2. Get alert coords from `alert.location.coordinates`
|
||||
3. Call `daeActions.setDaeDisplayEnabled(true)`
|
||||
4. Call `daeActions.setDaeAlertFilter({ userCoords: { latitude, longitude }, alertCoords: { latitude: alertLat, longitude: alertLon } })`
|
||||
5. Load DAEs: call `getNearbyDefibs()` with a radius covering the 10km corridor along the axis between user and alert positions, store results via `daeActions.setDaeList(results)`
|
||||
6. Navigate to AlertCurMap: `navigation.navigate("Main", { screen: "AlertCur", params: { screen: "AlertCurTab", params: { screen: "AlertCurMap" } } })`
|
||||
- Show this button for all users (sender and receiver) when the alert is open
|
||||
- Follow the exact same Button pattern as the other action buttons in this file
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add DAE Markers to AlertCurMap
|
||||
|
||||
**Goal:** Display defibrillator markers on the alert navigation map when enabled.
|
||||
|
||||
**Update `src/scenes/AlertCurMap/index.js`:**
|
||||
- Import `useDaeState` from `~/stores`
|
||||
- Read `daeDisplayEnabled` and `daeList` from the DAE store
|
||||
- When `daeDisplayEnabled` is true, render DAE markers on the map:
|
||||
- Use `Maplibre.PointAnnotation` or `Maplibre.MarkerView` for each DAE
|
||||
- Use a distinct icon/color for DAE markers (different from alert markers) — green with heart-pulse or defibrillator symbol
|
||||
- Show DAE name in a callout/tooltip on marker press
|
||||
- On marker press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
|
||||
|
||||
**Alternative approach (if using the existing FeatureImages/clustering pattern):**
|
||||
- Create DAE-specific features array from `daeList`
|
||||
- Convert each DAE to a GeoJSON Feature with `Point` geometry
|
||||
- Add a separate ShapeSource + SymbolLayer for DAE features
|
||||
- Use a defibrillator icon image (add to `FeatureImages` or create a DAE-specific one)
|
||||
|
||||
**Important:** DAE markers must coexist with the existing alert marker and route display without interfering.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Cardiac Keyword Detection Modal on Alert Posting
|
||||
|
||||
**Goal:** When posting an alert, detect cardiac-related keywords in the subject and show a modal suggesting to search for a defibrillator. The modal must work offline and persist even during navigation.
|
||||
|
||||
**Create `src/components/DAEModal/index.js`:**
|
||||
- A modal component using `Portal` + `Modal` from `react-native-paper`
|
||||
- Props: `visible`, `onDismiss`, `onSearchDAE`
|
||||
- Content:
|
||||
- Title: "Défibrillateur" or relevant header
|
||||
- Brief message about defibrillators nearby
|
||||
- Two buttons:
|
||||
- "Chercher un défibrillateur" (primary, calls `onSearchDAE`)
|
||||
- "Non merci" (secondary, calls `onDismiss`)
|
||||
- The modal must render at a high level in the component tree so it persists across navigations
|
||||
|
||||
**Create `src/utils/string/detectCardiacKeywords.js`:**
|
||||
- Export a function `detectCardiacKeywords(text)` that returns `true` if the text contains cardiac-related keywords
|
||||
- Keywords to match (case-insensitive, with common typos):
|
||||
- "cardiaque", "cardiac", "cardique" (typo)
|
||||
- "coeur", "cœur"
|
||||
- "malaise", "mailaise" (typo), "mallaise" (typo)
|
||||
- "inconscient", "inconsciente"
|
||||
- "évanoui", "évanouie", "evanouie", "evanoui", "évanouis" (typo variant)
|
||||
- "arrêt", "arret" (in context of "arrêt cardiaque")
|
||||
- "défibrillateur", "defibrillateur"
|
||||
- "réanimation", "reanimation"
|
||||
- "massage cardiaque"
|
||||
- "ne respire plus", "respire plus"
|
||||
- Use a regex approach for fuzzy matching
|
||||
|
||||
**Update `src/scenes/SendAlertConfirm/useOnSubmit.js`:**
|
||||
- The keyword detection needs to happen at submission time
|
||||
- However, the modal must show *during* the posting flow (even offline)
|
||||
- Approach:
|
||||
- Instead of modifying `useOnSubmit` directly (since the modal must persist across navigation), manage the modal state via the DAE store or a dedicated state
|
||||
- Add `showDaeModal: false` to the DAE store, and `daeActions.setShowDaeModal(bool)`
|
||||
- In `useOnSubmit.js`, after creating `alertSendAlertInput` but before or right after the mutation call, check `detectCardiacKeywords(subject)`. If true, call `daeActions.setShowDaeModal(true)`
|
||||
- The modal keeps showing even as navigation happens (because it's rendered at app root level via Portal)
|
||||
|
||||
**Mount the DAEModal at app level:**
|
||||
- Update `src/app/AppRoot.js` or `src/layout/Layout.js` to include the `DAEModal` component
|
||||
- The modal reads `showDaeModal` from `useDaeState`
|
||||
- "Chercher un défibrillateur" → `daeActions.setShowDaeModal(false)` + navigate to `DAEList`
|
||||
- "Non merci" → `daeActions.setShowDaeModal(false)`
|
||||
|
||||
---
|
||||
|
||||
## Task Dependency Order
|
||||
|
||||
```
|
||||
Task 1 (Store) → no dependencies, do first
|
||||
Task 2 (DAEItem) → depends on Task 1
|
||||
Task 3 (DAEList) → depends on Task 1
|
||||
Task 4 (Navigation) → depends on Tasks 2, 3
|
||||
Task 5 (Button in AlertCurOverview) → depends on Tasks 1, 4
|
||||
Task 6 (DAE Markers on AlertCurMap) → depends on Tasks 1, 2, 4
|
||||
Task 7 (Cardiac Modal) → depends on Tasks 1, 3, 4
|
||||
```
|
||||
|
||||
Parallelizable: Tasks 2 and 3 can run in parallel after Task 1.
|
||||
Task 4 should be done after Tasks 2 and 3.
|
||||
Tasks 5, 6, 7 can run in parallel after Task 4.
|
||||
395
plans/PLAN_DAE-gpt.md
Normal file
395
plans/PLAN_DAE-gpt.md
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
# DAE / Defibrillator integration plan (agent-splittable)
|
||||
|
||||
## Common plan prefix (include this at the top of every coding-agent prompt)
|
||||
|
||||
### Product goal
|
||||
|
||||
Integrate defibrillator (DAE) discovery into the app using the embedded SQLite DB query helper [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37). Provide:
|
||||
|
||||
- A new left-drawer link to a new view `DAEList`.
|
||||
- In an active alert `Situation` view, a button `Afficher les défibrillateurs` that:
|
||||
1) enables DAE display around the **10km corridor around the segment** between user location and alert location,
|
||||
2) then navigates to the alert map.
|
||||
- In alert map, render DAE markers; tapping a DAE opens `DAEItem`.
|
||||
- `DAEList` screen with bottom navigation: `Liste` (default) and `Carte`, showing defibs nearest→farthest around the user, **within 10km**.
|
||||
- `DAEItem` screen with bottom navigation: `Infos` (default) and a map/itinerary to reach the selected DAE (mimic alert routing).
|
||||
- During alert posting, if a cardiac-related keyword is detected in the alert subject, show a **persistent modal** (must remain on top even after redirect to the alert view, and must work offline) with two actions:
|
||||
- `Chercher un défibrillateur` → go to `DAEList`
|
||||
- `Non merci` → dismiss
|
||||
|
||||
### v1 decisions already made
|
||||
|
||||
1) Availability filter: **only** use `disponible_24h === 1` for now; no parsing of `horaires` string yet (later iteration).
|
||||
2) Corridor filter: **within 10km of the user↔alert segment** (not union of circles).
|
||||
3) Location permission denied: use last-known location; if none, show an explanatory empty state (no hard block).
|
||||
|
||||
### Known architecture + relevant anchors in codebase
|
||||
|
||||
- Defib query wrapper: [`src/data/getNearbyDefibs.js`](src/data/getNearbyDefibs.js:1) exports [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37) which calls repo H3 query and falls back to bbox on error.
|
||||
- Repo query + distance rank: [`src/db/defibsRepo.js`](src/db/defibsRepo.js:1) provides [`getNearbyDefibs()`](src/db/defibsRepo.js:62) and [`getNearbyDefibsBbox()`](src/db/defibsRepo.js:159).
|
||||
- Embedded DB bootstrap: [`getDb()`](src/db/openDb.js:11) expects a bundled asset `require('../assets/db/geodae.db')` in [`src/db/openDb.js`](src/db/openDb.js:31). **Note:** current repo listing shows `src/assets/db/` empty, so packaging must be validated.
|
||||
- Drawer navigation screens declared in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85).
|
||||
- Root stack only defines `Main` and `ConnectivityError` in [`src/navigation/RootStack.js`](src/navigation/RootStack.js:142). Drawer contains “hidden” stack-like screens (e.g. `SendAlertConfirm`) already.
|
||||
- Alert current tabs: `Situation`, `Messages`, `Carte` in [`src/scenes/AlertCur/Tabs.js`](src/scenes/AlertCur/Tabs.js:25).
|
||||
- Alert map uses MapLibre and has route computation (OSRM) already in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:170).
|
||||
- Map features clustering uses Supercluster in [`useFeatures()`](src/scenes/AlertCurMap/useFeatures.js:8) and map press routing in [`useOnPress()`](src/scenes/AlertCurMap/useOnPress.js:17).
|
||||
- Persistent top-layer UI is implemented elsewhere via `react-native-paper` [`Portal`](src/containers/SmsDisclaimerModel/index.js:37) + [`Modal`](src/containers/SmsDisclaimerModel/index.js:38).
|
||||
- Alert posting flow navigates to `AlertCurOverview` in [`onSubmit()`](src/scenes/SendAlertConfirm/useOnSubmit.js:32) after `alertActions.setNavAlertCur()` in [`src/scenes/SendAlertConfirm/useOnSubmit.js`](src/scenes/SendAlertConfirm/useOnSubmit.js:127).
|
||||
|
||||
### Data model (canonical Defib object for UI)
|
||||
|
||||
Base DB row shape (from repo select) is:
|
||||
|
||||
- `id: string`
|
||||
- `latitude: number`
|
||||
- `longitude: number`
|
||||
- `nom: string`
|
||||
- `adresse: string`
|
||||
- `horaires: string` (unused in v1)
|
||||
- `acces: string`
|
||||
- `disponible_24h: 0|1`
|
||||
- plus computed `distanceMeters: number`
|
||||
|
||||
Represent coordinates consistently as:
|
||||
|
||||
- Map coordinates arrays `[lon, lat]` to match existing map usage (see `alert.location.coordinates` in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:157)).
|
||||
|
||||
### Filtering logic requirements
|
||||
|
||||
**Near-user list (DAEList):**
|
||||
|
||||
- Input: user location (current if available, else last-known)
|
||||
- Filter: distance ≤ 10_000m
|
||||
- Sort: nearest→farthest
|
||||
- Availability: if `disponible_24h === 1` keep; else exclude in v1 (until horaires parsing)
|
||||
|
||||
**Alert-axis corridor overlay (Alert map + Situation button):**
|
||||
|
||||
- Input: user location + alert location
|
||||
- Filter: points within `corridorMeters = 10_000` of the line segment user→alert
|
||||
- Also restrict to a sensible max radius around user to limit query size (see “Query strategy” below)
|
||||
|
||||
Corridor math recommendation:
|
||||
|
||||
- Use existing Turf dependency already present in map stack: `@turf/helpers` [`lineString()`](src/scenes/AlertCurMap/index.js:24), `@turf/nearest-point-on-line` [`nearestPointOnLine()`](src/scenes/AlertCurMap/index.js:25), and a distance function (`geolib` or Turf `distance`).
|
||||
|
||||
### Query strategy (performance + offline)
|
||||
|
||||
Use the existing local SQLite query API (no network) via [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37).
|
||||
|
||||
- For near-user list: query radiusMeters = `10_000`, limit = e.g. 200–500 (tune later).
|
||||
- For corridor overlay:
|
||||
- First query a radius around the **midpoint** or around **user** large enough to include the whole segment + corridor.
|
||||
- Practical v1 approach: compute `segmentLengthMeters` and query radius = `segmentLengthMeters/2 + corridorMeters` around the midpoint.
|
||||
- Then apply corridor filter in JS and cap results to a max marker count (e.g. 200) to keep map responsive.
|
||||
|
||||
### Navigation and UX conventions
|
||||
|
||||
- Drawer items come from `<Drawer.Screen>` options in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:100) and are rendered by [`menuItem()`](src/navigation/DrawerNav/menuItem.js:4). Hidden routes should set `options.hidden = true`.
|
||||
- Bottom tab patterns exist (see alert tabs in [`src/scenes/AlertCur/Tabs.js`](src/scenes/AlertCur/Tabs.js:25)).
|
||||
- For “persistent modal on top even after redirect”, implement modal at a global provider level (within [`LayoutProviders`](src/layout/LayoutProviders.js:30) tree) using `Portal` so it survives navigation.
|
||||
|
||||
---
|
||||
|
||||
## Split tasks (agent-ready prompts)
|
||||
|
||||
Each task below is designed to be handed to a coding agent. Include the **Common plan prefix** above in every prompt.
|
||||
|
||||
### Task 1 — Validate embedded DB asset packaging and repo schema assumptions
|
||||
|
||||
**Objective:** Ensure the bundled SQLite `geodae.db` is present and accessible on-device, and confirm schema columns used by [`defibsRepo`](src/db/defibsRepo.js:120) exist.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- [`initDb()`](src/db/openDb.js:18) copies `require('../assets/db/geodae.db')` to documents.
|
||||
- Current workspace shows `src/assets/db/` empty; find where DB is stored or add it.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- App can open DB without throwing at [`require('../assets/db/geodae.db')`](src/db/openDb.js:31).
|
||||
- Query `SELECT ... FROM defibs` works with columns: `id, latitude, longitude, nom, adresse, horaires, acces, disponible_24h` and `h3`.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- [`src/db/openDb.js`](src/db/openDb.js:1)
|
||||
- DB asset under [`src/assets/db/`](src/assets/db:1)
|
||||
- Possibly Expo config / bundling rules (if needed)
|
||||
|
||||
---
|
||||
|
||||
### Task 2 — Define and implement defib filtering utilities (10km near-user + 10km corridor)
|
||||
|
||||
**Objective:** Create pure utility functions to:
|
||||
|
||||
- compute query radius for corridor overlay
|
||||
- filter a list of defibs to those inside corridor
|
||||
- normalize coordinates and compute distances
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Prefer reusing Turf already used in map stack (see imports in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:24)).
|
||||
- Keep utilities side-effect free and unit-testable.
|
||||
|
||||
**Suggested exports:**
|
||||
|
||||
- `computeCorridorQueryRadiusMeters({ userLonLat, alertLonLat, corridorMeters })`
|
||||
- `filterDefibsInCorridor({ defibs, userLonLat, alertLonLat, corridorMeters })`
|
||||
- `toLonLat({ latitude, longitude })`
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Given synthetic points, corridor filter behaves as “distance to segment ≤ 10km”.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- New: [`src/utils/geo/defibsCorridor.js`](src/utils/geo/defibsCorridor.js:1)
|
||||
- Possibly reuse existing [`haversine`](src/db/defibsRepo.js:5) logic as reference
|
||||
|
||||
---
|
||||
|
||||
### Task 3 — Add a Defibs store (zustand atom) to manage caching + overlay enablement + modal state
|
||||
|
||||
**Objective:** Add centralized state to avoid repeated DB queries and to coordinate UI across screens.
|
||||
|
||||
**State concerns:**
|
||||
|
||||
- cached near-user defibs list
|
||||
- cached corridor defibs for current alert id
|
||||
- flag `showDefibsOnAlertMap` (or `defibsOverlayEnabledByAlertId`)
|
||||
- selected defib id for `DAEItem`
|
||||
- “DAE suggestion modal” visibility (global)
|
||||
|
||||
**Integration points:**
|
||||
|
||||
- Mirror patterns used by alert store in [`createAtom()`](src/stores/alert.js:6).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Other tasks can simply call actions like `defibsActions.loadNearUser()` and `defibsActions.enableCorridorOverlay(alertId)`.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- New: [`src/stores/defibs.js`](src/stores/defibs.js:1)
|
||||
- Update exports/hooks in [`src/stores/index.js`](src/stores/index.js:1)
|
||||
|
||||
---
|
||||
|
||||
### Task 4 — Add navigation routes: `DAEList` in drawer + `DAEItem` as hidden route
|
||||
|
||||
**Objective:** Add new screens to navigation so they can be opened from:
|
||||
|
||||
- left drawer (DAEList)
|
||||
- map marker press (DAEItem)
|
||||
- modal CTA (DAEList)
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Drawer routes are defined in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85).
|
||||
- If `DAEItem` is a detail view, set `options.hidden = true` (see existing hidden screens around [`SendAlertConfirm`](src/navigation/Drawer.js:490)).
|
||||
- Decide where to place the DAE link in drawer sections:
|
||||
- Sections are sliced by indices in [`src/navigation/DrawerNav/DrawerItemList.js`](src/navigation/DrawerNav/DrawerItemList.js:4). Adding a new Drawer.Screen will shift indices; adjust `index1/index2` or reorder screens.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- A new drawer link navigates to `DAEList`.
|
||||
- `DAEItem` route can be navigated to programmatically.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85)
|
||||
- [`src/navigation/DrawerNav/DrawerItemList.js`](src/navigation/DrawerNav/DrawerItemList.js:1)
|
||||
|
||||
---
|
||||
|
||||
### Task 5 — Situation button on active alert: enable DAE overlay and navigate to alert map
|
||||
|
||||
**Objective:** In `Situation` view for current alert, add button `Afficher les défibrillateurs`.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
1) Determine user coords (prefer current; fall back to last-known as in alert map’s `useLocation` usage in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:83)).
|
||||
2) Query defibs from DB with a computed radius around midpoint.
|
||||
3) Filter to corridor (10km).
|
||||
4) Store result + enable overlay flag.
|
||||
5) Navigate to map tab: `Main → AlertCur → AlertCurTab → AlertCurMap` (same pattern used in [`AlertCurOverview`](src/scenes/AlertCurOverview/index.js:276)).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Button exists only when alert has coordinates.
|
||||
- After tap, map opens and defib markers appear.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- [`src/scenes/AlertCurOverview/index.js`](src/scenes/AlertCurOverview/index.js:61)
|
||||
- Store from Task 3
|
||||
- Utilities from Task 2
|
||||
|
||||
---
|
||||
|
||||
### Task 6 — Alert map: render DAE markers and open `DAEItem` on tap
|
||||
|
||||
**Objective:** Display defib markers as a separate feature layer on alert map.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Existing feature system creates a GeoJSON FeatureCollection for alerts in [`useFeatures()`](src/scenes/AlertCurMap/useFeatures.js:42).
|
||||
- Add DAE features when overlay is enabled.
|
||||
- Add a new marker icon to map images by extending [`FeatureImages`](src/containers/Map/FeatureImages.js:13).
|
||||
- Update press handler in [`useOnPress()`](src/scenes/AlertCurMap/useOnPress.js:17) to recognize `properties.defib` and navigate to `DAEItem`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Markers appear/disappear based on overlay flag.
|
||||
- Tapping a marker navigates to `DAEItem`.
|
||||
- Clustering behavior is acceptable (either include in cluster or keep separate; v1 can skip clustering by rendering as a separate layer).
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- [`src/scenes/AlertCurMap/useFeatures.js`](src/scenes/AlertCurMap/useFeatures.js:8)
|
||||
- [`src/scenes/AlertCurMap/useOnPress.js`](src/scenes/AlertCurMap/useOnPress.js:17)
|
||||
- [`src/containers/Map/FeatureImages.js`](src/containers/Map/FeatureImages.js:1)
|
||||
|
||||
---
|
||||
|
||||
### Task 7 — Implement `DAEList` screen with bottom tabs: List (default) + Map
|
||||
|
||||
**Objective:** Create `DAEList` scene with bottom navigation and two tabs:
|
||||
|
||||
- `Liste`: list view nearest→farthest
|
||||
- `Carte`: map view of same defibs
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Load near-user defibs within 10km using [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37).
|
||||
- Filter by availability: keep `disponible_24h === 1` only.
|
||||
- If no location available, show empty state with explanation.
|
||||
- Tapping list item navigates to `DAEItem`.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Follow bottom tab pattern from [`createBottomTabNavigator()`](src/scenes/AlertCur/Tabs.js:3).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Drawer link opens `DAEList`.
|
||||
- List is sorted by `distanceMeters`.
|
||||
- Map tab renders markers.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- New: [`src/scenes/DAEList/index.js`](src/scenes/DAEList/index.js:1)
|
||||
- New: [`src/scenes/DAEList/Tabs.js`](src/scenes/DAEList/Tabs.js:1)
|
||||
- Possibly shared list row component
|
||||
|
||||
---
|
||||
|
||||
### Task 8 — Implement `DAEItem` screen with bottom tabs: Infos (default) + Go-to map (itinerary)
|
||||
|
||||
**Objective:** Create `DAEItem` detail view for a selected defib.
|
||||
|
||||
**Tabs:**
|
||||
|
||||
- `Infos`: name, address, access, availability badge
|
||||
- `Carte`: show route from user to DAE, mimicking alert routing implementation in [`AlertCurMap`](src/scenes/AlertCurMap/index.js:170)
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Reuse route calculation patterns (OSRM URL building, polyline decode, step list components) from alert map.
|
||||
- Route target is defib coordinates instead of alert coordinates.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- From any entrypoint (alert map marker or list), `DAEItem` shows correct defib.
|
||||
- Itinerary works when online; offline behavior is a clear message (route unavailable offline).
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- New: [`src/scenes/DAEItem/index.js`](src/scenes/DAEItem/index.js:1)
|
||||
- New: [`src/scenes/DAEItem/Tabs.js`](src/scenes/DAEItem/Tabs.js:1)
|
||||
- New: [`src/scenes/DAEItem/Map.js`](src/scenes/DAEItem/Map.js:1)
|
||||
|
||||
---
|
||||
|
||||
### Task 9 — Keyword detection while posting an alert + persistent DAE suggestion modal
|
||||
|
||||
**Objective:** Detect cardiac-related terms during alert posting and display a global modal that remains visible even after navigation.
|
||||
|
||||
**Detection requirements:**
|
||||
|
||||
- Keywords include: `cardiaque`, `cardiac` (typos), `coeur`, `malaise`, `inconscient`, `évanoui` etc.
|
||||
- Should run locally/offline.
|
||||
|
||||
**Implementation approach (recommended):**
|
||||
|
||||
- Use fuzzy matching with `Fuse` (already used in [`findAlertTitle()`](src/finders/alertTitle.js:50)) or implement a lightweight normalization + substring/levenshtein.
|
||||
- Trigger detection in the confirm submit flow, before/around navigation in [`onSubmit()`](src/scenes/SendAlertConfirm/useOnSubmit.js:32).
|
||||
- Render modal at app root using `react-native-paper` [`Portal`](src/containers/SmsDisclaimerModel/index.js:37) inside [`LayoutProviders`](src/layout/LayoutProviders.js:51) so it persists across navigation.
|
||||
|
||||
**Modal UI:**
|
||||
|
||||
- Title/text: explain quickly why looking for a DAE matters.
|
||||
- Two buttons:
|
||||
- `Chercher un défibrillateur` → navigate to `DAEList`
|
||||
- `Non merci` → dismiss
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Modal shows for matching terms even with no internet.
|
||||
- Modal stays visible after redirect to current alert view.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- [`src/scenes/SendAlertConfirm/useOnSubmit.js`](src/scenes/SendAlertConfirm/useOnSubmit.js:1)
|
||||
- New: [`src/containers/DAESuggestModal/index.js`](src/containers/DAESuggestModal/index.js:1)
|
||||
- [`src/layout/LayoutProviders.js`](src/layout/LayoutProviders.js:30)
|
||||
- Store from Task 3
|
||||
|
||||
---
|
||||
|
||||
### Task 10 — Verification checklist (manual) + minimal automated coverage
|
||||
|
||||
**Objective:** Provide a deterministic checklist and (where feasible) simple automated tests.
|
||||
|
||||
**Manual verification checklist:**
|
||||
|
||||
1) **Drawer**: DAE link visible, opens list.
|
||||
2) **DAEList**:
|
||||
- permission granted → list populated, sorted
|
||||
- permission denied + last-known available → list uses last-known
|
||||
- permission denied + no last-known → empty state
|
||||
3) **Alert Situation button**: enables overlay and opens alert map.
|
||||
4) **Alert map**: DAE markers render; tap → DAEItem.
|
||||
5) **DAEItem routing**: online route works; offline shows message.
|
||||
6) **Keyword modal**:
|
||||
- trigger term in subject → modal shows
|
||||
- redirect to alert occurs and modal remains on top
|
||||
- CTA navigates to DAEList
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- Checklist documented and reproducible.
|
||||
|
||||
**Likely files touched:**
|
||||
|
||||
- New: [`plans/DAE-manual-test-checklist.md`](plans/DAE-manual-test-checklist.md:1)
|
||||
- Optional: e2e tests under [`e2e/`](e2e:1)
|
||||
|
||||
---
|
||||
|
||||
## Mermaid overview
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User posts alert] --> B[Keyword detection]
|
||||
B -->|match| M[Show persistent DAE modal]
|
||||
B -->|no match| C[Navigate to AlertCur Situation]
|
||||
M --> C
|
||||
M -->|Chercher un defibrillateur| L[DAEList]
|
||||
M -->|Non merci| C
|
||||
C --> S[Button Afficher les defibrillateurs]
|
||||
S --> E[Enable overlay in store]
|
||||
E --> MAP[AlertCurMap]
|
||||
MAP -->|tap DAE marker| ITEM[DAEItem]
|
||||
L -->|tap list item| ITEM
|
||||
```
|
||||
|
||||
370
plans/PLAN_DAE-merged.md
Normal file
370
plans/PLAN_DAE-merged.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
## Common plan prefix (include at the top of every coding-agent prompt)
|
||||
|
||||
### Product goal (v1)
|
||||
|
||||
Integrate defibrillator (DAE) discovery into the app using the embedded SQLite DB query helper `getNearbyDefibs()`.
|
||||
|
||||
Deliver:
|
||||
|
||||
1) A new left-drawer link to a new view `DAEList`.
|
||||
2) In an active alert `Situation` view, a button `Afficher les défibrillateurs` that:
|
||||
- enables DAE display inside a 10km corridor around the segment between user location and alert location
|
||||
- navigates to the alert map tab
|
||||
3) On alert map, render DAE markers; tapping a DAE opens `DAEItem`.
|
||||
4) `DAEList` screen with bottom navigation: `Liste` (default) and `Carte`, showing defibs nearest→farthest around the user, within 10km.
|
||||
5) `DAEItem` screen with bottom navigation: `Infos` (default) and `Carte` (map + itinerary to the selected DAE; reuse alert routing patterns).
|
||||
6) During alert posting, if cardiac-related keywords are detected in the alert subject, show a persistent modal (must remain visible even after navigation, and must work offline) with:
|
||||
- `Chercher un défibrillateur` → go to `DAEList`
|
||||
- `Non merci` → dismiss
|
||||
|
||||
### v1 decisions (explicit)
|
||||
|
||||
1) Availability display: use `horaires_std` (structured JSON) to show open/closed/unknown status and schedule details.
|
||||
- `horaires_std` shape: `{ days: number[]|null, slots: {open,close}[]|null, is24h: boolean, businessHours: boolean, nightHours: boolean, events: boolean, notes: string }`
|
||||
- `days`: ISO 8601 day numbers (1=Mon … 7=Sun), `null` if unknown.
|
||||
- `slots`: `[{open:"HH:MM", close:"HH:MM"}]`, `null` if no specific times.
|
||||
- Availability logic (pure function `getDefibAvailability(horaires_std, disponible_24h)` → `{ status: "open"|"closed"|"unknown", label: string }`):
|
||||
- `disponible_24h === 1` → always `"open"`, label `"24h/24 7j/7"`
|
||||
- `is24h && days includes today` → `"open"`
|
||||
- `days` includes today + current time falls within a `slot` → `"open"`
|
||||
- `days` includes today + no slots + `businessHours` → approximate Mon-Fri 8h-18h → `"open"` or `"closed"`
|
||||
- `days` does not include today → `"closed"`
|
||||
- `events === true` → `"unknown"`, label `"Selon événements"`
|
||||
- Else → `"unknown"`
|
||||
- No hard filter: show all defibs regardless of availability, but display status visually (green/red/grey dot or icon).
|
||||
2) Corridor filter: points are kept if distance to the user→alert line segment is ≤ `corridorMeters = 10_000`.
|
||||
3) Alert-map DAE markers: v1 uses a separate, non-clustered layer (do not mix into alert clusters).
|
||||
4) Location permission denied: use last-known location; if none, show an explanatory empty state (no hard block).
|
||||
5) DB open failure: the app must not crash; DAE UI shows an error empty state and overlay remains disabled.
|
||||
6) Keyword detection: normalized-text + regex list (no fuzzy search library) in v1.
|
||||
|
||||
### Relevant anchors in codebase
|
||||
|
||||
- Defib query wrapper: `src/data/getNearbyDefibs.js` exports `getNearbyDefibs()`.
|
||||
- Repo query: `src/db/defibsRepo.js` — SELECTs and JSON-parses `horaires_std`.
|
||||
- Embedded DB bootstrap: `src/db/openDb.js` requires `../assets/db/geodae.db`.
|
||||
- Drawer navigation: `src/navigation/Drawer.js`.
|
||||
- Drawer sections: `src/navigation/DrawerNav/DrawerItemList.js`.
|
||||
- Header titles: `src/navigation/RootStack.js`.
|
||||
- Alert tabs: `src/scenes/AlertCur/Tabs.js`.
|
||||
- Alert map feature pipeline: `src/scenes/AlertCurMap/useFeatures.js` + `src/scenes/AlertCurMap/useOnPress.js`.
|
||||
- Persistent modal pattern: `src/containers/SmsDisclaimerModel/index.js` uses Paper `Portal` + `Modal`.
|
||||
- Alert posting flow: `src/scenes/SendAlertConfirm/useOnSubmit.js`.
|
||||
- Preprocessing pipeline: `scripts/dae/` (geodae-to-csv.js → csv-to-sqlite.mjs).
|
||||
|
||||
---
|
||||
|
||||
## Split tasks (agent-ready)
|
||||
|
||||
### Task 1 — Validate embedded DB asset packaging and schema assumptions
|
||||
|
||||
Objective: ensure the bundled SQLite `geodae.db` is present and accessible on-device, and confirm expected schema columns exist.
|
||||
|
||||
Notes:
|
||||
|
||||
- `src/db/openDb.js` calls `require('../assets/db/geodae.db')`.
|
||||
- In the current workspace, `src/assets/db/` may be missing; find the DB source and ensure it is bundled for Expo.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- App can open the DB without throwing at the asset require.
|
||||
- Query `SELECT ... FROM defibs` works with columns: `id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h` and `h3`.
|
||||
- `horaires_std` is a JSON string parseable by `JSON.parse()` (already handled in `defibsRepo.js`).
|
||||
- If DB open fails, DAE screens show a non-blocking error empty state and overlay stays disabled.
|
||||
|
||||
---
|
||||
|
||||
### Task 2 — Implement geo utilities for corridor filtering + availability helper (pure, unit-testable)
|
||||
|
||||
Objective: create pure utilities to compute the query radius for the corridor overlay, filter defibs to those inside the corridor, and determine real-time availability from `horaires_std`.
|
||||
|
||||
#### 2a — Geo / corridor utilities
|
||||
|
||||
Suggested exports (e.g. `src/utils/geo/corridor.js`):
|
||||
|
||||
- `toLonLat({ latitude, longitude })`
|
||||
- `computeCorridorQueryRadiusMeters({ userLonLat, alertLonLat, corridorMeters })`
|
||||
- `filterDefibsInCorridor({ defibs, userLonLat, alertLonLat, corridorMeters })`
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Use the existing Turf usage patterns already present in the alert map stack.
|
||||
- Corridor definition: keep point if distance to the line segment ≤ `corridorMeters`.
|
||||
- Query strategy: query around midpoint with radius `segmentLengthMeters / 2 + corridorMeters`, then apply corridor filter in JS and cap markers (e.g. 200) to keep map responsive.
|
||||
|
||||
#### 2b — Availability helper
|
||||
|
||||
Suggested export (e.g. `src/utils/dae/getDefibAvailability.js`):
|
||||
|
||||
- `getDefibAvailability(horaires_std, disponible_24h, now?)` → `{ status: "open"|"closed"|"unknown", label: string }`
|
||||
|
||||
The `horaires_std` object has shape:
|
||||
```
|
||||
{ days: number[]|null, slots: {open,close}[]|null, is24h, businessHours, nightHours, events, notes }
|
||||
```
|
||||
|
||||
Logic (in priority order):
|
||||
1. `disponible_24h === 1` → `"open"`, label `"24h/24 7j/7"`
|
||||
2. `is24h === true` and `days` includes current ISO day → `"open"`, label `"24h/24"`
|
||||
3. `days !== null` and does not include current ISO day → `"closed"`, label day range from `days`
|
||||
4. `days` includes today and `slots` is non-empty → check if current time falls within any slot → `"open"` or `"closed"` with next open/close time as label
|
||||
5. `businessHours === true` (no explicit slots) → approximate Mon-Fri 08:00-18:00 → `"open"` or `"closed"`
|
||||
6. `nightHours === true` → approximate 20:00-08:00 → `"open"` or `"closed"`
|
||||
7. `events === true` → `"unknown"`, label `"Selon événements"`
|
||||
8. Fallback → `"unknown"`, label from `notes` or `"Horaires non renseignés"`
|
||||
|
||||
The `now` parameter (defaults to `new Date()`) enables deterministic testing.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Given synthetic points, corridor filter is correct and deterministic.
|
||||
- Given synthetic `horaires_std` + fixed `now`, availability status is correct.
|
||||
|
||||
---
|
||||
|
||||
### Task 3 — Add a Defibs store (zustand atom) for caching + overlay + selection + modal state
|
||||
|
||||
Objective: add centralized state to avoid repeated DB queries and coordinate UI across screens.
|
||||
|
||||
State:
|
||||
|
||||
- `nearUserDefibs: []`
|
||||
- `corridorDefibs: []` (or keyed by current alert id if needed)
|
||||
- `showDefibsOnAlertMap: false`
|
||||
- `selectedDefib: null`
|
||||
- `showDaeSuggestModal: false`
|
||||
|
||||
Actions (suggested):
|
||||
|
||||
- `loadNearUser({ userLonLat })`
|
||||
- `loadCorridor({ userLonLat, alertLonLat })`
|
||||
- `setShowDefibsOnAlertMap(bool)`
|
||||
- `setSelectedDefib(defib)`
|
||||
- `setShowDaeSuggestModal(bool)`
|
||||
- `reset()`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Other tasks can call actions without duplicating query/filter logic.
|
||||
|
||||
---
|
||||
|
||||
### Task 4 — Create `DAEList` scene with bottom tabs: `Liste` + `Carte`
|
||||
|
||||
Objective: add `DAEList` screen with bottom navigation and two tabs.
|
||||
|
||||
UI specifics (from existing patterns):
|
||||
|
||||
- Follow the app’s bottom-tab pattern.
|
||||
- Tab icons:
|
||||
- Liste: `format-list-bulleted`
|
||||
- Carte: `map-marker-outline`
|
||||
|
||||
Behavior:
|
||||
|
||||
- Get user location (current if possible; else last-known).
|
||||
- Query within 10km using `getNearbyDefibs({ radiusMeters: 10_000 })`.
|
||||
- No hard availability filter: show all defibs. Each row shows real-time availability status via `getDefibAvailability(horaires_std, disponible_24h)`.
|
||||
- Sort by `distanceMeters` ascending.
|
||||
- Empty states:
|
||||
- no location → explain how to enable permission
|
||||
- DB error → explain that DAE database is unavailable
|
||||
|
||||
List row content:
|
||||
- Name (`nom`), distance, address summary.
|
||||
- Availability status indicator: green dot + "Ouvert" / red dot + "Fermé" / grey dot + "Inconnu" (or label from `getDefibAvailability`).
|
||||
- If `horaires_std.notes` is non-empty, show it as a secondary line.
|
||||
|
||||
Carte tab:
|
||||
- Map markers colored by availability status (green/red/grey).
|
||||
|
||||
Interaction:
|
||||
|
||||
- Tap list row or map marker → set `selectedDefib` in store and navigate to `DAEItem`.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Drawer link opens `DAEList` and list is sorted nearest→farthest.
|
||||
- Availability status is displayed per row/marker.
|
||||
|
||||
---
|
||||
|
||||
### Task 5 — Create `DAEItem` scene with bottom tabs: `Infos` + `Carte`
|
||||
|
||||
Objective: create DAE detail view for a selected defib.
|
||||
|
||||
Tab icons:
|
||||
|
||||
- Infos: `information-outline`
|
||||
- Carte: `map-marker-outline`
|
||||
|
||||
Infos tab content:
|
||||
|
||||
- Name (`nom`), address (`adresse`), access (`acces`), distance.
|
||||
- Availability section:
|
||||
- Current status via `getDefibAvailability()` with colored indicator.
|
||||
- Schedule details from `horaires_std`:
|
||||
- If `is24h`: "Disponible 24h/24"
|
||||
- If `days`: show day range (e.g. "Lun-Ven")
|
||||
- If `slots`: show time slots (e.g. "08:00 - 18:00")
|
||||
- If `businessHours`: "Heures ouvrables"
|
||||
- If `nightHours`: "Heures de nuit"
|
||||
- If `events`: "Selon événements"
|
||||
- If `notes`: show notes text
|
||||
- Fallback: show raw `horaires` string if `horaires_std` is null/empty.
|
||||
- Add an `Itinéraire` button that switches to the `Carte` tab.
|
||||
|
||||
Carte tab:
|
||||
|
||||
- Map + itinerary to the defib coordinates.
|
||||
- Reuse alert routing implementation patterns (OSRM fetch etc.).
|
||||
- Offline behavior: show a clear message that routing is unavailable offline.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- From list or alert map marker, `DAEItem` shows the correct selected defib.
|
||||
|
||||
---
|
||||
|
||||
### Task 6 — Navigation wiring: `DAEList` in drawer + `DAEItem` hidden
|
||||
|
||||
Objective: register new screens and keep drawer sections consistent.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Add `<Drawer.Screen name='DAEList'>` with label `Défibrillateurs`.
|
||||
- Drawer icon: `MaterialCommunityIcons` name `heart-pulse`.
|
||||
- Add `<Drawer.Screen name='DAEItem'>` hidden (not shown in menu) and only navigated programmatically.
|
||||
- Adjust section indices in `DrawerItemList.js` if adding a screen shifts boundaries.
|
||||
- Add header title cases for `DAEList` and `DAEItem`.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Drawer link appears under the intended section and opens `DAEList`.
|
||||
- `DAEItem` can be navigated to programmatically.
|
||||
|
||||
---
|
||||
|
||||
### Task 7 — Add Situation button: enable corridor overlay and navigate to alert map
|
||||
|
||||
Objective: in active alert Situation view, add `Afficher les défibrillateurs`.
|
||||
|
||||
UI specifics:
|
||||
|
||||
- Use the app’s existing action button pattern.
|
||||
- Icon: `MaterialCommunityIcons` name `heart-pulse`.
|
||||
- Position: next to/after existing main actions (align with current layout conventions).
|
||||
|
||||
Behavior:
|
||||
|
||||
1) Get user coords.
|
||||
2) Get alert coords.
|
||||
3) Load corridor defibs using midpoint query radius + corridor filter.
|
||||
4) Store results and set overlay enabled.
|
||||
5) Navigate to map tab using existing nested navigation pattern.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Tap leads to alert map and DAE markers appear.
|
||||
|
||||
---
|
||||
|
||||
### Task 8 — Alert map overlay: render DAE markers (separate layer) and open `DAEItem` on tap
|
||||
|
||||
Objective: add a DAE marker layer to the alert map.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Extend the existing feature pipeline to include DAE features when overlay is enabled.
|
||||
- Add DAE icon images to map images (3 variants: green/red/grey for open/closed/unknown).
|
||||
- Compute availability via `getDefibAvailability()` to select marker color.
|
||||
- Update press handling to detect DAE features and navigate to `DAEItem`.
|
||||
- v1 does not cluster DAE markers.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Markers appear/disappear based on overlay flag.
|
||||
- Tapping a marker opens `DAEItem`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9 — Keyword detection during alert posting + persistent DAE suggestion modal
|
||||
|
||||
Objective: detect cardiac-related terms in alert subject and display a global modal that remains visible even after navigation.
|
||||
|
||||
Detection (v1):
|
||||
|
||||
- Normalize text: lowercase + remove diacritics.
|
||||
- Regex list covering common terms and typos, e.g.:
|
||||
- cardiaque, cardiac, cardique
|
||||
- coeur, cœur
|
||||
- malaise, mailaise, mallaise
|
||||
- inconscient
|
||||
- evanoui, évanoui (variants)
|
||||
- arret, arrêt (especially arrêt cardiaque)
|
||||
- defibrillateur, défibrillateur
|
||||
- reanimation, réanimation
|
||||
- massage cardiaque
|
||||
- ne respire plus
|
||||
|
||||
Modal:
|
||||
|
||||
- Use Paper `Portal` + `Modal` mounted high in the tree so it persists across navigations.
|
||||
- CTA:
|
||||
- `Chercher un défibrillateur` → dismiss + navigate to `DAEList`
|
||||
- `Non merci` → dismiss
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Modal shows offline.
|
||||
- Modal remains visible after redirect to current alert view.
|
||||
|
||||
---
|
||||
|
||||
### Task 10 — Verification checklist (manual)
|
||||
|
||||
Objective: provide a deterministic checklist.
|
||||
|
||||
Checklist:
|
||||
|
||||
1) Drawer: link visible, opens list.
|
||||
2) DAEList:
|
||||
- permission granted → list populated and sorted
|
||||
- permission denied + last-known available → list uses last-known
|
||||
- permission denied + no last-known → empty state
|
||||
- DB missing/unavailable → non-blocking error empty state
|
||||
- each row shows availability status (open/closed/unknown) with colored indicator
|
||||
3) Situation button: enables overlay and opens alert map.
|
||||
4) Alert map: DAE markers render with availability-colored icons; tap → DAEItem.
|
||||
5) DAEItem:
|
||||
- Infos tab shows schedule details from `horaires_std` (days, slots, 24h, notes).
|
||||
- Availability status is prominently displayed.
|
||||
- Routing: online route works; offline shows message.
|
||||
6) Keyword modal:
|
||||
- trigger terms → modal shows
|
||||
- modal persists across navigation
|
||||
- CTA navigates to DAEList
|
||||
7) Availability logic:
|
||||
- 24h/24 defib → shows "open" at any time
|
||||
- Defib with Lun-Ven slots → shows "open" during those hours, "closed" on weekends
|
||||
- Defib with `events` flag → shows "unknown" / "Selon événements"
|
||||
|
||||
---
|
||||
|
||||
## Mermaid overview
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User posts alert] --> B[Keyword detection]
|
||||
B -->|match| M[Show persistent DAE modal]
|
||||
B -->|no match| C[Navigate to AlertCur Situation]
|
||||
M --> C
|
||||
M -->|Chercher un defibrillateur| L[DAEList]
|
||||
M -->|Non merci| C
|
||||
C --> S[Button Afficher les defibrillateurs]
|
||||
S --> E[Enable overlay in store]
|
||||
E --> MAP[AlertCurMap]
|
||||
MAP -->|tap DAE marker| ITEM[DAEItem]
|
||||
L -->|tap list item| ITEM
|
||||
```
|
||||
|
||||
|
|
@ -15,7 +15,9 @@ const OUTPUT = join(__dirname, "geodae.csv");
|
|||
function escapeCsv(value) {
|
||||
if (value == null) return "";
|
||||
// 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(",")) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
|
@ -70,13 +72,11 @@ function formatDays(arr) {
|
|||
// Detect consecutive range
|
||||
const indices = sorted.map((d) => DAY_ORDER.indexOf(d));
|
||||
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) {
|
||||
return (
|
||||
DAY_ABBREV[sorted[0]] + "-" + DAY_ABBREV[sorted[sorted.length - 1]]
|
||||
);
|
||||
return DAY_ABBREV[sorted[0]] + "-" + DAY_ABBREV[sorted[sorted.length - 1]];
|
||||
}
|
||||
|
||||
return sorted.map((d) => DAY_ABBREV[d] || d).join(", ");
|
||||
|
|
@ -91,7 +91,7 @@ function formatHours(arr) {
|
|||
(h) =>
|
||||
h &&
|
||||
h.toLowerCase() !== "non renseigné" &&
|
||||
h.toLowerCase() !== "non renseigne"
|
||||
h.toLowerCase() !== "non renseigne",
|
||||
);
|
||||
return cleaned.join(" + ");
|
||||
}
|
||||
|
|
|
|||
BIN
src/assets/img/marker-grey.png
Normal file
BIN
src/assets/img/marker-grey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4 KiB |
73
src/containers/DaeSuggestModal/index.js
Normal file
73
src/containers/DaeSuggestModal/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,7 +25,12 @@ export default function AlertSymbolLayer({ level, isDisabled }) {
|
|||
|
||||
return (
|
||||
<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}
|
||||
id={key}
|
||||
belowLayerID={belowLayerID}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Maplibre from "@maplibre/maplibre-react-native";
|
|||
import markerRed from "~/assets/img/marker-red.png";
|
||||
import markerYellow from "~/assets/img/marker-yellow.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 markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
|
||||
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
|
||||
|
|
@ -14,6 +15,7 @@ const images = {
|
|||
red: markerRed,
|
||||
yellow: markerYellow,
|
||||
green: markerGreen,
|
||||
grey: markerGrey,
|
||||
redDisabled: markerRedDisabled,
|
||||
yellowDisabled: markerYellowDisabled,
|
||||
greenDisabled: markerGreenDisabled,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ const iconStyle = {
|
|||
iconSize: 0.5,
|
||||
};
|
||||
|
||||
const defibIconStyle = {
|
||||
...iconStyle,
|
||||
iconAllowOverlap: true,
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ theme: { colors } }) => ({
|
||||
clusterCount: {
|
||||
textField: "{point_count_abbreviated}",
|
||||
|
|
@ -58,6 +63,15 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
|
|||
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}
|
||||
</Maplibre.ShapeSource>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,16 @@ export default async function getNearbyDefibs({
|
|||
});
|
||||
} catch (err) {
|
||||
// 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({
|
||||
lat,
|
||||
lon,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// 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";
|
||||
|
||||
// H3 average edge lengths in meters per resolution (0..15).
|
||||
const H3_EDGE_M = [
|
||||
1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24,
|
||||
9, 3, 1, 0.5,
|
||||
1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24, 9,
|
||||
3, 1, 0.5,
|
||||
];
|
||||
|
||||
const H3_RES = 8;
|
||||
|
|
@ -29,8 +29,7 @@ function bboxClause(lat, lon, radiusMeters) {
|
|||
// 1 degree longitude shrinks with cos(lat)
|
||||
const dLon = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180));
|
||||
return {
|
||||
clause:
|
||||
"latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
|
||||
clause: "latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
|
||||
params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon],
|
||||
};
|
||||
}
|
||||
|
|
@ -75,11 +74,22 @@ export async function getNearbyDefibs({
|
|||
disponible24hOnly = 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);
|
||||
|
||||
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
|
||||
|
|
@ -89,7 +99,15 @@ export async function getNearbyDefibs({
|
|||
}
|
||||
|
||||
// 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 = [];
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -180,7 +204,10 @@ export async function getNearbyDefibsBbox({
|
|||
limit,
|
||||
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);
|
||||
|
||||
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h
|
||||
|
|
|
|||
123
src/db/ensureEmbeddedDb.js
Normal file
123
src/db/ensureEmbeddedDb.js
Normal 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,
|
||||
};
|
||||
98
src/db/ensureEmbeddedDb.test.js
Normal file
98
src/db/ensureEmbeddedDb.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
350
src/db/openDb.js
350
src/db/openDb.js
|
|
@ -1,41 +1,337 @@
|
|||
// Open the pre-built geodae SQLite database (Expo variant).
|
||||
// Requires: expo-sqlite, expo-file-system, expo-asset
|
||||
import * as SQLite from "expo-sqlite";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { Asset } from "expo-asset";
|
||||
// Open the pre-built geodae SQLite database.
|
||||
//
|
||||
// IMPORTANT: This module must not crash at load time when a native SQLite
|
||||
// backend is missing (Hermes: "Cannot find native module 'ExpoSQLite'").
|
||||
//
|
||||
// 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";
|
||||
|
||||
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() {
|
||||
if (!_dbPromise) {
|
||||
_dbPromise = initDb();
|
||||
_dbPromise = getDbImpl();
|
||||
}
|
||||
return _dbPromise;
|
||||
}
|
||||
|
||||
async function initDb() {
|
||||
const sqliteDir = `${FileSystem.documentDirectory}SQLite`;
|
||||
const dbPath = `${sqliteDir}/${DB_NAME}`;
|
||||
/**
|
||||
* Non-throwing DB opener.
|
||||
*
|
||||
* 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 dirInfo = await FileSystem.getInfoAsync(sqliteDir);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(sqliteDir, { intermediates: true });
|
||||
const logErrorDetails = (label, err) => {
|
||||
if (!err) {
|
||||
console.warn(`${prefix} ${label} <no error object>`);
|
||||
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);
|
||||
if (!fileInfo.exists) {
|
||||
const asset = Asset.fromModule(require("../assets/db/geodae.db"));
|
||||
await asset.downloadAsync();
|
||||
await FileSystem.copyAsync({ from: asset.localUri, to: dbPath });
|
||||
}
|
||||
|
||||
const db = await SQLite.openDatabaseAsync(DB_NAME);
|
||||
// Read-only optimizations
|
||||
await db.execAsync("PRAGMA journal_mode = WAL");
|
||||
await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB
|
||||
return db;
|
||||
}
|
||||
|
||||
async function getDbImpl() {
|
||||
const backend = await selectBackend();
|
||||
return backend.getDb();
|
||||
}
|
||||
|
||||
async function selectBackend() {
|
||||
if (_backendPromise) return _backendPromise;
|
||||
|
||||
_backendPromise = (async () => {
|
||||
const errors = [];
|
||||
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
87
src/db/openDbExpoSqlite.js
Normal file
87
src/db/openDbExpoSqlite.js
Normal 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
225
src/db/openDbOpSqlite.js
Normal 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,
|
||||
};
|
||||
32
src/db/openDbOpSqlite.test.js
Normal file
32
src/db/openDbOpSqlite.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
34
src/db/validateDbSchema.js
Normal file
34
src/db/validateDbSchema.js
Normal 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,
|
||||
};
|
||||
19
src/db/validateDbSchema.test.js
Normal file
19
src/db/validateDbSchema.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,8 @@ import {
|
|||
Dark as NavigationDarkTheme,
|
||||
} from "~/theme/navigation";
|
||||
|
||||
import DaeSuggestModal from "~/containers/DaeSuggestModal";
|
||||
|
||||
// import { navActions } from "~/stores";
|
||||
|
||||
// const linking = {
|
||||
|
|
@ -86,6 +88,9 @@ export default function LayoutProviders({ layoutKey, setLayoutKey, children }) {
|
|||
>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
{/* Global persistent modal: mounted outside navigation tree, but can navigate via RootNav ref */}
|
||||
<DaeSuggestModal />
|
||||
</ComposeComponents>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
63
src/lib/h3/index.js
Normal file
63
src/lib/h3/index.js
Normal 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;
|
||||
|
|
@ -23,6 +23,8 @@ import AlertAggListArchived from "~/scenes/AlertAggListArchived";
|
|||
import About from "~/scenes/About";
|
||||
import Contribute from "~/scenes/Contribute";
|
||||
import Location from "~/scenes/Location";
|
||||
import DAEList from "~/scenes/DAEList";
|
||||
import DAEItem from "~/scenes/DAEItem";
|
||||
import Developer from "~/scenes/Developer";
|
||||
import HelpSignal from "~/scenes/HelpSignal";
|
||||
|
||||
|
|
@ -366,6 +368,22 @@ export default React.memo(function DrawerNav() {
|
|||
}}
|
||||
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
|
||||
name="Links"
|
||||
component={Links}
|
||||
|
|
@ -503,6 +521,14 @@ export default React.memo(function DrawerNav() {
|
|||
}}
|
||||
component={SendAlertFinder}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="DAEItem"
|
||||
component={DAEItem}
|
||||
options={{
|
||||
hidden: true,
|
||||
unmountOnBlur: true,
|
||||
}}
|
||||
/>
|
||||
{devModeEnabled && (
|
||||
<Drawer.Screen
|
||||
name="Developer"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ function getHeaderTitle(route) {
|
|||
case "SendAlertFinder":
|
||||
return "Par mot-clé";
|
||||
|
||||
case "DAEList":
|
||||
return "Défibrillateurs";
|
||||
case "DAEItem":
|
||||
return "Défibrillateur";
|
||||
|
||||
case "ConnectivityError":
|
||||
return "Non connecté";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ export default function prioritizeFeatures(features) {
|
|||
return features
|
||||
.filter(({ properties }) => !properties.isUserLocation)
|
||||
.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 (x.cluster && y.cluster) {
|
||||
return x.x_max_level_num < y.x_max_level_num ? 1 : -1;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import Supercluster from "supercluster";
|
|||
import useShallowMemo from "~/hooks/useShallowMemo";
|
||||
import useShallowEffect from "~/hooks/useShallowEffect";
|
||||
import { deepEqual } from "fast-equals";
|
||||
import { useDefibsState } from "~/stores";
|
||||
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||
|
||||
export default function useFeatures({
|
||||
clusterFeature,
|
||||
|
|
@ -13,6 +15,11 @@ export default function useFeatures({
|
|||
route,
|
||||
alertCoords,
|
||||
}) {
|
||||
const { showDefibsOnAlertMap, corridorDefibs } = useDefibsState([
|
||||
"showDefibsOnAlertMap",
|
||||
"corridorDefibs",
|
||||
]);
|
||||
|
||||
// Check if we have valid coordinates
|
||||
const hasUserCoords =
|
||||
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 {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
};
|
||||
}, [list]);
|
||||
}, [list, showDefibsOnAlertMap, corridorDefibs]);
|
||||
|
||||
const superCluster = useShallowMemo(() => {
|
||||
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;
|
||||
}, [featureCollection.features]);
|
||||
// console.log({ superCluster: JSON.stringify(superCluster) });
|
||||
|
|
@ -123,6 +172,15 @@ export default function useFeatures({
|
|||
const userCoordinates = [userCoords.longitude, userCoords.latitude];
|
||||
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
|
||||
const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0;
|
||||
const hasValidAlertCoords =
|
||||
|
|
@ -157,6 +215,8 @@ export default function useFeatures({
|
|||
}, [
|
||||
setShape,
|
||||
clusterFeature,
|
||||
featureCollection.features,
|
||||
showDefibsOnAlertMap,
|
||||
userCoords,
|
||||
hasUserCoords,
|
||||
routeCoords,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native";
|
|||
|
||||
import { ANIMATION_DURATION } from "~/containers/Map/constants";
|
||||
|
||||
import { alertActions } from "~/stores";
|
||||
import { alertActions, defibsActions } from "~/stores";
|
||||
import { createLogger } from "~/lib/logger";
|
||||
import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";
|
||||
|
||||
|
|
@ -29,6 +29,12 @@ export default function useOnPress({
|
|||
const [feature] = features;
|
||||
const { properties } = feature;
|
||||
|
||||
if (properties?.isDefib && properties?.defib) {
|
||||
defibsActions.setSelectedDefib(properties.defib);
|
||||
navigation.navigate("DAEItem");
|
||||
return;
|
||||
}
|
||||
|
||||
if (properties.cluster) {
|
||||
// center and expand to cluster's points
|
||||
const { current: camera } = cameraRef;
|
||||
|
|
|
|||
|
|
@ -6,14 +6,17 @@ import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|||
import { deepEqual } from "fast-equals";
|
||||
|
||||
import withConnectivity from "~/hoc/withConnectivity";
|
||||
import { useToast } from "~/lib/toast-notifications";
|
||||
|
||||
import {
|
||||
useAlertState,
|
||||
useSessionState,
|
||||
alertActions,
|
||||
useAggregatedMessagesState,
|
||||
defibsActions,
|
||||
} from "~/stores";
|
||||
import { getCurrentLocation } from "~/location";
|
||||
import { getStoredLocation } from "~/location/storage";
|
||||
|
||||
import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.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 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 notifyAround = useCallback(async () => {
|
||||
|
|
@ -398,6 +494,33 @@ export default withConnectivity(
|
|||
</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 && (
|
||||
<MapLinksButton coordinates={alert.location.coordinates} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
|
|||
actionComingHelpButton: {},
|
||||
actionComingHelpText: {},
|
||||
actionComingHelpIcon: {},
|
||||
actionShowDefibsButton: {
|
||||
backgroundColor: colors.blue,
|
||||
},
|
||||
actionShowDefibsText: {},
|
||||
actionShowDefibsIcon: {},
|
||||
actionSmsButton: {},
|
||||
actionSmsText: {},
|
||||
actionSmsIcon: {},
|
||||
|
|
|
|||
385
src/scenes/DAEItem/Carte.js
Normal file
385
src/scenes/DAEItem/Carte.js
Normal 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
385
src/scenes/DAEItem/Infos.js
Normal 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
130
src/scenes/DAEItem/index.js
Normal 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
227
src/scenes/DAEList/Carte.js
Normal 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,
|
||||
},
|
||||
});
|
||||
166
src/scenes/DAEList/DefibRow.js
Normal file
166
src/scenes/DAEList/DefibRow.js
Normal 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
197
src/scenes/DAEList/Liste.js
Normal 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,
|
||||
},
|
||||
});
|
||||
61
src/scenes/DAEList/index.js
Normal file
61
src/scenes/DAEList/index.js
Normal 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>
|
||||
);
|
||||
});
|
||||
72
src/scenes/DAEList/useNearbyDefibs.js
Normal file
72
src/scenes/DAEList/useNearbyDefibs.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ import {
|
|||
} from "react-native-gesture-handler";
|
||||
import { createStyles, useTheme } from "~/theme";
|
||||
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 { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@ import uuidGenerator from "react-native-uuid";
|
|||
import { phoneCallEmergency } from "~/lib/phone-call";
|
||||
|
||||
import network from "~/network";
|
||||
import { getSessionState, alertActions, useParamsState } from "~/stores";
|
||||
import {
|
||||
getSessionState,
|
||||
alertActions,
|
||||
defibsActions,
|
||||
useParamsState,
|
||||
} from "~/stores";
|
||||
import { getCurrentLocation } from "~/location";
|
||||
|
||||
import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency";
|
||||
|
||||
import subjectSuggestsDefib from "~/utils/dae/subjectSuggestsDefib";
|
||||
|
||||
import { SEND_ALERT_MUTATION } from "./gql";
|
||||
|
||||
export default function useOnSubmit() {
|
||||
|
|
@ -125,6 +132,13 @@ async function onSubmit(args, context) {
|
|||
});
|
||||
|
||||
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", {
|
||||
screen: "AlertCur",
|
||||
params: {
|
||||
|
|
|
|||
114
src/stores/defibs.js
Normal file
114
src/stores/defibs.js
Normal 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,
|
||||
};
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ import params from "./params";
|
|||
import notifications from "./notifications";
|
||||
import permissionWizard from "./permissionWizard";
|
||||
import aggregatedMessages from "./aggregatedMessages";
|
||||
import defibs from "./defibs";
|
||||
|
||||
const store = createStore({
|
||||
tree,
|
||||
|
|
@ -28,6 +29,7 @@ const store = createStore({
|
|||
permissionWizard,
|
||||
notifications,
|
||||
aggregatedMessages,
|
||||
defibs,
|
||||
});
|
||||
|
||||
// console.log("store", JSON.stringify(Object.keys(store), null, 2));
|
||||
|
|
@ -100,4 +102,9 @@ export const {
|
|||
getAggregatedMessagesState,
|
||||
subscribeAggregatedMessagesState,
|
||||
aggregatedMessagesActions,
|
||||
|
||||
useDefibsState,
|
||||
getDefibsState,
|
||||
subscribeDefibsState,
|
||||
defibsActions,
|
||||
} = store;
|
||||
|
|
|
|||
174
src/utils/dae/getDefibAvailability.js
Normal file
174
src/utils/dae/getDefibAvailability.js
Normal 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" };
|
||||
}
|
||||
60
src/utils/dae/getDefibAvailability.test.js
Normal file
60
src/utils/dae/getDefibAvailability.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
76
src/utils/dae/subjectSuggestsDefib.js
Normal file
76
src/utils/dae/subjectSuggestsDefib.js
Normal 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;
|
||||
40
src/utils/dae/subjectSuggestsDefib.test.js
Normal file
40
src/utils/dae/subjectSuggestsDefib.test.js
Normal 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
79
src/utils/geo/corridor.js
Normal 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 user→alert segment.
|
||||
* Corridor definition: distance(point, lineSegment(user→alert)) <= 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;
|
||||
}
|
||||
48
src/utils/geo/corridor.test.js
Normal file
48
src/utils/geo/corridor.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
40
yarn.lock
40
yarn.lock
|
|
@ -5044,6 +5044,16 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 0.11.0
|
||||
resolution: "@pkgjs/parseargs@npm:0.11.0"
|
||||
|
|
@ -7013,6 +7023,7 @@ __metadata:
|
|||
"@mapbox/polyline": "npm:^1.2.1"
|
||||
"@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23"
|
||||
"@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-community/cli": "npm:^18.0.0"
|
||||
"@react-native-community/netinfo": "npm:^11.4.1"
|
||||
|
|
@ -7094,6 +7105,7 @@ __metadata:
|
|||
expo-secure-store: "npm:~14.2.4"
|
||||
expo-sensors: "npm:~14.1.4"
|
||||
expo-splash-screen: "npm:~0.30.10"
|
||||
expo-sqlite: "npm:^55.0.10"
|
||||
expo-status-bar: "npm:~2.2.3"
|
||||
expo-system-ui: "npm:~5.0.11"
|
||||
expo-task-manager: "npm:~13.1.6"
|
||||
|
|
@ -7104,6 +7116,7 @@ __metadata:
|
|||
google-libphonenumber: "npm:^3.2.32"
|
||||
graphql: "npm:^16.10.0"
|
||||
graphql-ws: "npm:^6.0.4"
|
||||
h3-js: "npm:^4.4.0"
|
||||
hash.js: "npm:^1.1.7"
|
||||
husky: "npm:^9.0.11"
|
||||
i18next: "npm:^23.2.10"
|
||||
|
|
@ -7544,6 +7557,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.7.2
|
||||
resolution: "axe-core@npm:4.7.2"
|
||||
|
|
@ -10889,6 +10909,19 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 2.2.3
|
||||
resolution: "expo-status-bar@npm:2.2.3"
|
||||
|
|
@ -11960,6 +11993,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.7.8
|
||||
resolution: "handlebars@npm:4.7.8"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue