Step 8: Build the Devices section
What we are building
A Devices tab where the patient can discover and pair supported hardware, see the current connection and optional battery state, and remove a remembered device. Pairing state is stored locally; measurements continue to be saved separately through the flow from step 7.
What you should already have
BTProviderwith the accepted BP2 and F4 declarations.- The
DeviceStateContextconnection and last-upload state from step 7. expo-secure-storeinstalled in step 1.
The implementation
Update app/(patient)/_layout.tsx to use tabs in the authenticated area:
import { useSession } from "@ovok/native";
import { Redirect, Tabs } from "expo-router";
import { ActivityIndicator } from "react-native";
import { useTranslation } from "react-i18next";
import { BluetoothWorkspace } from "../../src/features/bluetooth/device-state";
export default function PatientLayout() {
const { status } = useSession({ sessions: false });
const { t } = useTranslation();
if (status === "loading") return <ActivityIndicator />;
if (status !== "authenticated") return <Redirect href="/sign-in" />;
return (
<BluetoothWorkspace>
<Tabs>
<Tabs.Screen name="index" options={{ title: t("tabs.home") }} />
<Tabs.Screen name="devices" options={{ title: t("tabs.devices") }} />
<Tabs.Screen name="settings" options={{ title: t("tabs.settings") }} />
</Tabs>
</BluetoothWorkspace>
);
}
Create app/(patient)/devices.tsx:
import { PairingFlow } from "@ovok/native/ui";
import * as SecureStore from "expo-secure-store";
import { useContext } from "react";
import { Alert, Linking, ScrollView, View } from "react-native";
import { Button, Card, Text } from "react-native-paper";
import { useTranslation } from "react-i18next";
import { DeviceStateContext } from "../../src/features/bluetooth/device-state";
const pairedDeviceStorage = {
getItem: (key: string) => SecureStore.getItemAsync(key),
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
};
export default function DevicesScreen() {
const { t, i18n } = useTranslation();
const deviceState = useContext(DeviceStateContext);
return (
<ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}>
<Text variant="headlineMedium">{t("devices.title")}</Text>
<Text>{t("devices.intro")}</Text>
{Object.entries(deviceState?.devices ?? {}).map(([id, device]) => (
<Card key={id}>
<Card.Title
title={device.name || t("devices.supportedDevice")}
subtitle={t(`devices.status.${device.status}`, device.status)}
/>
<Card.Content>
{typeof device.batteryLevel === "number" && (
<Text>
{t("devices.battery", { value: device.batteryLevel })}
</Text>
)}
{device.hasError && <Text>{t("devices.connectionProblem")}</Text>}
</Card.Content>
</Card>
))}
<Card>
<Card.Content>
<Text>{t("devices.lastUpload")}</Text>
<Text>
{deviceState?.lastSavedAt
? deviceState.lastSavedAt.toLocaleString(i18n.language)
: t("devices.noRecentUpload")}
</Text>
</Card.Content>
</Card>
<PairingFlow
storage={pairedDeviceStorage}
labels={{
header: t("devices.addTitle"),
description: t("devices.addDescription"),
empty: t("devices.noneNearby"),
device: t("devices.supportedDevice"),
pair: t("devices.pair"),
pairingCodePrompt: t("devices.pairingCodePrompt"),
pairingCode: t("devices.pairingCode"),
continue: t("common.continue"),
pairing: t("devices.pairing"),
paired: t("devices.paired"),
retry: t("common.retry"),
remembered: t("devices.remembered"),
}}
renderDevice={(device, actions) => (
<Card key={device.id}>
<Card.Title
title={
device.deviceData.model ||
device.deviceData.localName ||
t("devices.supportedDevice")
}
subtitle={t(`devices.pairStatus.${actions.status}`)}
/>
<Card.Actions>
{actions.status === "paired" ? (
<Button onPress={() => void actions.forget()}>
{t("devices.remove")}
</Button>
) : (
<Button
loading={actions.status === "pairing"}
disabled={actions.status === "pairing"}
onPress={() => void actions.pair()}
>
{t("devices.pair")}
</Button>
)}
</Card.Actions>
</Card>
)}
onError={() =>
Alert.alert(t("devices.pairingFailed"), t("errors.tryAgain"))
}
/>
<View>
<Text>{t("devices.catalogPrompt")}</Text>
<Button
onPress={() =>
void Linking.openURL(
"https://docs.ovok.com/native-sdk/components/bt-management/supported-devices/",
)
}
>
{t("devices.openCatalog")}
</Button>
</View>
</ScrollView>
);
}
The PairingFlow must render beneath the BTProvider from step 5. It owns discovery UI and reads the provider runtime; it does not create a second BLE manager. Its forget() action forgets the active device and removes the remembered ID. The SDK currently reports battery percentage only when the connected device exposes the supported battery service.
The catalog contains artwork, device details, and image credits; use those assets and credits through the supported-device catalog rather than copying manufacturer images into this app. Follow the device's supplied instructions for the actual measurement procedure.
Important Ovok decisions
- The pairing ID is saved locally by
PairingFlow; it is not an account credential or FHIR resource. - This tutorial stores only the last successful upload timestamp in memory. It resets when this app session ends; it is not a durable per-device sync history.
- Do not display Bluetooth peripheral IDs to patients or log them.
- Bluetooth pairing and measurement are separate states. A paired device is not proof that a reading was saved.
Expected result
The authenticated patient can see discovered supported devices, start pairing, see paired status, and forget the current device. Connected devices show status and battery only when the runtime provides it; successful measurement upload time is shown separately.
Common errors and troubleshooting
- The list is empty: use a physical phone, grant Bluetooth permission, wake the device, and keep the app open while discovery runs.
- The device appears but pairing fails: check Bluetooth is enabled, device is not connected to another phone, and follow the model-specific instructions in the catalog or device packaging.
- Battery is absent: the device may not expose a battery level; do not invent or estimate one.
- A pair action remains pending: inspect
PairingFlow.onErrorin development and verify that only one provider owns this scan. - Last upload disappears after restart: this page intentionally reports app-session state only. A durable sync history needs an app-owned persisted record or a server query.
Next step
Continue to step 9: build Settings.