Skip to main content

Step 10: Offline behavior, Settings, and localisation

What we are building​

An encrypted local cache for the current plan and a durable outbox for adherence reports. We will also finish the patient Settings screen and add a second CMS locale.

What you should already have​

  • The running Expo development build and SQLCipher plugin from step 1.
  • expo-sqlite, expo-secure-store, expo-crypto, and @react-native-community/netinfo installed.
  • The plan loader, adherence Bundle builder, local reminders, and CMS/i18next setup from earlier steps.

The implementation​

Open an encrypted local database​

Create src/features/medication/offline-storage.ts:

Create the encryption key once, keep it in the operating system's secure storage, and apply it before issuing any SQL query. This database stores the last downloaded plan and any unsent patient reports:

import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import * as SQLite from "expo-sqlite";

const databaseName = "ovok-medication.db";
const keyName = "ovok-medication-db-key";

export async function openMedicationDatabase() {
let key = await SecureStore.getItemAsync(keyName);
if (!key) {
key = Array.from(await Crypto.getRandomBytesAsync(32), (byte) => byte.toString(16).padStart(2, "0")).join("");
await SecureStore.setItemAsync(keyName, key);
}

const db = await SQLite.openDatabaseAsync(databaseName);
await db.execAsync(`PRAGMA key = '${key}'`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS plan_cache (
patient_id TEXT NOT NULL,
request_id TEXT NOT NULL,
resource_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (patient_id, request_id)
);
CREATE TABLE IF NOT EXISTS plan_cache_state (
patient_id TEXT PRIMARY KEY NOT NULL,
synced_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS history_cache (
patient_id TEXT NOT NULL,
event_id TEXT NOT NULL,
resource_json TEXT NOT NULL,
PRIMARY KEY (patient_id, event_id)
);
CREATE TABLE IF NOT EXISTS history_cache_state (
patient_id TEXT PRIMARY KEY NOT NULL,
synced_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS translation_cache (
locale TEXT PRIMARY KEY NOT NULL,
tree_json TEXT NOT NULL,
fetched_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS adherence_outbox (
patient_id TEXT NOT NULL,
occurrence_key TEXT NOT NULL,
bundle_json TEXT NOT NULL,
sync_state TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
PRIMARY KEY (patient_id, occurrence_key)
);
`);
return db;
}

key contains only hex characters generated by the app. Never interpolate user input into SQL. Keep the Patient id on every row so one signed-in account cannot flush another account's pending events.

Cache the plan and queue reports​

After a successful plan search, atomically replace that Patient's cache with the returned plan. Before sending a Taken or Skipped report, persist the Bundle created in step 8:

export async function cacheMedicationPlan(db: SQLite.SQLiteDatabase, patientId: string, requests: Array<{ id?: string }>) {
const syncedAt = new Date().toISOString();
await db.withTransactionAsync(async () => {
await db.runAsync("DELETE FROM plan_cache WHERE patient_id = ?", patientId);
await db.runAsync(
`INSERT INTO plan_cache_state (patient_id, synced_at) VALUES (?, ?)
ON CONFLICT(patient_id) DO UPDATE SET synced_at = excluded.synced_at`,
patientId,
syncedAt,
);
for (const request of requests) {
if (!request.id) throw new Error("A server medication request has no id and cannot be cached.");
await db.runAsync(
`INSERT INTO plan_cache (patient_id, request_id, resource_json, updated_at)
VALUES (?, ?, ?, ?)`,
patientId,
request.id,
JSON.stringify(request),
syncedAt,
);
}
});
}

export async function readCachedMedicationPlan<T>(db: SQLite.SQLiteDatabase, patientId: string) {
const snapshot = await db.getFirstAsync<{ synced_at: string }>(
"SELECT synced_at FROM plan_cache_state WHERE patient_id = ?",
patientId,
);
if (!snapshot) return undefined;
const rows = await db.getAllAsync<{ resource_json: string }>(
`SELECT resource_json, updated_at FROM plan_cache
WHERE patient_id = ? ORDER BY updated_at DESC`,
patientId,
);
return {
requests: rows.map((row) => JSON.parse(row.resource_json) as T),
syncedAt: snapshot.synced_at,
};
}

export async function cacheMedicationHistory(db: SQLite.SQLiteDatabase, patientId: string, events: Array<{ id?: string }>) {
const syncedAt = new Date().toISOString();
await db.withTransactionAsync(async () => {
await db.runAsync("DELETE FROM history_cache WHERE patient_id = ?", patientId);
await db.runAsync(
`INSERT INTO history_cache_state (patient_id, synced_at) VALUES (?, ?)
ON CONFLICT(patient_id) DO UPDATE SET synced_at = excluded.synced_at`,
patientId,
syncedAt,
);
for (const event of events) {
if (!event.id) throw new Error("A server medication report has no id and cannot be cached.");
await db.runAsync(
"INSERT INTO history_cache (patient_id, event_id, resource_json) VALUES (?, ?, ?)",
patientId,
event.id,
JSON.stringify(event),
);
}
});
}

export async function readCachedMedicationHistory<T>(db: SQLite.SQLiteDatabase, patientId: string) {
const snapshot = await db.getFirstAsync<{ synced_at: string }>(
"SELECT synced_at FROM history_cache_state WHERE patient_id = ?",
patientId,
);
if (!snapshot) return undefined;
const rows = await db.getAllAsync<{ resource_json: string }>(
"SELECT resource_json FROM history_cache WHERE patient_id = ?",
patientId,
);
return {
events: rows.map((row) => JSON.parse(row.resource_json) as T),
syncedAt: snapshot.synced_at,
};
}

export async function cacheCmsTranslations(
db: SQLite.SQLiteDatabase,
locale: string,
tree: Record<string, unknown>,
) {
await db.runAsync(
`INSERT INTO translation_cache (locale, tree_json, fetched_at) VALUES (?, ?, ?)
ON CONFLICT(locale) DO UPDATE SET tree_json = excluded.tree_json, fetched_at = excluded.fetched_at`,
locale,
JSON.stringify(tree),
new Date().toISOString(),
);
}

export async function readCachedCmsTranslations(db: SQLite.SQLiteDatabase, locale: string) {
const row = await db.getFirstAsync<{ tree_json: string }>(
"SELECT tree_json FROM translation_cache WHERE locale = ?",
locale,
);
return row ? JSON.parse(row.tree_json) as Record<string, unknown> : undefined;
}

export async function queueAdherenceBundle(
db: SQLite.SQLiteDatabase,
patientId: string,
occurrenceKey: string,
bundle: unknown,
) {
await db.runAsync(
`INSERT OR IGNORE INTO adherence_outbox (patient_id, occurrence_key, bundle_json, created_at)
VALUES (?, ?, ?, ?)`,
patientId,
occurrenceKey,
JSON.stringify(bundle),
new Date().toISOString(),
);
}

Read one queued report when the Today screen mounts so the UI can restore its local pending state after an app restart:

export async function readQueuedAdherenceReport(
db: SQLite.SQLiteDatabase,
patientId: string,
occurrenceKey: string,
) {
const row = await db.getFirstAsync<{ bundle_json: string; sync_state: string }>(
`SELECT bundle_json, sync_state FROM adherence_outbox
WHERE patient_id = ? AND occurrence_key = ?`,
patientId,
occurrenceKey,
);
if (!row) return undefined;

const bundle = JSON.parse(row.bundle_json) as {
entry?: Array<{ resource?: { status?: string } }>;
};
const status = bundle.entry?.[0]?.resource?.status;
if (status !== "completed" && status !== "not-done") return undefined;
return {
action: status === "completed" ? "taken" as const : "skipped" as const,
syncState: row.sync_state,
};
}

Update the plan cache only after the FHIR search succeeds. If the search fails, call readCachedMedicationPlan() for the authenticated Patient and mark it Last synced with its timestamp; do not describe stale data as current. The phone's already-scheduled local reminders can still fire offline.

Replace the step 5 plan hook with this version. It falls back only to the signed-in Patient's cache, and exposes the source and last-sync time so the screen can distinguish stale data:

import { useClient } from "@ovok/core";
import { useSession } from "@ovok/native";
import * as React from "react";

import { loadMedicationPlan } from "./medication-plan";
import { cacheMedicationPlan, openMedicationDatabase, readCachedMedicationPlan } from "./offline-storage";

type MedicationPlan = Awaited<ReturnType<typeof loadMedicationPlan>>;

export function useMedicationPlan() {
const client = useClient();
const { status, isAuthenticated, profile } = useSession({ sessions: false });
const patientId = profile?.resourceType === "Patient" ? profile.id : undefined;
const [plan, setPlan] = React.useState<MedicationPlan>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<Error>();
const [cacheError, setCacheError] = React.useState<Error>();
const [source, setSource] = React.useState<"server" | "cache">("server");
const [lastSyncedAt, setLastSyncedAt] = React.useState<string>();

React.useEffect(() => {
if (status === "loading") return;
if (!isAuthenticated || !patientId) {
setPlan([]);
setLoading(false);
setError(undefined);
setCacheError(undefined);
setSource("server");
setLastSyncedAt(undefined);
return;
}

let current = true;
setLoading(true);
setError(undefined);
setCacheError(undefined);
setPlan([]);
setSource("server");
setLastSyncedAt(undefined);
void (async () => {
try {
const fresh = await loadMedicationPlan(client, patientId);
if (!current) return;
const syncedAt = new Date().toISOString();
setPlan(fresh);
setSource("server");
setLastSyncedAt(syncedAt);
try {
const db = await openMedicationDatabase();
await cacheMedicationPlan(db, patientId, fresh);
} catch (cacheReason) {
if (current) setCacheError(cacheReason instanceof Error ? cacheReason : new Error(String(cacheReason)));
}
} catch (reason) {
const serverError = reason instanceof Error ? reason : new Error(String(reason));
try {
const db = await openMedicationDatabase();
const cached = await readCachedMedicationPlan<MedicationPlan[number]>(db, patientId);
if (!current) return;
if (cached) {
setPlan(cached.requests);
setSource("cache");
setLastSyncedAt(cached.syncedAt);
} else {
setError(serverError);
}
} catch (cacheReason) {
if (current) {
setError(serverError);
setCacheError(cacheReason instanceof Error ? cacheReason : new Error(String(cacheReason)));
}
}
} finally {
if (current) setLoading(false);
}
})();
return () => { current = false; };
}, [client, isAuthenticated, patientId, status]);

return { plan, patient: patientId ? profile : undefined, loading, error, cacheError, source, lastSyncedAt };
}

In Today, show Last synced with lastSyncedAt when source === "cache". Do not present cached data as current, or replace it with an empty plan when the server request fails.

Render the cache timestamp and a recoverable warning when a fresh online plan could not be cached:

const { source, lastSyncedAt, cacheError } = useMedicationPlan();
const { t, i18n } = useTranslation();

{source === "cache" && lastSyncedAt && (
<Text>
{t("medication.lastSynced", {
date: new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" })
.format(new Date(lastSyncedAt)),
})}
</Text>
)}
{source === "server" && cacheError && (
<Text accessibilityRole="alert">{t("medication.offlineCacheError")}</Text>
)}

Keep confirmed history available offline​

Replace the Step 9 useMedicationHistory implementation in src/features/medication/history.ts. Keep its MedicationAdministrationView type and reload() contract. Cache only this tutorial's occurrence reports, not every MedicationAdministration returned by the Patient search:

import { useClient } from "@ovok/core";
import * as React from "react";

import { occurrenceIdentifierSystem } from "./adherence";
import { cacheMedicationHistory, openMedicationDatabase, readCachedMedicationHistory } from "./offline-storage";

export function useMedicationHistory(patientId: string) {
const client = useClient();
const [events, setEvents] = React.useState<MedicationAdministrationView[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error>();
const [cacheError, setCacheError] = React.useState<Error>();
const [source, setSource] = React.useState<"server" | "cache">("server");
const [lastSyncedAt, setLastSyncedAt] = React.useState<string>();
const [revision, setRevision] = React.useState(0);
const reload = React.useCallback(() => setRevision((value) => value + 1), []);

React.useEffect(() => {
let current = true;
setEvents([]);
setError(undefined);
setCacheError(undefined);
if (!patientId) {
setLoading(false);
return () => { current = false; };
}

setLoading(true);
void (async () => {
try {
const loaded: MedicationAdministrationView[] = [];
for await (const page of client.searchResourcePages("MedicationAdministration", {
subject: `Patient/${patientId}`,
_count: "50",
})) loaded.push(...page);

const ownReports = loaded.filter((event) =>
event.identifier?.some((item) => item.system === occurrenceIdentifierSystem),
);
const syncedAt = new Date().toISOString();
if (current) {
setEvents(ownReports);
setSource("server");
setLastSyncedAt(syncedAt);
}
try {
const db = await openMedicationDatabase();
await cacheMedicationHistory(db, patientId, ownReports);
} catch (reason) {
if (current) setCacheError(reason instanceof Error ? reason : new Error(String(reason)));
}
} catch (reason) {
const serverError = reason instanceof Error ? reason : new Error(String(reason));
try {
const db = await openMedicationDatabase();
const cached = await readCachedMedicationHistory<MedicationAdministrationView>(db, patientId);
if (!current) return;
if (cached) {
setEvents(cached.events);
setSource("cache");
setLastSyncedAt(cached.syncedAt);
} else {
setError(serverError);
}
} catch (cacheReason) {
if (current) {
setError(serverError);
setCacheError(cacheReason instanceof Error ? cacheReason : new Error(String(cacheReason)));
}
}
} finally {
if (current) setLoading(false);
}
})();
return () => { current = false; };
}, [client, patientId, revision]);

return { events, loading, error, cacheError, source, lastSyncedAt, reload };
}

In the Step 9 History screen, update the hook result and display the cache timestamp when source === "cache":

const { events, loading, error, cacheError, source, lastSyncedAt } = useMedicationHistory(patientId);

{source === "cache" && lastSyncedAt && (
<Text>
{t("medication.lastSynced", {
date: new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" })
.format(new Date(lastSyncedAt)),
})}
</Text>
)}
{source === "server" && cacheError && (
<Text accessibilityRole="alert">{t("medication.offlineCacheError")}</Text>
)}

Keep local outbox reports on Today as Pending sync; do not mix them into server-confirmed history.

Flush the outbox safely​

Sync only after the same Patient is authenticated. Delete a queued row only after the server returns a successful response for its transaction entry:

import { readOvokError, type OvokClient } from "@ovok/core";
import { buildAdherenceBundle, type DoseOccurrence, type PatientReport } from "./adherence";

export async function flushAdherenceOutbox(
db: SQLite.SQLiteDatabase,
client: OvokClient,
authenticatedPatientId: string,
) {
const rows = await db.getAllAsync<{ occurrence_key: string; bundle_json: string }>(
`SELECT occurrence_key, bundle_json FROM adherence_outbox
WHERE patient_id = ? AND sync_state = 'pending' ORDER BY created_at`,
authenticatedPatientId,
);

for (const row of rows) {
const bundle = JSON.parse(row.bundle_json) as Parameters<OvokClient["executeBatch"]>[0];
let response: Awaited<ReturnType<OvokClient["executeBatch"]>>;
try {
response = await client.executeBatch(bundle);
} catch (reason) {
const { status } = readOvokError(reason);
if (status !== undefined && status >= 400 && status < 500) {
await db.runAsync(
`UPDATE adherence_outbox SET sync_state = 'needs-attention'
WHERE patient_id = ? AND occurrence_key = ?`,
authenticatedPatientId,
row.occurrence_key,
);
}
throw reason;
}
const status = response.entry?.[0]?.response?.status;
const code = Number(status?.slice(0, 3));
if (code >= 400 && code < 500) {
await db.runAsync(
`UPDATE adherence_outbox SET sync_state = 'needs-attention'
WHERE patient_id = ? AND occurrence_key = ?`,
authenticatedPatientId,
row.occurrence_key,
);
throw new Error(`Adherence report needs attention for ${row.occurrence_key}: ${status}`);
}
if (!status || !/^2\d\d/.test(status)) {
throw new Error(`Adherence sync did not succeed for ${row.occurrence_key}: ${status ?? "missing response status"}`);
}
await db.runAsync(
"DELETE FROM adherence_outbox WHERE patient_id = ? AND occurrence_key = ?",
authenticatedPatientId,
row.occurrence_key,
);
}
}

The Taken/Skipped handler now stores first and sends second:

export async function recordPatientReport(
db: SQLite.SQLiteDatabase,
client: OvokClient,
patientId: string,
occurrence: DoseOccurrence,
report: PatientReport,
isOnline: boolean,
) {
const bundle = buildAdherenceBundle(patientId, occurrence, report);
await queueAdherenceBundle(db, patientId, occurrence.occurrenceKey, bundle);

if (!isOnline) return "pending-sync" as const;
await flushAdherenceOutbox(db, client, patientId);
return "synced" as const;
}

In AdherenceActions from step 8, add syncState state and replace its online-only submitAdherenceReport() call with the durable write. The action is queued before the network request; restore that same action when the route regains focus:

import NetInfo from "@react-native-community/netinfo";
import { useFocusEffect } from "expo-router";

import { openMedicationDatabase, readQueuedAdherenceReport, recordPatientReport } from "./offline-storage";

const [syncState, setSyncState] = useState<string>();

useFocusEffect(React.useCallback(() => {
let current = true;
void (async () => {
if (!profile?.id) return;
try {
const db = await openMedicationDatabase();
const queued = await readQueuedAdherenceReport(db, profile.id, occurrence.occurrenceKey);
if (!current) return;
if (queued) {
setAttemptedAction(queued.action);
setSavedAction(queued.action);
setSyncState(queued.syncState);
} else {
setSyncState(undefined);
}
} catch {
if (current) setError(true);
}
})();
return () => { current = false; };
}, [profile?.id, occurrence.occurrenceKey]));

async function submit(action: PatientReport["action"]) {
if (!profile?.id || attemptedAction) return;
setAttemptedAction(action);
let db: Awaited<ReturnType<typeof openMedicationDatabase>> | undefined;
try {
db = await openMedicationDatabase();
const network = await NetInfo.fetch();
const isOnline = network.isConnected === true && network.isInternetReachable !== false;
const result = await recordPatientReport(db, client, profile.id, occurrence, {
action,
reportedAt: new Date(),
}, isOnline);
setSavedAction(action);
setSyncState(result === "pending-sync" ? "pending" : undefined);
if (result === "synced") onSaved?.();
} catch {
try {
const queued = db
? await readQueuedAdherenceReport(db, profile.id, occurrence.occurrenceKey)
: undefined;
if (queued) {
setSavedAction(queued.action);
setSyncState(queued.syncState);
} else {
setError(true);
setAttemptedAction(undefined);
}
} catch {
setError(true);
}
}
}

Render the restored state before the buttons: syncState === "pending" uses t("medication.pendingSync"), and syncState === "needs-attention" uses t("medication.syncError"). Keep the action locked while either state exists. When a reconnect flush succeeds, reload History; its server event becomes the confirmed state. A local queued report is never labelled Saved to Ovok.

In AdherenceActions, show those states before the local savedAction state so an offline report is not presented as a confirmed server record:

if (existingAction) return <Text>{t("medication.patientReported")}: {t(`medication.${existingAction}`)}</Text>;
if (syncState === "pending") return <Text>{t("medication.pendingSync")}</Text>;
if (syncState === "needs-attention") return <Text accessibilityRole="alert">{t("medication.syncError")}</Text>;
if (savedAction) return <Text>{t("medication.patientReported")}: {t(`medication.${savedAction}`)}</Text>;

If the request fails, keep the queue row and display Pending sync or Needs attention according to the failure. Do not show Saved to Ovok until a successful response. The transaction's conditional create means retrying the same occurrence remains safe.

Watch connectivity and app-resume events from the authenticated patient area:

import NetInfo from "@react-native-community/netinfo";
import { AppState } from "react-native";

export function watchForReconnect(sync: () => Promise<void>, onError: (error: Error) => void) {
let running = false;
const runIfOnline = async () => {
if (running) return;
running = true;
try {
const state = await NetInfo.fetch();
if (state.isConnected && state.isInternetReachable !== false) {
await sync();
}
} catch (reason) {
onError(reason instanceof Error ? reason : new Error(String(reason)));
} finally {
running = false;
}
};
void runIfOnline();
const stopNetworkListener = NetInfo.addEventListener((state) => {
if (state.isConnected && state.isInternetReachable !== false) void runIfOnline();
});
const appStateListener = AppState.addEventListener("change", (state) => {
if (state === "active") void runIfOnline();
});

return () => {
stopNetworkListener();
appStateListener.remove();
};
}

Call watchForReconnect(async () => { await flushAdherenceOutbox(db, client, patientId); await reloadHistory(); }, showSyncError) only after the session confirms that patientId is the authenticated Patient. Keep processing sequentially. On a network error, leave the row pending; on a 4xx response, mark it needs-attention, show a sync error, and stop automatic retries until the app or support workflow resolves it. Conditional create in step 8 makes an uncertain retry idempotent.

Create src/features/medication/use-medication-reconnect-sync.ts. Mount this hook from the authenticated patient layout so it can flush while the app is open. It owns its listener cleanup and reloads History after the server confirms a queued report:

import { useClient } from "@ovok/core";
import { useSession } from "@ovok/native";
import * as React from "react";

import { flushAdherenceOutbox, openMedicationDatabase, watchForReconnect } from "./offline-storage";
import { useMedicationHistory } from "./history";

export function useMedicationReconnectSync() {
const client = useClient();
const { isAuthenticated, profile } = useSession({ sessions: false });
const patientId = profile?.resourceType === "Patient" ? profile.id : undefined;
const { reload: reloadHistory } = useMedicationHistory(patientId ?? "");
const [syncError, setSyncError] = React.useState<Error>();

React.useEffect(() => {
if (!isAuthenticated || !patientId) return;
let current = true;
let stop = () => {};
void openMedicationDatabase()
.then((db) => {
if (!current) return;
stop = watchForReconnect(async () => {
await flushAdherenceOutbox(db, client, patientId);
reloadHistory();
if (current) setSyncError(undefined);
}, (error) => { if (current) setSyncError(error); });
})
.catch((reason: unknown) => {
if (current) setSyncError(reason instanceof Error ? reason : new Error(String(reason)));
});
return () => {
current = false;
stop();
};
}, [client, isAuthenticated, patientId, reloadHistory]);

return { syncError };
}

Ovok's built-in offline queue is for measurements, not generic FHIR resources. This plan cache and adherence outbox are application-owned. SQLCipher reduces exposure at rest; it does not replace a threat model, device lock policy, retention rules, consent, or account isolation. Never flush one Patient's data while another Patient is signed in.

Finish Settings​

Create src/app/(patient)/settings.tsx. This small screen uses the Native session for account state, persists only local preferences, and lets the CMS hook fetch the chosen locale. Add a real privacy/consent route owned by your product; the tutorial does not invent an Ovok consent API.

import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
import { useSession } from "@ovok/native";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { Pressable, Switch, Text, View } from "react-native";

import i18n from "../../localization/i18n";
import { ovokConfig } from "../../config/ovok";
import { occurrencesForNextSevenDays, reconcileMedicationReminders, reminderPreferenceKey, requestReminderPermission } from "../../features/medication/reminders";
import { useMedicationPlan } from "../../features/medication/use-medication-plan";

const languagePreferenceKey = "ovok-app-language";

export default function SettingsScreen() {
const { plan } = useMedicationPlan();
const { profile, logout } = useSession({ sessions: false });
const { t } = useTranslation();
const [remindersEnabled, setRemindersEnabled] = useState(false);
const [reminderMessage, setReminderMessage] = useState<string>();

useEffect(() => {
void SecureStore.getItemAsync(reminderPreferenceKey).then((value) => setRemindersEnabled(value === "true"));
}, []);

async function setReminderPreference(enabled: boolean) {
try {
if (enabled && !(await requestReminderPermission(t("medication.notificationChannel")))) {
setReminderMessage(t("settings.notificationsDenied"));
return;
}
await SecureStore.setItemAsync(reminderPreferenceKey, String(enabled));
setRemindersEnabled(enabled);
await reconcileMedicationReminders(enabled ? occurrencesForNextSevenDays(plan) : [], {
title: t("medication.reminderTitle"),
body: t("medication.reminderBody"),
});
setReminderMessage(undefined);
} catch {
setReminderMessage(t("medication.reminderScheduleError"));
}
}

async function setLanguage(language: "en" | "de") {
await SecureStore.setItemAsync(languagePreferenceKey, language);
await i18n.changeLanguage(language);
}

async function signOut() {
try {
await reconcileMedicationReminders([], {
title: t("medication.reminderTitle"),
body: t("medication.reminderBody"),
});
} catch {
setReminderMessage(t("medication.reminderScheduleError"));
}
await logout();
}

return (
<View style={{ padding: 20, gap: 16 }}>
<Text accessibilityRole="header">{t("settings.title")}</Text>
<Text>{t("settings.profile")}: {profile?.id ?? "—"}</Text>
<Text>{t("settings.language")}</Text>
<View style={{ flexDirection: "row", gap: 12 }}>
<Pressable accessibilityRole="button" onPress={() => void setLanguage("en")}><Text>{t("settings.english")}</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => void setLanguage("de")}><Text>{t("settings.german")}</Text></Pressable>
</View>
<Text>{t("settings.reminders")}</Text>
<Switch accessibilityLabel={t("settings.reminders")} value={remindersEnabled} onValueChange={(value) => void setReminderPreference(value)} />
{!!reminderMessage && <Text accessibilityRole="alert">{reminderMessage}</Text>}
<Text>{t("settings.privacyAndConsent")}</Text>
<Text>{t("settings.appInformation")}: {Constants.expoConfig?.version ?? "—"}</Text>
<Text>{ovokConfig.baseUrl}</Text>
<Pressable accessibilityRole="button" onPress={() => void signOut()}>
<Text>{t("settings.signOut")}</Text>
</Pressable>
</View>
);
}

When the patient returns to Today, useMedicationReminderSchedule() reads the saved preference and rebuilds or cancels this app's local reminders. Pending adherence reports remain keyed to the Patient id; the reconnect worker only queries the currently authenticated Patient. The settings screen does not edit prescriptions or offer clinical advice.

Do not add dose changes, interaction checking, or medication advice to Settings.

Add German through the Ovok CMS​

In the same published CMS translations groups used in step 4, add German (de) values for every user-facing key. Do not add en.json or de.json files. The Native SDK's useCmsTranslations({ tenantCode }) merges published CMS translations into the app's shared i18next instance. Use i18n.changeLanguage("de") for the app language control, and format dates using the selected language while preserving the schedule-time rule from step 4.

Create or update these German values in the same CMS translation groups. Keep all group slugs, keys, and {{...}} interpolation names unchanged.

medication German values​

KeyGerman value
todayTitleHeutige Medikamente
loadingWird geladen…
loadErrorDiese Informationen konnten nicht geladen werden. Bitte versuchen Sie es erneut.
emptyPlanFür dieses Konto ist kein aktiver Medikamentenplan verfügbar.
noSupportedScheduleEs sind noch keine unterstützten Angaben zum Zeitplan verfügbar.
morningMorgen
afternoonNachmittag
eveningAbend
nightNacht
upcomingBevorstehend
dueFällig
takenEingenommen
skippedAusgelassen
missedNicht erfasst
missedMessageFür diese Dosis wurde keine Einnahme erfasst.
unknownNameKein Medikamentenname angegeben
requestStatusPlanstatus: {{status}}
dose{{value}} {{unit}}
startDateBeginn: {{date}}
endDateEnde: {{date}}
notificationChannelErinnerungen
reminderTitleErinnerung
reminderBodySie haben eine geplante Erinnerung. Öffnen Sie die App, um Details anzusehen.
reminderScheduleErrorErinnerungen konnten nicht aktualisiert werden. Öffnen Sie die Einstellungen und versuchen Sie es erneut.
savingIhre Rückmeldung wird gespeichert…
markTakenAls eingenommen markieren
markSkippedAls ausgelassen markieren
patientReportedVom Patienten gemeldet
reportErrorDiese Rückmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.
retryReportErneut senden
historyTitleMedikamentenverlauf
filterAllGesamter Zeitraum
filter7DaysLetzte 7 Tage
filter30DaysLetzte 30 Tage
noHistoryKeine Medikamentenmeldungen in diesem Zeitraum.
recordedStatusErfasster Status: {{status}}
viewDetailsDetails ansehen
notInPlanDieses Medikament ist nicht im aktuellen Plan enthalten.
lastSyncedZuletzt synchronisiert: {{date}}
offlineCacheErrorDiese Informationen konnten nicht für die Offline-Nutzung gespeichert werden. Beim nächsten Mal sind sie möglicherweise ohne Verbindung nicht verfügbar.
pendingSyncIhre Rückmeldung ist auf diesem Gerät gespeichert und wartet auf die Synchronisierung.
syncErrorFür eine gespeicherte Rückmeldung ist eine Aktion erforderlich, bevor sie synchronisiert werden kann.

settings German values​

KeyGerman value
titleEinstellungen
profilePatienten-ID
languageSprache
englishEnglisch
germanDeutsch
remindersErinnerungen
privacyAndConsentDatenschutz und Einwilligungen
signOutAbmelden
appInformationApp-Informationen
notificationsDeniedBenachrichtigungen sind deaktiviert. Sie können sie in den Geräteeinstellungen ändern.

Set register.registrationFailed to Ihr Konto konnte nicht erstellt werden. and register.tryAgain to Prüfen Sie Ihre Angaben und versuchen Sie es erneut. Translate every Native SDK sign-in and registration key used by the app; the sign-in reference and registration reference list the full current key sets. The CMS applies English fallback one string at a time while German translations are incomplete.

The translations collection is open tenant content. Store only UI strings there—never medication plans, Patient names, PHI, or credentials. The hook keeps translations in memory; if the app cold-starts without a network connection, the CMS cannot provide a newly requested language. The root gate below restores the saved locale before the CMS hook runs, then step 10's encrypted cache supplies a previously fetched translation bundle when offline. Keep the CMS as the editable source of truth; the encrypted local copy is only a cache. See Use Ovok CMS with i18n for the CMS schema, fallback behavior, caching, and the documented HTTP loading option.

Replace the LocalizationGate from step 4 so the saved language is selected before CmsTranslations reads the CMS:

import * as React from "react";
import * as SecureStore from "expo-secure-store";
import { ActivityIndicator } from "react-native";

import i18n, { localizationReady } from "./i18n";

const languagePreferenceKey = "ovok-app-language";

export function LocalizationGate({ children }: React.PropsWithChildren) {
const [ready, setReady] = React.useState(false);

React.useEffect(() => {
let current = true;
void (async () => {
try {
await localizationReady;
const language = await SecureStore.getItemAsync(languagePreferenceKey);
if (language === "en" || language === "de") await i18n.changeLanguage(language);
} catch {
// Keep the initialized English language if local preference storage is unavailable.
} finally {
if (current) setReady(true);
}
})();
return () => { current = false; };
}, []);

if (!ready) return <ActivityIndicator />;
return <CmsTranslations>{children}</CmsTranslations>;
}

Replace the CmsTranslations body from step 4 with this offline-aware version. It still loads and publishes through Ovok CMS; SQLite only caches the most recently fetched public translation tree for each locale:

import * as React from "react";
import { ActivityIndicator, Button, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useCmsLocale, useCmsTranslations } from "@ovok/native/cms";

import { ovokConfig } from "../config/ovok";
import { cacheCmsTranslations, openMedicationDatabase, readCachedCmsTranslations } from "../features/medication/offline-storage";

export function CmsTranslations({ children }: React.PropsWithChildren) {
const locale = useCmsLocale();
const { i18n } = useTranslation();
const { data, loading, error, reload } = useCmsTranslations({
tenantCode: ovokConfig.tenantCode,
environment: "staging",
});
const [ready, setReady] = React.useState(false);
const [cached, setCached] = React.useState(false);

React.useEffect(() => {
let current = true;
setReady(false);
setCached(false);
if (loading) return () => { current = false; };

void (async () => {
try {
const db = await openMedicationDatabase();
if (!error && data) {
await cacheCmsTranslations(db, locale, data);
if (current) setReady(true);
return;
}
if (error) {
const tree = await readCachedCmsTranslations(db, locale);
if (tree) {
i18n.addResourceBundle(locale, "translation", tree, true, false);
await i18n.changeLanguage(locale);
if (current) setCached(true);
}
if (current) setReady(true);
return;
}
if (current) setReady(true);
} catch {
if (current) setReady(true);
}
})();

return () => { current = false; };
}, [data, error, i18n, loading, locale]);

if (loading || !ready) return <ActivityIndicator />;
if ((error || !data) && !cached) {
return (
<View>
<Text>App language content is unavailable. Connect to the internet and try again.</Text>
<Button title="Retry" onPress={() => void reload()} />
</View>
);
}
return <>{children}</>;
}

Add the translation_cache table from the database setup above before using this gate. The CMS hook merges a successful response into i18next; the fallback branch merges only the encrypted cached tree. On a first offline launch with no cached locale, the gate shows the retry state rather than rendering untranslated keys. Keep this bootstrap message minimal; all normal app copy comes from CMS.

Important Ovok decisions​

  • The medication plan remains FHIR data; only the UI translations come from the CMS.
  • Local scheduling continues without a network after the OS has accepted notifications. The app does not claim guaranteed delivery.
  • A queued report is marked Pending sync, not Saved to Ovok. It becomes synced only after a successful server response.
  • MedicationRequest records remain server-authoritative. The offline cache is read-only; patients cannot edit the plan offline.
  • The app does not infer a skipped dose recommendation, dose adjustment, or medication safety result.

Replace the patient layout's Slot from step 5 with Expo Router tabs so every finished route is reachable. Keep the authentication checks at the top of that layout:

import { Redirect, Tabs } from "expo-router";
import { useSession } from "@ovok/native";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Text, View } from "react-native";
import { useMedicationReconnectSync } from "../../features/medication/use-medication-reconnect-sync";

export default function PatientLayout() {
const { status, isAuthenticated, profile } = useSession({ sessions: false });
const { t } = useTranslation();
const { syncError } = useMedicationReconnectSync();
if (status === "loading") return <ActivityIndicator />;
if (!isAuthenticated || profile?.resourceType !== "Patient") return <Redirect href="/sign-in" />;
return (
<View style={{ flex: 1 }}>
<Tabs>
<Tabs.Screen name="index" options={{ title: t("medication.todayTitle") }} />
<Tabs.Screen name="history" options={{ title: t("medication.historyTitle") }} />
<Tabs.Screen name="settings" options={{ title: t("settings.title") }} />
<Tabs.Screen name="medications/[requestId]" options={{ href: null }} />
</Tabs>
{!!syncError && <Text accessibilityRole="alert">{t("medication.syncError")}</Text>}
</View>
);
}

Expected result​

The final app can sign in a synthetic Patient, read and cache the Patient's current plan, show today's supported schedule, fire private local reminders, record Taken and Skipped reports, retry those reports safely when online, show paginated history, and display English/German UI strings authored in the Ovok CMS.

Common errors and troubleshooting​

  • SQLCipher reports a wrong key or database error: set PRAGMA key immediately after opening the database and before any query; rebuild the native app after changing the plugin.
  • The app cannot decrypt its cache after reinstall: the secure key may have been removed by the OS. Discard the unreadable cache and refresh from Ovok when online; never bypass the encryption failure.
  • An outbox item disappears before sync: delete only after the transaction entry returns a 2xx status. Keep failed or ambiguous items for an idempotent retry.
  • A queued event is sent for the wrong account: query rows by the authenticated Patient id and keep Patient ids as part of every database key.
  • German text is missing: publish the de values in the same environment, then confirm the tenant, locale, group slug, and active i18next instance.
  • A local notification is missing after restart: check OS permission, scheduled notification list, and platform delivery settings. Reconcile future reminders when the app resumes.

Previous / Next​

Previous: step 9: build History and Medication Details · Tutorial complete: Medication Reminder overview.