feat(dae): updates (+ lint)
This commit is contained in:
parent
c366f8f9e8
commit
9914bd5276
14 changed files with 760 additions and 71 deletions
|
|
@ -191,7 +191,14 @@ function formatAddress(p) {
|
||||||
// Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC"
|
// Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC"
|
||||||
let city = (p.c_com_nom || "").trim();
|
let city = (p.c_com_nom || "").trim();
|
||||||
if (cp && city) {
|
if (cp && city) {
|
||||||
city = city.replace(new RegExp("\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)"), "").trim();
|
city = city
|
||||||
|
.replace(
|
||||||
|
new RegExp(
|
||||||
|
"\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)",
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip cp+city already embedded in street field
|
// Strip cp+city already embedded in street field
|
||||||
|
|
@ -199,7 +206,9 @@ function formatAddress(p) {
|
||||||
if (cp && street.includes(cp)) {
|
if (cp && street.includes(cp)) {
|
||||||
const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
// Trailing: "street 38200 Vienne" → "street"
|
// Trailing: "street 38200 Vienne" → "street"
|
||||||
street = street.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "").trim();
|
street = street
|
||||||
|
.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "")
|
||||||
|
.trim();
|
||||||
// Leading: "62117 rue de Lambres" → "rue de Lambres"
|
// Leading: "62117 rue de Lambres" → "rue de Lambres"
|
||||||
street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim();
|
street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim();
|
||||||
}
|
}
|
||||||
|
|
@ -359,7 +368,8 @@ function fixCoordinates(lat, lon, geometry) {
|
||||||
// 3. Try power-of-10 normalization for misplaced decimals
|
// 3. Try power-of-10 normalization for misplaced decimals
|
||||||
const fixedLat = tryNormalizeCoord(lat, 90);
|
const fixedLat = tryNormalizeCoord(lat, 90);
|
||||||
const fixedLon = tryNormalizeCoord(lon, 180);
|
const fixedLon = tryNormalizeCoord(lon, 180);
|
||||||
if (isPlausibleFrance(fixedLat, fixedLon)) return { lat: fixedLat, lon: fixedLon };
|
if (isPlausibleFrance(fixedLat, fixedLon))
|
||||||
|
return { lat: fixedLat, lon: fixedLon };
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,28 @@ export default function getDb() {
|
||||||
return _dbPromise;
|
return _dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current DB connection and clear all cached state.
|
||||||
|
* After calling this, the next `getDb()` / `getDbSafe()` call will re-open
|
||||||
|
* the DB from disk — picking up any file that was swapped in the meantime.
|
||||||
|
*/
|
||||||
|
export function resetDb() {
|
||||||
|
// Close the op-sqlite backend if it was loaded.
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { resetDbOpSqlite } = require("./openDbOpSqlite");
|
||||||
|
if (typeof resetDbOpSqlite === "function") {
|
||||||
|
resetDbOpSqlite();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// op-sqlite not available — nothing to close.
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbPromise = null;
|
||||||
|
_backendPromise = null;
|
||||||
|
_selectedBackendName = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-throwing DB opener.
|
* Non-throwing DB opener.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,24 @@ async function openDbOpSqlite() {
|
||||||
return _dbPromise;
|
return _dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current DB connection and clear cached promises.
|
||||||
|
* After calling this, the next `openDbOpSqlite()` call will re-open the DB.
|
||||||
|
*/
|
||||||
|
function resetDbOpSqlite() {
|
||||||
|
if (_rawDb) {
|
||||||
|
try {
|
||||||
|
if (typeof _rawDb.close === "function") {
|
||||||
|
_rawDb.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: DB may already be closed or in an invalid state.
|
||||||
|
}
|
||||||
|
_rawDb = null;
|
||||||
|
}
|
||||||
|
_dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Exports (CJS + ESM-ish):
|
// Exports (CJS + ESM-ish):
|
||||||
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
|
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
|
||||||
// cannot hand back a nullish / unexpected callable export shape.
|
// cannot hand back a nullish / unexpected callable export shape.
|
||||||
|
|
@ -220,6 +238,7 @@ module.exports = {
|
||||||
openDbOpSqlite,
|
openDbOpSqlite,
|
||||||
openDb: openDbOpSqlite,
|
openDb: openDbOpSqlite,
|
||||||
default: openDbOpSqlite,
|
default: openDbOpSqlite,
|
||||||
|
resetDbOpSqlite,
|
||||||
// Named export for unit tests.
|
// Named export for unit tests.
|
||||||
adaptDbToRepoInterface,
|
adaptDbToRepoInterface,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
213
src/db/updateDaeDb.js
Normal file
213
src/db/updateDaeDb.js
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Over-the-air DAE database update.
|
||||||
|
//
|
||||||
|
// Downloads a fresh geodae.db from the Minio/S3 bucket, validates it,
|
||||||
|
// swaps the on-device copy, and resets the DB connection so subsequent
|
||||||
|
// queries use the new data.
|
||||||
|
//
|
||||||
|
// IMPORTANT:
|
||||||
|
// - All native requires must stay inside functions so this file can be loaded
|
||||||
|
// in Jest/node without crashing.
|
||||||
|
|
||||||
|
import env from "~/env";
|
||||||
|
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||||
|
|
||||||
|
const DB_NAME = "geodae.db";
|
||||||
|
const GEODAE_BUCKET = "geodae";
|
||||||
|
const METADATA_FILE = "metadata.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public Minio URL for a given bucket/object.
|
||||||
|
* @param {string} object - object key within the geodae bucket
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function geodaeUrl(object) {
|
||||||
|
const base = env.MINIO_URL.replace(/\/+$/, "");
|
||||||
|
return `${base}/${GEODAE_BUCKET}/${object}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UpdateProgress
|
||||||
|
* @property {number} totalBytesWritten
|
||||||
|
* @property {number} totalBytesExpectedToWrite
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UpdateResult
|
||||||
|
* @property {boolean} success
|
||||||
|
* @property {boolean} [alreadyUpToDate]
|
||||||
|
* @property {string} [updatedAt]
|
||||||
|
* @property {Error} [error]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install the latest geodae.db from the server.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {function(UpdateProgress): void} [options.onProgress] - download progress callback
|
||||||
|
* @param {function(string): void} [options.onPhase] - phase change callback ("checking"|"downloading"|"installing")
|
||||||
|
* @returns {Promise<UpdateResult>}
|
||||||
|
*/
|
||||||
|
export async function updateDaeDb({ onProgress, onPhase } = {}) {
|
||||||
|
// Lazy requires to keep Jest/node stable.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const FileSystemModule = require("expo-file-system");
|
||||||
|
const FileSystem = FileSystemModule?.default ?? FileSystemModule;
|
||||||
|
|
||||||
|
const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`;
|
||||||
|
const dbUri = `${sqliteDirUri}/${DB_NAME}`;
|
||||||
|
const tmpUri = `${FileSystem.cacheDirectory}geodae-update-${Date.now()}.db`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Phase 1: Check metadata ──────────────────────────────────────────
|
||||||
|
onPhase?.("checking");
|
||||||
|
|
||||||
|
const metadataUrl = geodaeUrl(METADATA_FILE);
|
||||||
|
const metaResponse = await fetch(metadataUrl);
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`[DAE_UPDATE] Failed to fetch metadata: HTTP ${metaResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const metadata = await metaResponse.json();
|
||||||
|
const remoteUpdatedAt = metadata.updatedAt;
|
||||||
|
|
||||||
|
if (!remoteUpdatedAt) {
|
||||||
|
throw new Error("[DAE_UPDATE] Metadata missing updatedAt field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with stored last update timestamp
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const memoryAsyncStorageModule = require("~/storage/memoryAsyncStorage");
|
||||||
|
const memoryAsyncStorage =
|
||||||
|
memoryAsyncStorageModule?.default ?? memoryAsyncStorageModule;
|
||||||
|
const storedUpdatedAt = await memoryAsyncStorage.getItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
storedUpdatedAt &&
|
||||||
|
new Date(remoteUpdatedAt).getTime() <= new Date(storedUpdatedAt).getTime()
|
||||||
|
) {
|
||||||
|
return { success: true, alreadyUpToDate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Download ────────────────────────────────────────────────
|
||||||
|
onPhase?.("downloading");
|
||||||
|
|
||||||
|
const dbUrl = geodaeUrl(DB_NAME);
|
||||||
|
const downloadResumable = FileSystem.createDownloadResumable(
|
||||||
|
dbUrl,
|
||||||
|
tmpUri,
|
||||||
|
{},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
const downloadResult = await downloadResumable.downloadAsync();
|
||||||
|
|
||||||
|
if (!downloadResult?.uri) {
|
||||||
|
throw new Error("[DAE_UPDATE] Download failed: no URI returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the downloaded file is non-empty
|
||||||
|
const tmpInfo = await FileSystem.getInfoAsync(tmpUri);
|
||||||
|
if (!tmpInfo.exists || tmpInfo.size === 0) {
|
||||||
|
throw new Error("[DAE_UPDATE] Downloaded file is empty or missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Validate ────────────────────────────────────────────────
|
||||||
|
onPhase?.("installing");
|
||||||
|
|
||||||
|
// Quick validation: open the downloaded DB and check schema
|
||||||
|
// We use the same validation as the main DB opener.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { assertDbHasTable } = require("./validateDbSchema");
|
||||||
|
|
||||||
|
// Try to open the temp DB with op-sqlite for validation
|
||||||
|
let validationDb = null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const opSqliteMod = require("@op-engineering/op-sqlite");
|
||||||
|
const open = opSqliteMod?.open ?? opSqliteMod?.default?.open;
|
||||||
|
if (typeof open === "function") {
|
||||||
|
// op-sqlite needs the directory and filename separately
|
||||||
|
const tmpDir = tmpUri.substring(0, tmpUri.lastIndexOf("/"));
|
||||||
|
const tmpName = tmpUri.substring(tmpUri.lastIndexOf("/") + 1);
|
||||||
|
validationDb = open({ name: tmpName, location: tmpDir });
|
||||||
|
|
||||||
|
// Wrap for assertDbHasTable compatibility
|
||||||
|
const getAllAsync = async (sql, params = []) => {
|
||||||
|
const exec =
|
||||||
|
typeof validationDb.executeAsync === "function"
|
||||||
|
? validationDb.executeAsync.bind(validationDb)
|
||||||
|
: validationDb.execute?.bind(validationDb);
|
||||||
|
if (!exec) throw new Error("No execute method on validation DB");
|
||||||
|
const res = params.length ? await exec(sql, params) : await exec(sql);
|
||||||
|
return res?.rows ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
await assertDbHasTable({ getAllAsync }, "defibs");
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
const err = new Error("[DAE_UPDATE] Downloaded DB failed validation");
|
||||||
|
err.cause = validationError;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
// Close validation DB
|
||||||
|
if (validationDb && typeof validationDb.close === "function") {
|
||||||
|
try {
|
||||||
|
validationDb.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: Swap ────────────────────────────────────────────────────
|
||||||
|
// IMPORTANT: resetDb() closes the DB and clears cached promises.
|
||||||
|
// No concurrent DB queries should be in flight at this point.
|
||||||
|
// The caller (store action) is the only code path that triggers this,
|
||||||
|
// and it awaits completion before allowing new queries.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const { resetDb } = require("./openDb");
|
||||||
|
resetDb();
|
||||||
|
|
||||||
|
// Ensure SQLite directory exists
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri);
|
||||||
|
if (!dirInfo.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(sqliteDirUri, {
|
||||||
|
intermediates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the DB file
|
||||||
|
await FileSystem.moveAsync({ from: tmpUri, to: dbUri });
|
||||||
|
|
||||||
|
// Persist the update timestamp
|
||||||
|
await memoryAsyncStorage.setItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
remoteUpdatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
"[DAE_UPDATE] Successfully updated geodae.db to version:",
|
||||||
|
remoteUpdatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, updatedAt: remoteUpdatedAt };
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file on any error (FileSystem is in scope from the outer try)
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn("[DAE_UPDATE] Update failed:", error?.message, error);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,7 +57,9 @@ network.oaFilesKy = oaFilesKy;
|
||||||
|
|
||||||
export default function NetworkProviders({ children }) {
|
export default function NetworkProviders({ children }) {
|
||||||
const [key, setKey] = useState(0);
|
const [key, setKey] = useState(0);
|
||||||
const [transportClient, setTransportClient] = useState(() => network.apolloClient);
|
const [transportClient, setTransportClient] = useState(
|
||||||
|
() => network.apolloClient,
|
||||||
|
);
|
||||||
|
|
||||||
const networkState = useNetworkState([
|
const networkState = useNetworkState([
|
||||||
"initialized",
|
"initialized",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ import { useTheme } from "~/theme";
|
||||||
import { useDefibsState, useNetworkState } from "~/stores";
|
import { useDefibsState, useNetworkState } from "~/stores";
|
||||||
import useLocation from "~/hooks/useLocation";
|
import useLocation from "~/hooks/useLocation";
|
||||||
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
|
||||||
import { osmProfileUrl, profileDefaultModes } from "~/scenes/AlertCurMap/routing";
|
import {
|
||||||
|
osmProfileUrl,
|
||||||
|
profileDefaultModes,
|
||||||
|
} from "~/scenes/AlertCurMap/routing";
|
||||||
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
|
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
|
||||||
import {
|
import {
|
||||||
announceForA11yIfScreenReaderEnabled,
|
announceForA11yIfScreenReaderEnabled,
|
||||||
|
|
@ -172,15 +175,12 @@ export default React.memo(function DAEItemCarte() {
|
||||||
const mapHeadSeeAllRef = useRef(null);
|
const mapHeadSeeAllRef = useRef(null);
|
||||||
const lastStepsTriggerRef = useRef(null);
|
const lastStepsTriggerRef = useRef(null);
|
||||||
|
|
||||||
const openStepper = useCallback(
|
const openStepper = useCallback((triggerRef) => {
|
||||||
(triggerRef) => {
|
if (triggerRef) {
|
||||||
if (triggerRef) {
|
lastStepsTriggerRef.current = triggerRef;
|
||||||
lastStepsTriggerRef.current = triggerRef;
|
}
|
||||||
}
|
setStepperIsOpened(true);
|
||||||
setStepperIsOpened(true);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeStepper = useCallback(() => {
|
const closeStepper = useCallback(() => {
|
||||||
setStepperIsOpened(false);
|
setStepperIsOpened(false);
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,7 @@ export default React.memo(function DAEItemInfos() {
|
||||||
|
|
||||||
{/* In-app navigation option */}
|
{/* In-app navigation option */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={goToCarte}
|
onPress={goToCarte}
|
||||||
style={modalStyles.option}
|
style={modalStyles.option}
|
||||||
activeOpacity={0.6}
|
activeOpacity={0.6}
|
||||||
|
|
@ -411,6 +412,7 @@ export default React.memo(function DAEItemInfos() {
|
||||||
<React.Fragment key={app.id}>
|
<React.Fragment key={app.id}>
|
||||||
<View style={modalStyles.separator} />
|
<View style={modalStyles.separator} />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => openExternalApp(app)}
|
onPress={() => openExternalApp(app)}
|
||||||
style={modalStyles.option}
|
style={modalStyles.option}
|
||||||
activeOpacity={0.6}
|
activeOpacity={0.6}
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,7 @@ export default React.memo(function DAEListCarte() {
|
||||||
|
|
||||||
// Waiting for location
|
// Waiting for location
|
||||||
if (!hasLocation && defibs.length === 0 && !hasCoords) {
|
if (!hasLocation && defibs.length === 0 && !hasCoords) {
|
||||||
return (
|
return <LoadingView message="Recherche de votre position…" />;
|
||||||
<LoadingView message="Recherche de votre position…" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading defibs from database
|
// Loading defibs from database
|
||||||
|
|
|
||||||
309
src/scenes/DAEList/DaeUpdateBanner.js
Normal file
309
src/scenes/DAEList/DaeUpdateBanner.js
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React, { useEffect, useCallback } from "react";
|
||||||
|
import { View, StyleSheet, TouchableOpacity } from "react-native";
|
||||||
|
import { ProgressBar, ActivityIndicator } from "react-native-paper";
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { useTheme } from "~/theme";
|
||||||
|
import { defibsActions, useDefibsState } from "~/stores";
|
||||||
|
|
||||||
|
function formatDate(isoString) {
|
||||||
|
if (!isoString) return null;
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(function DaeUpdateBanner() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const {
|
||||||
|
daeUpdateState,
|
||||||
|
daeUpdateProgress,
|
||||||
|
daeUpdateError,
|
||||||
|
daeLastUpdatedAt,
|
||||||
|
} = useDefibsState([
|
||||||
|
"daeUpdateState",
|
||||||
|
"daeUpdateProgress",
|
||||||
|
"daeUpdateError",
|
||||||
|
"daeLastUpdatedAt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load persisted last-update date on mount
|
||||||
|
useEffect(() => {
|
||||||
|
defibsActions.loadLastDaeUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(() => {
|
||||||
|
defibsActions.triggerDaeUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismissError = useCallback(() => {
|
||||||
|
defibsActions.dismissDaeUpdateError();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
daeUpdateState === "checking" ||
|
||||||
|
daeUpdateState === "downloading" ||
|
||||||
|
daeUpdateState === "installing";
|
||||||
|
|
||||||
|
// Done state
|
||||||
|
if (daeUpdateState === "done") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.primary || "#4CAF50") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="check-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.primary || "#4CAF50"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#4CAF50" }]}
|
||||||
|
>
|
||||||
|
{"Base de donn\u00e9es mise \u00e0 jour !"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already up-to-date
|
||||||
|
if (daeUpdateState === "up-to-date") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(colors.onSurfaceVariant || colors.grey || "#666") + "10",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="check-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{"Donn\u00e9es d\u00e9j\u00e0 \u00e0 jour"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (daeUpdateState === "error") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.error || "#F44336") + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="alert-circle-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.errorText, { color: colors.error || "#F44336" }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{daeUpdateError || "Erreur lors de la mise \u00e0 jour"}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleUpdate}
|
||||||
|
style={styles.retryTouch}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="refresh"
|
||||||
|
size={20}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleDismissError}
|
||||||
|
style={styles.dismissTouch}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="close"
|
||||||
|
size={18}
|
||||||
|
color={colors.error || "#F44336"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloading state
|
||||||
|
if (daeUpdateState === "downloading") {
|
||||||
|
const pct = Math.round(daeUpdateProgress * 100);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
styles.progressBanner,
|
||||||
|
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.progressHeader}>
|
||||||
|
<ActivityIndicator size={14} color={colors.primary} />
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
|
||||||
|
>
|
||||||
|
{`T\u00e9l\u00e9chargement\u2026 ${pct}%`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={daeUpdateProgress}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.progressBar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checking / Installing state
|
||||||
|
if (isActive) {
|
||||||
|
const label =
|
||||||
|
daeUpdateState === "checking"
|
||||||
|
? "V\u00e9rification\u2026"
|
||||||
|
: "Installation\u2026";
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size={14} color={colors.primary} />
|
||||||
|
<Text
|
||||||
|
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle state
|
||||||
|
const formattedDate = formatDate(daeLastUpdatedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
(colors.onSurfaceVariant || colors.grey || "#666") + "08",
|
||||||
|
borderBottomColor: colors.outlineVariant || colors.grey,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="database-sync-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.onSurfaceVariant || colors.grey}
|
||||||
|
/>
|
||||||
|
<View style={styles.idleTextContainer}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dateText,
|
||||||
|
{ color: colors.onSurfaceVariant || colors.grey },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formattedDate
|
||||||
|
? `Derni\u00e8re mise \u00e0 jour : ${formattedDate}`
|
||||||
|
: "Donn\u00e9es int\u00e9gr\u00e9es \u00e0 l'application"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleUpdate}
|
||||||
|
style={[
|
||||||
|
styles.updateButton,
|
||||||
|
{ backgroundColor: colors.primary || "#2196F3" },
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="download" size={14} color="#fff" />
|
||||||
|
<Text style={styles.updateButtonText}>{"Mettre \u00e0 jour"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBanner: {
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
progressHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "500",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
retryTouch: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
dismissTouch: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
idleTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
updateButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
updateButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -192,9 +192,7 @@ export default React.memo(function DAEListListe() {
|
||||||
|
|
||||||
// Waiting for location
|
// Waiting for location
|
||||||
if (!hasLocation && allDefibs.length === 0) {
|
if (!hasLocation && allDefibs.length === 0) {
|
||||||
return (
|
return <LoadingView message="Recherche de votre position…" />;
|
||||||
<LoadingView message="Recherche de votre position…" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading defibs from database
|
// Loading defibs from database
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
|
@ -6,6 +7,7 @@ import { fontFamily, useTheme } from "~/theme";
|
||||||
|
|
||||||
import DAEListListe from "./Liste";
|
import DAEListListe from "./Liste";
|
||||||
import DAEListCarte from "./Carte";
|
import DAEListCarte from "./Carte";
|
||||||
|
import DaeUpdateBanner from "./DaeUpdateBanner";
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
|
@ -13,49 +15,63 @@ export default React.memo(function DAEList() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<View style={styles.container}>
|
||||||
screenOptions={{
|
<DaeUpdateBanner />
|
||||||
headerShown: false,
|
<View style={styles.tabContainer}>
|
||||||
tabBarActiveTintColor: colors.primary,
|
<Tab.Navigator
|
||||||
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
|
screenOptions={{
|
||||||
tabBarLabelStyle: {
|
headerShown: false,
|
||||||
fontFamily,
|
tabBarActiveTintColor: colors.primary,
|
||||||
fontSize: 12,
|
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
|
||||||
},
|
tabBarLabelStyle: {
|
||||||
tabBarStyle: {
|
fontFamily,
|
||||||
backgroundColor: colors.surface,
|
fontSize: 12,
|
||||||
borderTopColor: colors.outlineVariant || colors.grey,
|
},
|
||||||
},
|
tabBarStyle: {
|
||||||
}}
|
backgroundColor: colors.surface,
|
||||||
>
|
borderTopColor: colors.outlineVariant || colors.grey,
|
||||||
<Tab.Screen
|
},
|
||||||
name="DAEListListe"
|
}}
|
||||||
component={DAEListListe}
|
>
|
||||||
options={{
|
<Tab.Screen
|
||||||
tabBarLabel: "Liste",
|
name="DAEListListe"
|
||||||
tabBarIcon: ({ color, size }) => (
|
component={DAEListListe}
|
||||||
<MaterialCommunityIcons
|
options={{
|
||||||
name="format-list-bulleted"
|
tabBarLabel: "Liste",
|
||||||
color={color}
|
tabBarIcon: ({ color, size }) => (
|
||||||
size={size}
|
<MaterialCommunityIcons
|
||||||
/>
|
name="format-list-bulleted"
|
||||||
),
|
color={color}
|
||||||
}}
|
size={size}
|
||||||
/>
|
/>
|
||||||
<Tab.Screen
|
),
|
||||||
name="DAEListCarte"
|
}}
|
||||||
component={DAEListCarte}
|
/>
|
||||||
options={{
|
<Tab.Screen
|
||||||
tabBarLabel: "Carte",
|
name="DAEListCarte"
|
||||||
tabBarIcon: ({ color, size }) => (
|
component={DAEListCarte}
|
||||||
<MaterialCommunityIcons
|
options={{
|
||||||
name="map-marker-outline"
|
tabBarLabel: "Carte",
|
||||||
color={color}
|
tabBarIcon: ({ color, size }) => (
|
||||||
size={size}
|
<MaterialCommunityIcons
|
||||||
/>
|
name="map-marker-outline"
|
||||||
),
|
color={color}
|
||||||
}}
|
size={size}
|
||||||
/>
|
/>
|
||||||
</Tab.Navigator>
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,19 @@ const RADIUS_METERS = 10_000;
|
||||||
*/
|
*/
|
||||||
export default function useNearbyDefibs() {
|
export default function useNearbyDefibs() {
|
||||||
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
|
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
|
||||||
const { nearUserDefibs, loadingNearUser, errorNearUser, showUnavailable } =
|
const {
|
||||||
useDefibsState([
|
nearUserDefibs,
|
||||||
"nearUserDefibs",
|
loadingNearUser,
|
||||||
"loadingNearUser",
|
errorNearUser,
|
||||||
"errorNearUser",
|
showUnavailable,
|
||||||
"showUnavailable",
|
daeUpdateState,
|
||||||
]);
|
} = useDefibsState([
|
||||||
|
"nearUserDefibs",
|
||||||
|
"loadingNearUser",
|
||||||
|
"errorNearUser",
|
||||||
|
"showUnavailable",
|
||||||
|
"daeUpdateState",
|
||||||
|
]);
|
||||||
|
|
||||||
const hasLocation =
|
const hasLocation =
|
||||||
coords && coords.latitude !== null && coords.longitude !== null;
|
coords && coords.latitude !== null && coords.longitude !== null;
|
||||||
|
|
@ -42,6 +48,16 @@ export default function useNearbyDefibs() {
|
||||||
});
|
});
|
||||||
}, [hasLocation, coords]);
|
}, [hasLocation, coords]);
|
||||||
|
|
||||||
|
// After a successful DB update, reset the position cache so the next
|
||||||
|
// render re-queries the fresh database.
|
||||||
|
const prevUpdateState = useRef(daeUpdateState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevUpdateState.current !== "done" && daeUpdateState === "done") {
|
||||||
|
lastLoadedRef.current = null;
|
||||||
|
}
|
||||||
|
prevUpdateState.current = daeUpdateState;
|
||||||
|
}, [daeUpdateState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasLocation) {
|
if (hasLocation) {
|
||||||
setNoLocation(false);
|
setNoLocation(false);
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,5 @@ export const STORAGE_KEYS = {
|
||||||
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
|
||||||
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
|
||||||
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
|
||||||
|
DAE_DB_UPDATED_AT: registerAsyncStorageKey("@dae_db_updated_at"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,16 @@ import {
|
||||||
computeCorridorQueryRadiusMeters,
|
computeCorridorQueryRadiusMeters,
|
||||||
filterDefibsInCorridor,
|
filterDefibsInCorridor,
|
||||||
} from "~/utils/geo/corridor";
|
} from "~/utils/geo/corridor";
|
||||||
|
import { updateDaeDb } from "~/db/updateDaeDb";
|
||||||
|
import memoryAsyncStorage from "~/storage/memoryAsyncStorage";
|
||||||
|
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||||
|
|
||||||
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
|
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
|
||||||
const DEFAULT_CORRIDOR_M = 10_000;
|
const DEFAULT_CORRIDOR_M = 10_000;
|
||||||
const DEFAULT_LIMIT = 200;
|
const DEFAULT_LIMIT = 200;
|
||||||
|
|
||||||
|
const AUTO_DISMISS_DELAY = 4_000;
|
||||||
|
|
||||||
export default createAtom(({ merge, reset }) => {
|
export default createAtom(({ merge, reset }) => {
|
||||||
const actions = {
|
const actions = {
|
||||||
reset,
|
reset,
|
||||||
|
|
@ -98,6 +103,78 @@ export default createAtom(({ merge, reset }) => {
|
||||||
return { defibs: [], error };
|
return { defibs: [], error };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── DAE DB Over-the-Air Update ─────────────────────────────────────
|
||||||
|
|
||||||
|
loadLastDaeUpdate: async () => {
|
||||||
|
try {
|
||||||
|
const stored = await memoryAsyncStorage.getItem(
|
||||||
|
STORAGE_KEYS.DAE_DB_UPDATED_AT,
|
||||||
|
);
|
||||||
|
if (stored) {
|
||||||
|
merge({ daeLastUpdatedAt: stored });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerDaeUpdate: async () => {
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "checking",
|
||||||
|
daeUpdateProgress: 0,
|
||||||
|
daeUpdateError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateDaeDb({
|
||||||
|
onPhase: (phase) => {
|
||||||
|
merge({ daeUpdateState: phase });
|
||||||
|
},
|
||||||
|
onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
|
||||||
|
const progress =
|
||||||
|
totalBytesExpectedToWrite > 0
|
||||||
|
? totalBytesWritten / totalBytesExpectedToWrite
|
||||||
|
: 0;
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "downloading",
|
||||||
|
daeUpdateProgress: progress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.alreadyUpToDate) {
|
||||||
|
merge({ daeUpdateState: "up-to-date" });
|
||||||
|
setTimeout(() => {
|
||||||
|
merge({ daeUpdateState: "idle" });
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "error",
|
||||||
|
daeUpdateError: result.error?.message || "Erreur inconnue",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: update stored timestamp and clear loaded defibs
|
||||||
|
// so the next query fetches from the fresh DB.
|
||||||
|
merge({
|
||||||
|
daeUpdateState: "done",
|
||||||
|
daeLastUpdatedAt: result.updatedAt,
|
||||||
|
nearUserDefibs: [],
|
||||||
|
corridorDefibs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
merge({ daeUpdateState: "idle" });
|
||||||
|
}, AUTO_DISMISS_DELAY);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissDaeUpdateError: () => {
|
||||||
|
merge({ daeUpdateState: "idle", daeUpdateError: null });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -113,6 +190,12 @@ export default createAtom(({ merge, reset }) => {
|
||||||
loadingCorridor: false,
|
loadingCorridor: false,
|
||||||
errorNearUser: null,
|
errorNearUser: null,
|
||||||
errorCorridor: null,
|
errorCorridor: null,
|
||||||
|
|
||||||
|
// DAE DB update state
|
||||||
|
daeUpdateState: "idle", // "idle"|"checking"|"downloading"|"installing"|"done"|"error"|"up-to-date"
|
||||||
|
daeUpdateProgress: 0, // 0..1
|
||||||
|
daeUpdateError: null,
|
||||||
|
daeLastUpdatedAt: null,
|
||||||
},
|
},
|
||||||
actions,
|
actions,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue