Step 7: Build the measurement screen
What we are building
A patient dashboard for blood pressure, ECG, pulse when supplied, body weight, connection state, and recent saved readings. The flow is: device reading → normalized SDK result → authenticated Patient association → Ovok measurement save → FHIR-backed query → screen refresh.
What you should already have
- The authenticated route group from step 6.
BTProvidermounted once inBluetoothWorkspacefrom step 5.acceptedDevicescontainingIntegratedDevices.BP2andIntegratedDevices.F4.transaction-bundlesenabled in the sandbox project.
The implementation
Save BP2 and scale readings from the provider event
Replace src/features/bluetooth/device-state.tsx with this version. It keeps the provider in one place and saves the blood-pressure, ECG, and body-weight result categories:
import {
MeasurementTypeKey,
useClient,
} from "@ovok/core";
import { useSession } from "@ovok/native";
import { BTProvider } from "@ovok/native/bt-management";
import { BleManager } from "react-native-ble-plx";
import * as React from "react";
import { acceptedDevices } from "./accepted-devices";
type DeviceInfo = {
name: string;
status: string;
batteryLevel?: number;
hasError?: boolean;
};
type DeviceState = {
devices: Record<string, DeviceInfo>;
saveState: "idle" | "saving" | "saved" | "error";
lastSavedAt: Date | null;
};
export const DeviceStateContext = React.createContext<DeviceState | null>(null);
const bleManager = new BleManager();
export function BluetoothWorkspace({
children,
}: React.PropsWithChildren) {
const client = useClient();
const { profile } = useSession({ sessions: false });
const [devices, setDevices] = React.useState<DeviceState["devices"]>({});
const [saveState, setSaveState] = React.useState<DeviceState["saveState"]>("idle");
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null);
return (
<DeviceStateContext.Provider value={{ devices, saveState, lastSavedAt }}>
<BTProvider
bleManager={bleManager}
acceptedDevices={acceptedDevices}
onDeviceStatusChanged={({ deviceData, status }) => {
setDevices((current) => ({
...current,
[deviceData.id]: {
...current[deviceData.id],
name: deviceData.model || deviceData.localName || "",
status,
hasError: false,
},
}));
}}
onConnectionEvent={({ deviceData, batteryLevel }) => {
if (typeof batteryLevel !== "number") return;
setDevices((current) => ({
...current,
[deviceData.id]: {
...current[deviceData.id],
name: deviceData.model || deviceData.localName || "",
status: current[deviceData.id]?.status ?? "Disconnected",
batteryLevel,
},
}));
}}
onError={({ deviceData }) => {
if (!deviceData) return;
setDevices((current) => ({
...current,
[deviceData.id]: {
...current[deviceData.id],
name: deviceData.model || deviceData.localName || "",
status: current[deviceData.id]?.status ?? "Disconnected",
hasError: true,
},
}));
}}
onResult={({ deviceData, data }) => {
if (
data.measurementTypeKey !== MeasurementTypeKey.bloodPressure &&
data.measurementTypeKey !== MeasurementTypeKey.ecg &&
data.measurementTypeKey !== MeasurementTypeKey.bodyWeight
) {
return;
}
if (!profile?.id) {
setSaveState("error");
return;
}
setSaveState("saving");
void client
.saveMeasurement({
patientId: profile.id,
device: deviceData,
measurement: data,
effectiveDateTime: data.recordedAt ?? new Date(),
})
.then(() => {
setLastSavedAt(new Date());
setSaveState("saved");
})
.catch(() => setSaveState("error"));
}}
>
{children}
</BTProvider>
</DeviceStateContext.Provider>
);
}
onResult is an immediate callback; the provider does not wait for this save. This foreground tutorial reports the outcome in the app and does not promise offline delivery. If your product must retain readings through process death or network loss, implement the documented Native SDK background delivery flow and its durable storage acknowledgement separately.
The device's recordedAt is used when supplied. Otherwise the app uses the time it received the reading. bodyWeight is saved in kilograms. The BP2 reports ECG as a separate ecg result with its waveform samples in diagramPoints; the typed measurement API saves those samples, duration, optional heart rate, and optional device-reported result label. See the ECG, blood-pressure, and body-weight references.
Build the dashboard
Create app/(patient)/index.tsx:
import {
MeasurementTypeKey,
useObservations,
type BloodPressureMeasurement,
type BodyWeightMeasurement,
} from "@ovok/core";
import { useSession } from "@ovok/native";
import { EcgStripViewer } from "@ovok/native/ui";
import { useEffect, useContext } from "react";
import { ActivityIndicator, 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 readingTypes = [
MeasurementTypeKey.bloodPressure,
MeasurementTypeKey.ecg,
MeasurementTypeKey.bodyWeight,
] as const;
const previewMeasurements: (BloodPressureMeasurement | BodyWeightMeasurement)[] = __DEV__
? [
{
measurementTypeKey: MeasurementTypeKey.bloodPressure,
systolic: 121,
diastolic: 79,
heartRate: 68,
recordedAt: new Date(),
},
{
measurementTypeKey: MeasurementTypeKey.bodyWeight,
bodyWeight: 72.4,
recordedAt: new Date(),
},
]
: [];
function PatientReadings({ patientId }: { patientId: string }) {
const { t, i18n } = useTranslation();
const deviceState = useContext(DeviceStateContext);
const {
measurements,
loading,
error,
refetch,
} = useObservations({
types: readingTypes,
patientId,
count: 20,
sort: "-date",
});
useEffect(() => {
if (deviceState?.lastSavedAt) void refetch();
}, [deviceState?.lastSavedAt, refetch]);
if (loading) return <ActivityIndicator />;
if (error) {
return (
<View>
<Text>{t("readings.loadFailed")}</Text>
<Button onPress={() => void refetch()}>{t("common.retry")}</Button>
</View>
);
}
if (measurements.length === 0) {
if (!__DEV__) return <Text>{t("readings.empty")}</Text>;
}
const showingPreview = __DEV__ && measurements.length === 0;
const visibleMeasurements = showingPreview ? previewMeasurements : measurements;
return (
<View>
{showingPreview && <Text>{t("readings.preview")}</Text>}
{visibleMeasurements.map((measurement, index) => {
const time = measurement.recordedAt?.toLocaleString(i18n.language);
const title =
measurement.measurementTypeKey === MeasurementTypeKey.bloodPressure
? t("readings.bloodPressure")
: measurement.measurementTypeKey === MeasurementTypeKey.bodyWeight
? t("readings.bodyWeight")
: t("readings.ecg");
return (
<Card key={`${measurement.measurementTypeKey}-${time ?? index}`}>
<Card.Title title={title} subtitle={time ?? t("readings.timeUnavailable")} />
<Card.Content>
{measurement.measurementTypeKey === MeasurementTypeKey.bloodPressure ? (
<>
<Text>
{t("readings.bloodPressureValue", {
systolic: measurement.systolic ?? "—",
diastolic: measurement.diastolic ?? "—",
})}
</Text>
<Text>
{t("readings.pulseValue", {
value: measurement.heartRate ?? "—",
})}
</Text>
</>
) : measurement.measurementTypeKey === MeasurementTypeKey.bodyWeight ? (
<Text>
{t("readings.weightValue", {
value: measurement.bodyWeight ?? "—",
})}
</Text>
) : (
<>
{measurement.heartRate != null && (
<Text>
{t("readings.pulseValue", { value: measurement.heartRate })}
</Text>
)}
{measurement.diagnosticResult && (
<Text>
{t("readings.ecgDeviceResult", {
value: measurement.diagnosticResult,
})}
</Text>
)}
{measurement.diagramPoints?.length &&
measurement.duration &&
measurement.duration > 0 ? (
<EcgStripViewer
samples={measurement.diagramPoints}
sampleRate={
measurement.diagramPoints.length / measurement.duration
}
pageDurationSeconds={10}
/>
) : (
<Text>{t("readings.ecgWaveformUnavailable")}</Text>
)}
<Text>{t("readings.ecgDisclaimer")}</Text>
</>
)}
</Card.Content>
</Card>
);
})}
</View>
);
}
export default function MeasurementScreen() {
const { t } = useTranslation();
const { status, profile } = useSession({ sessions: false });
const deviceState = useContext(DeviceStateContext);
if (status === "loading") return <ActivityIndicator />;
if (status !== "authenticated" || !profile?.id) {
return <Text>{t("auth.sessionUnavailable")}</Text>;
}
const connected = Object.values(deviceState?.devices ?? {}).some(
(device) => device.status === "Connected" || device.status === "Measuring",
);
return (
<ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}>
<Text variant="headlineMedium">{t("readings.title")}</Text>
<Card>
<Card.Content>
<Text>{connected ? t("devices.connected") : t("devices.notConnected")}</Text>
<Text>{t("readings.devicePrompt")}</Text>
</Card.Content>
</Card>
{deviceState?.saveState === "saving" && <Text>{t("readings.saving")}</Text>}
{deviceState?.saveState === "saved" && <Text>{t("readings.saved")}</Text>}
{deviceState?.saveState === "error" && (
<Text accessibilityRole="alert">{t("readings.saveFailed")}</Text>
)}
<PatientReadings patientId={profile.id} />
</ScrollView>
);
}
The person starts a blood-pressure measurement or ECG recording with the BP2's physical controls and takes a weight reading by standing on the scale. This provider integration does not expose a startMeasurement() command. Do not add a fake start button. During development, the dashboard shows two clearly labelled in-memory blood-pressure and weight samples only when the server has no readings; it never passes those fixtures to saveMeasurement(). Do not synthesize ECG traces or use sample readings to validate device behavior or clinical workflows. See useObservations for query filters and return states.
Important Ovok decisions
- The patient route is already authenticated;
patientIdis still passed explicitly so the query is scoped to that Patient. - A blood-pressure reading can contain an optional heart-rate value. Render it as unavailable when the monitor did not provide it; do not derive it.
- The BP2's ECG
diagramPointsare waveform samples in millivolts.EcgStripVieweronly presents the supplied trace; it does not decode, analyze, or diagnose it. - A device-reported ECG label is shown as source data. It is not an Ovok interpretation or a diagnosis.
- The app refreshes after a successful save. A visible “saved” state means the immediate request completed; this sample has no durable offline queue.
- Do not interpret readings or assign alert thresholds in this UI. Alert decisions and care escalation belong to the configured clinical workflow.
Expected result
With a paired device, blood-pressure, ECG, or weight data arrives through BTProvider.onResult, the app saves it to the authenticated Patient, and the dashboard refreshes from Ovok. ECG recordings with valid waveform samples and timing appear in EcgStripViewer. Without a device, the dashboard still shows its connection, empty, loading, and error states.
Common errors and troubleshooting
- The provider sees a reading but it is not saved: check that the result type is blood pressure, ECG, or body weight, then check the current Patient session, API environment, AccessPolicy, and
transaction-bundlesfeature. - The ECG waveform is missing: the viewer needs a non-empty
diagramPointsarray and a positiveduration; show the source metadata and unavailable state instead of fabricating samples. - Weight looks off: confirm the UI says kilograms and never treats the value as pounds.
- A reading disappears when the app closes before upload: this sample uses immediate foreground writes. Add and validate the SDK's opt-in durable background delivery before promising queued or background uploads.
- Pulse is blank: the BP2 may not include heart rate in a particular result; the app correctly leaves it unavailable.
Next step
Continue to step 8: build the Devices section.