Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions homeflow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# HomeFlow

A research app for Stanford's CS342 — Building for Digital Health. HomeFlow helps men with BPH (benign prostatic hyperplasia) passively track their voiding patterns, sleep, and activity before and after bladder outlet surgery, so researchers can actually measure whether surgery works in the real world — not just in a clinic.

Built by Stream Team (Team 3).

---

## What it does

Right now, when a patient has prostate surgery, the main outcome measure is a questionnaire they fill out in a waiting room. HomeFlow replaces that with continuous, passive data collection at home using hardware and sensors the patient already has.

Concretely, it:

- Records every void using a **Throne uroflow device** attached to the patient's toilet, capturing flow rate, volume, and flow curve shape
- Pulls **activity, sleep, and heart rate** data from Apple Watch and HealthKit automatically, once per day
- Collects a **baseline IPSS symptom score** at enrollment and follow-up scores at 1 and 12 weeks post-surgery
- Walks patients through **enrollment and informed consent** entirely in-app, with a signed PDF stored securely
- Syncs everything to a **Firebase backend** for the research team to analyze

The patient doesn't need to do anything after setup. The app runs in the background.

---

## The patient journey

```
Download app
Eligibility screening (takes ~2 min)
Informed consent + digital signature
Create account
Grant HealthKit + Throne permissions
Medical history review (pre-filled from Apple Health)
Baseline IPSS survey
Done — passive collection starts
Follow-up IPSS at 1 week and 12 weeks post-surgery
```

---

## Tech stack

| Layer | What we use |
|---|---|
| App framework | React Native + Expo 54, TypeScript |
| Navigation | Expo Router (file-based) |
| Uroflow data | Throne API |
| Health data | Apple HealthKit + Apple Watch |
| Clinical records | Apple Health FHIR (CDA/HL7 XML, parsed on-device) |
| Backend | Firebase (Firestore + Storage + Cloud Functions) |
| Auth | Firebase Auth (Apple Sign In) |
| Forms | Formik + Yup |
| Surveys | Custom IPSS questionnaire component |

---

## Project structure

```
app/
(onboarding)/ # Enrollment flow: welcome → eligibility → consent → account → permissions → medical history → survey
(tabs)/ # Main app: home, voiding log, profile
_layout.tsx # Root layout, auth guards, provider hierarchy

src/services/
clinicalNotesSync.ts # HealthKit FHIR clinical notes → parse CDA XML → Firebase Storage + Firestore
consentPdfSync.ts # Generate signed consent PDF → Firebase Storage
healthkitSync.ts # Activity, sleep, HRV → Firestore
throneFirestore.ts # Throne session/metric reads + medical history writes
cdaParser.ts # Decode and parse HL7 CDA XML from Apple Health

lib/
services/ # HealthKit client, consent service, onboarding state
consent/ # IRB consent document content
questionnaires/ # IPSS and eligibility questionnaire definitions

components/
ui/ # SignaturePad, icons, shared primitives
onboarding/ # Progress bar, consent agreement, continue button

functions/ # Firebase Cloud Functions (Throne data ingestion)
```

---

## Getting started

You'll need:
- macOS with Xcode installed
- A physical iPhone (HealthKit does not work on simulators)
- Node.js 18+
- Firebase CLI: `npm install -g firebase-tools`
- A `.env` file with the keys below (ask a teammate)

```bash
# Install dependencies
npm install

# Start the dev server
npx expo start

# Run on physical device (replace UDID with yours)
npx expo run:ios --device YOUR_DEVICE_UDID

# Run tests
npm test

# Deploy Firestore rules
firebase deploy --only firestore

# Deploy Storage rules
firebase deploy --only storage

# Deploy Cloud Functions
firebase deploy --only functions
```

### Environment variables

```
EXPO_PUBLIC_FIREBASE_API_KEY=
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=
EXPO_PUBLIC_FIREBASE_PROJECT_ID=
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
EXPO_PUBLIC_FIREBASE_APP_ID=
EXPO_PUBLIC_BACKEND_TYPE=firebase
THRONE_BASE_URL=
THRONE_API_KEY=
THRONE_TIMEZONE=
STUDY_ID=
```

> `THRONE_API_KEY` is server-side only — it lives in Cloud Functions and never reaches the client bundle.

---

## Data model

Everything in Firestore is scoped under `users/{uid}/`:

| Collection | What's in it |
|---|---|
| `throne_sessions/` | Void events from the Throne device (written by Cloud Function) |
| `throne_metrics/` | Flow rate curves per session |
| `hk_stepCount/` | Daily step counts from HealthKit |
| `hk_sleepAnalysis/` | Sleep stages and duration |
| `hk_heartRate/` | Heart rate samples |
| `hk_heartRateVariabilitySDNN/` | HRV samples |
| `medical_history/current` | Confirmed demographics, medications, conditions, procedures |
| `medical_history_prefill/latest` | FHIR-parsed pre-fill from Apple Health (read-only to user) |
| `clinical_notes/` | CDA XML notes from Apple Health, parsed to readable text |
| `surgery_date/current` | Patient's scheduled surgery date |
| `consent_response/current` | Consent record + storage path of signed PDF |

Firebase Storage holds:
- `users/{uid}/consent_pdfs/` — signed consent PDFs
- `users/{uid}/clinical_notes/` — raw decoded CDA XML for the MedGemma research pipeline

---

## A few things worth knowing

**Clinical notes come as HL7 CDA XML, not PDFs.** Apple Health delivers clinical documents as base64-encoded CDA XML. We parse them on-device into human-readable sections and store both the parsed text (in Firestore, for the app) and the raw XML (in Storage, for the research pipeline).

**Consent is recorded in two places.** The local AsyncStorage entry is the fast gate-keeper (used to block/allow onboarding steps). The Firebase record + signed PDF is the durable IRB audit trail.

**The Throne API key never touches the client.** All Throne API calls go through a Cloud Function. The client only reads from Firestore.

**Background sync doesn't require the app to be open.** HealthKit background delivery is registered at onboarding completion. The app wakes up to sync data once per day even if the patient never opens it.

**This is a research prototype.** Some stubs exist. Demo-safe fallbacks are in place when Throne hardware isn't available. Data is not used for clinical care.

---

## Team

Stream Team (Team 3) — Stanford CS342, Winter 2026

Principal Investigator: Ryan Sun, MD
IRB Protocol: IRB# -----
Contact: homeflow-study@stanford.edu
94 changes: 85 additions & 9 deletions homeflow/app/(onboarding)/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* In dev mode, a skip button allows bypassing auth for faster iteration.
*/

import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import {
View,
Text,
Expand All @@ -29,16 +29,17 @@
import { useAuth } from '@/hooks/use-auth';
import { saveSurgeryDate } from '@/src/services/throneFirestore';
import { getAuth } from '@/src/services/firestore';
import { uploadConsentPdf } from '@/src/services/consentPdfSync';
import {
OnboardingProgressBar,
ContinueButton,

Check warning on line 35 in homeflow/app/(onboarding)/account.tsx

View workflow job for this annotation

GitHub Actions / Lint

'ContinueButton' is defined but never used
} from '@/components/onboarding';

export default function AccountScreen() {
const router = useRouter();
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const { signInWithEmail, signUpWithEmail, signInWithApple, signInWithGoogle, isAuthenticated } = useAuth();
const { signInWithEmail, signUpWithEmail, signInWithApple, signInWithGoogle, signOut, isAuthenticated, user } = useAuth();

const [mode, setMode] = useState<'login' | 'signup'>('signup');
const [email, setEmail] = useState('');
Expand All @@ -47,24 +48,36 @@
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(false);

// If user is already authenticated, advance automatically
useEffect(() => {
if (isAuthenticated) {
handleAdvance();
}
}, [isAuthenticated]);
// If already signed in, show a "continue / switch account" prompt rather than
// silently skipping — this handles the onboarding-reset-while-logged-in case.

const handleAdvance = async () => {
// Flush any surgery date collected before login to Firestore now that we have a UID.
const uid = getAuth().currentUser?.uid;
if (uid) {
const data = await OnboardingService.getData();

// Flush surgery date collected pre-login
const surgeryDate = data.eligibility?.surgeryDate;
if (surgeryDate) {
saveSurgeryDate(uid, surgeryDate).catch((err) => {
console.warn('[Account] Failed to flush surgery date to Firestore:', err);
});
}

// Upload consent PDF now that we have a UID
const pending = data.pendingConsentPdf;
if (pending) {
uploadConsentPdf({
signatureType: pending.signatureType,
participantName: pending.participantName,
signatureValue: pending.signatureValue,
consentDate: pending.consentDate,
}).then((result) => {
if (!result.ok) {
console.warn('[Account] Consent PDF upload failed (non-fatal):', result.error);
}
});
}
}

await OnboardingService.goToStep(OnboardingStep.PERMISSIONS);
Expand Down Expand Up @@ -132,7 +145,7 @@
}
};

const handleDevSkip = async () => {

Check warning on line 148 in homeflow/app/(onboarding)/account.tsx

View workflow job for this annotation

GitHub Actions / Lint

'handleDevSkip' is assigned a value but never used
await OnboardingService.goToStep(OnboardingStep.PERMISSIONS);
router.push('/(onboarding)/permissions' as Href);
};
Expand Down Expand Up @@ -160,6 +173,29 @@
</Text>
</View>

{/* Already signed in — let user continue or switch accounts */}
{isAuthenticated && (
<View style={[styles.alreadySignedIn, { borderColor: StanfordColors.cardinal }]}>
<Text style={[styles.alreadySignedInText, { color: colors.text }]}>
Signed in as {user?.email ?? 'your account'}
</Text>
<View style={styles.alreadySignedInButtons}>
<TouchableOpacity
style={[styles.continueAsButton, { backgroundColor: StanfordColors.cardinal }]}
onPress={handleAdvance}
>
<Text style={styles.continueAsButtonText}>Continue</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.switchButton, { borderColor: colors.border }]}
onPress={async () => { await signOut(); }}
>
<Text style={[styles.switchButtonText, { color: colors.icon }]}>Switch Account</Text>
</TouchableOpacity>
</View>
</View>
)}

<View style={styles.form}>
{mode === 'signup' && (
<View style={styles.nameRow}>
Expand Down Expand Up @@ -371,6 +407,46 @@
fontSize: 17,
fontWeight: '500',
},
alreadySignedIn: {
borderWidth: 1,
borderRadius: 12,
padding: Spacing.md,
marginBottom: Spacing.lg,
gap: Spacing.sm,
},
alreadySignedInText: {
fontSize: 15,
fontWeight: '500',
textAlign: 'center',
},
alreadySignedInButtons: {
flexDirection: 'row',
gap: Spacing.sm,
},
continueAsButton: {
flex: 1,
height: 44,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
continueAsButtonText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
switchButton: {
flex: 1,
height: 44,
borderRadius: 10,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
switchButtonText: {
fontSize: 15,
fontWeight: '500',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
Expand Down
19 changes: 9 additions & 10 deletions homeflow/app/(onboarding)/consent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
import { IconSymbol } from '@/components/ui/icon-symbol';
import { SignaturePad, type SignaturePadRef } from '@/components/ui/SignaturePad';
import { useAuth } from '@/hooks/use-auth';
import { uploadConsentPdf } from '@/src/services/consentPdfSync';

function buildConsentText(): string {
const header = [
Expand Down Expand Up @@ -123,16 +122,16 @@
// Record consent locally (source of truth for gate-keeping)
await ConsentService.recordConsent(signatureValue);

// Upload signed PDF to Firebase Storage + write Firestore metadata.
// Non-fatal: failure should not block the participant from proceeding.
const pdfResult = await uploadConsentPdf({
signatureType: signatureMode === 'type' ? 'typed' : 'drawn',
participantName,
signatureValue,
// Store signature data so account.tsx can upload the PDF after sign-in.
// We can't upload now because the user isn't authenticated yet.
await OnboardingService.updateData({
pendingConsentPdf: {
signatureType: signatureMode === 'type' ? 'typed' : 'drawn',
participantName,
signatureValue,
consentDate: new Date().toISOString(),
},
});
if (!pdfResult.ok) {
console.warn('[Consent] PDF upload failed (non-fatal):', pdfResult.error);
}

// Advance onboarding
await OnboardingService.goToStep(OnboardingStep.ACCOUNT);
Expand Down Expand Up @@ -201,7 +200,7 @@
};

// Dev-only handler that bypasses consent validation
const handleDevContinue = async () => {

Check warning on line 203 in homeflow/app/(onboarding)/consent.tsx

View workflow job for this annotation

GitHub Actions / Lint

'handleDevContinue' is assigned a value but never used
setIsSubmitting(true);

try {
Expand Down
Loading
Loading