diff --git a/homeflow/CLAUDE.md b/homeflow/CLAUDE.md
index ed01214..9ef56a6 100644
--- a/homeflow/CLAUDE.md
+++ b/homeflow/CLAUDE.md
@@ -1,8 +1,23 @@
-# AI Coding Instructions
+# AI Coding Instructions for HomeFlow
-## Stack
+## Project Context
-React Native, Expo 54, TypeScript, Expo Router, Formik + Yup
+**Project:** HomeFlow - BPH Patient Digital Health Study
+**Course:** CS342 – Building for Digital Health
+**Team:** Stream Team (Team 3)
+**Platform:** iOS-first (React Native / Expo), cross-platform where feasible
+
+**Purpose:** Enable passive, longitudinal measurement of voiding patterns, activity, and sleep in the home environment for BPH patients before and after bladder outlet surgery.
+
+## Tech Stack
+
+- React Native + Expo 54, TypeScript, Expo Router
+- Spezi Vibe framework
+- Apple HealthKit + Apple Watch integration
+- Apple ResearchKit (consent/enrollment)
+- Throne Uroflow API
+- Formik + Yup (forms/validation)
+- Cloud backend (PI-managed; e.g., GCP / Medplum / Firebase)
## Commands
@@ -11,6 +26,22 @@ npx expo start # Start dev server (press i for iOS, a for Android)
npm test # Run tests
```
+## Core Data Types
+
+### Throne Uroflow Data
+- Void timestamp, voided volume
+- Maximum flow rate (Qmax), average flow rate (Qavg)
+- Flow curve shape, voiding frequency
+- Nocturia events, patient annotations (straining, urgency)
+
+### Apple HealthKit / Watch Data
+- Step count, active minutes, sedentary time
+- Sleep duration and stages
+- Heart rate and vitals (when available)
+
+### Surveys
+- IPSS (International Prostate Symptom Score) - collected longitudinally
+
## Provider Hierarchy
Order matters - don't rearrange:
@@ -22,10 +53,21 @@ StandardProvider → SchedulerProvider → AccountProvider → App
1. **Always use Standard** - Access data via `useStandard()`, never import backends directly
2. **AccountService = auth only** - Login, register, logout, profile
-3. **BackendService = data only** - Tasks, outcomes, questionnaires
+3. **BackendService = data only** - Tasks, outcomes, questionnaires, uroflow, HealthKit
4. **Cancellation tokens** - Every async effect needs `let cancelled = false`
5. **Memoize context values** - Always `useMemo` for provider values
6. **Declarative auth guards** - Use ``, not `router.replace()`
+7. **Privacy-first** - De-identify data before upload; no unnecessary PHI
+8. **Research-only** - No real-time clinical alerts or treatment recommendations
+9. **Adhere to PRD** - All AI-generated code must strictly follow `docs/PRD.md`
+
+## Key Flows
+
+1. **Enrollment & Consent** - ResearchKit-based eligibility screening and informed consent
+2. **Permissions** - HealthKit access, Watch data, Throne device access
+3. **Initial Data Intake** - Demographics from HealthKit + chatbot-assisted history + baseline IPSS
+4. **Daily Passive Collection** - Uroflow, activity, sleep data synced ~once per day
+5. **Periodic Surveys** - IPSS prompts at defined intervals
## Key Files
@@ -34,6 +76,7 @@ StandardProvider → SchedulerProvider → AccountProvider → App
| `lib/services/standard-context.tsx` | Standard pattern - provides backend & auth |
| `app/_layout.tsx` | Root layout with providers and auth guards |
| `app/(tabs)/_layout.tsx` | Tab navigation |
+| `docs/PRD.md` | Product requirements - source of truth |
## Don't
@@ -43,6 +86,17 @@ StandardProvider → SchedulerProvider → AccountProvider → App
- Use `router.replace()` for auth guards
- Forget cleanup functions in useEffect
- Skip cancellation tokens in async effects
+- Build real-time clinical decision support
+- Store unnecessary PHI
+- Deviate from PRD requirements without explicit approval
+
+## Constraints & Assumptions
+
+- This is a **research prototype** for CS342 - demo-safe implementations acceptable
+- Backend may be simulated or partially stubbed
+- Data collected is **not used for clinical care**
+- During demos, Throne uroflow data may be simulated if hardware unavailable
+- iOS HealthKit limitations acknowledged (e.g., delayed syncs)
## Development Agents
@@ -69,4 +123,4 @@ Claude Code agents for common tasks. Invoke with `/agent-name`.
| data-model | `/data-model` | Design health data models and FHIR structures |
| ux-planner | `/ux-planner` | Design user flows and engagement strategies |
-Agent definitions are in `.claude/agents/`.
+Agent definitions are in `.claude/commands/`.
diff --git a/homeflow/app/(onboarding)/_layout.tsx b/homeflow/app/(onboarding)/_layout.tsx
new file mode 100644
index 0000000..aff9344
--- /dev/null
+++ b/homeflow/app/(onboarding)/_layout.tsx
@@ -0,0 +1,79 @@
+/**
+ * Onboarding Layout
+ *
+ * Stack navigator for onboarding flow with smooth transitions.
+ * Disables back gesture to prevent users from skipping steps.
+ */
+
+import React from 'react';
+import { Stack } from 'expo-router';
+import { useColorScheme } from 'react-native';
+import { Colors } from '@/constants/theme';
+
+export default function OnboardingLayout() {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/homeflow/app/(onboarding)/baseline-survey.tsx b/homeflow/app/(onboarding)/baseline-survey.tsx
new file mode 100644
index 0000000..fa126eb
--- /dev/null
+++ b/homeflow/app/(onboarding)/baseline-survey.tsx
@@ -0,0 +1,340 @@
+/**
+ * Baseline Survey Screen
+ *
+ * IPSS questionnaire for baseline symptom assessment.
+ * Presented as a modal-like experience that feels integrated.
+ */
+
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ useColorScheme,
+ Animated,
+} from 'react-native';
+import { useRouter, Href } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { QuestionnaireForm, QuestionnaireResult } from '@spezivibe/questionnaire';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+import { OnboardingStep } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import {
+ IPSS_QUESTIONNAIRE,
+ calculateIPSSScore,
+ getIPSSSeverityDescription,
+} from '@/lib/questionnaires/ipss-questionnaire';
+import { OnboardingProgressBar, ContinueButton } from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+export default function BaselineSurveyScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const [showResults, setShowResults] = useState(false);
+ const [score, setScore] = useState<{ totalScore: number; qolScore: number; severity: string } | null>(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Animations
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(50)).current;
+
+ useEffect(() => {
+ if (showResults) {
+ Animated.parallel([
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 400,
+ useNativeDriver: true,
+ }),
+ Animated.spring(slideAnim, {
+ toValue: 0,
+ tension: 50,
+ friction: 8,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [showResults, fadeAnim, slideAnim]);
+
+ const handleSubmit = async (result: QuestionnaireResult) => {
+ if (result.status !== 'completed') return;
+
+ const response = result.response;
+
+ // Extract answers from response
+ const answers: Record = {};
+
+ response.item?.forEach((item) => {
+ if (item.answer?.[0]?.valueCoding?.code) {
+ answers[item.linkId] = parseInt(item.answer[0].valueCoding.code, 10);
+ }
+ });
+
+ // Calculate score
+ const calculatedScore = calculateIPSSScore(answers);
+ setScore(calculatedScore);
+
+ // Save to onboarding data
+ await OnboardingService.updateData({
+ ipssBaseline: {
+ score: calculatedScore.totalScore,
+ qolScore: calculatedScore.qolScore,
+ completedAt: new Date().toISOString(),
+ responseId: response.id || `ipss-${Date.now()}`,
+ },
+ });
+
+ setShowResults(true);
+ };
+
+ const handleContinue = async () => {
+ setIsSubmitting(true);
+
+ try {
+ await OnboardingService.goToStep(OnboardingStep.COMPLETE);
+ router.replace('/(onboarding)/complete' as Href);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const getSeverityColor = () => {
+ if (!score) return colors.icon;
+ switch (score.severity) {
+ case 'mild':
+ return '#34C759';
+ case 'moderate':
+ return '#FF9500';
+ case 'severe':
+ return '#FF3B30';
+ default:
+ return colors.icon;
+ }
+ };
+
+ if (showResults) {
+ return (
+
+
+
+
+
+
+
+ Baseline Complete!
+
+
+
+ Your IPSS Score
+
+
+ {score?.totalScore}
+
+ /35
+
+
+
+ {score?.severity?.toUpperCase()} SYMPTOMS
+
+
+
+
+
+ {score && getIPSSSeverityDescription(score.severity as any)}
+
+
+
+
+ Quality of Life Score
+
+
+ {score?.qolScore}/6
+
+
+
+
+ This score will be used as your baseline. We'll track how your symptoms
+ change over the course of the study.
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Baseline Survey
+
+
+ Please answer these questions about your urinary symptoms over the past month.
+
+
+
+ {/* TEMPORARY: QuestionnaireForm has its own ScrollView - no dev button here */}
+ {/* Use "Reset Onboarding" on home screen to test this flow again */}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ paddingTop: Spacing.sm,
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingBottom: Spacing.md,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ titleContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ marginTop: Spacing.sm,
+ },
+ title: {
+ fontSize: 22,
+ fontWeight: '700',
+ },
+ subtitle: {
+ fontSize: 14,
+ textAlign: 'center',
+ marginTop: Spacing.sm,
+ lineHeight: 20,
+ },
+ formContainer: {
+ flex: 1,
+ },
+ resultsContainer: {
+ flex: 1,
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingTop: Spacing.xl * 2,
+ alignItems: 'center',
+ },
+ resultsIcon: {
+ marginBottom: Spacing.lg,
+ },
+ resultsTitle: {
+ fontSize: 28,
+ fontWeight: '700',
+ marginBottom: Spacing.xl,
+ },
+ scoreCard: {
+ width: '100%',
+ borderRadius: 16,
+ padding: Spacing.lg,
+ alignItems: 'center',
+ marginBottom: Spacing.lg,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 3,
+ },
+ scoreLabel: {
+ fontSize: 14,
+ fontWeight: '500',
+ marginBottom: 8,
+ },
+ scoreRow: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ },
+ scoreValue: {
+ fontSize: 64,
+ fontWeight: '700',
+ },
+ scoreMax: {
+ fontSize: 24,
+ fontWeight: '500',
+ },
+ severityBadge: {
+ paddingHorizontal: 16,
+ paddingVertical: 6,
+ borderRadius: 20,
+ marginTop: Spacing.sm,
+ },
+ severityText: {
+ fontSize: 13,
+ fontWeight: '700',
+ letterSpacing: 0.5,
+ },
+ resultsDescription: {
+ fontSize: 16,
+ lineHeight: 24,
+ textAlign: 'center',
+ marginBottom: Spacing.lg,
+ },
+ qolCard: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ width: '100%',
+ borderRadius: 12,
+ padding: Spacing.md,
+ marginBottom: Spacing.lg,
+ },
+ qolLabel: {
+ fontSize: 15,
+ },
+ qolValue: {
+ fontSize: 18,
+ fontWeight: '600',
+ },
+ disclaimer: {
+ fontSize: 14,
+ textAlign: 'center',
+ lineHeight: 20,
+ paddingHorizontal: Spacing.md,
+ },
+ footer: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.lg,
+ },
+});
diff --git a/homeflow/app/(onboarding)/chat.tsx b/homeflow/app/(onboarding)/chat.tsx
new file mode 100644
index 0000000..8437a3a
--- /dev/null
+++ b/homeflow/app/(onboarding)/chat.tsx
@@ -0,0 +1,305 @@
+/**
+ * Onboarding Chat Screen
+ *
+ * Combined eligibility screening and medical history collection
+ * through natural conversation with AI assistant.
+ */
+
+import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ useColorScheme,
+ Animated,
+} from 'react-native';
+import { useRouter, Href } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import Constants from 'expo-constants';
+import { ChatView, ChatProvider } from '@spezivibe/chat';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+import { OnboardingStep, STUDY_INFO } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { OnboardingProgressBar, ContinueButton } from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+/**
+ * Combined system prompt for eligibility + medical history
+ */
+const SYSTEM_PROMPT = `You are a friendly research assistant helping to screen and enroll participants in the HomeFlow BPH study. Your goal is to:
+1. First, check eligibility through natural conversation
+2. Then, if eligible, collect medical history
+
+## Study Information
+- Name: ${STUDY_INFO.name}
+- Institution: ${STUDY_INFO.institution}
+- Purpose: Track voiding patterns and symptoms before/after bladder outlet surgery
+
+## Phase 1: Eligibility (Required Criteria)
+Check these naturally (don't read a checklist):
+1. Has iPhone with iOS 15+ (required)
+2. Has Apple Watch (required)
+3. Has BPH diagnosis OR experiencing urinary symptoms like frequent urination, weak stream, nighttime urination (required)
+4. Considering or scheduled for bladder outlet surgery like TURP, laser therapy, UroLift, Rezum (required)
+5. Willing to use Throne uroflow device (optional - okay to skip)
+
+## Phase 2: Medical History (if eligible)
+Collect conversationally:
+1. Current medications (especially for BPH/prostate)
+2. Other medical conditions
+3. Allergies
+4. Previous surgeries
+5. BPH treatment history
+
+## Guidelines
+- Be warm, conversational, and empathetic
+- Ask one or two things at a time
+- If they mention symptoms, acknowledge them
+- If ineligible, be kind and explain why
+- Don't give medical advice
+
+## Important Responses
+When eligibility is confirmed, include the exact phrase: [ELIGIBLE]
+When ineligible, include: [INELIGIBLE]
+When medical history is complete, include: [HISTORY_COMPLETE]
+
+These markers help the app know when to enable the Continue button.
+
+## Start the conversation
+Open with something like: "Hi! I'm here to help you join the HomeFlow study. Let me ask a few questions to make sure this study is a good fit for you. First, are you using an iPhone?"`;
+
+type ChatPhase = 'eligibility' | 'medical_history' | 'complete' | 'ineligible';
+
+export default function OnboardingChatScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const [phase, setPhase] = useState('eligibility');
+ const [canContinue, setCanContinue] = useState(false);
+
+ // Animation for continue button
+ const buttonOpacity = useRef(new Animated.Value(0)).current;
+
+ // Get API key from environment
+ const apiKey = Constants.expoConfig?.extra?.openaiApiKey || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '';
+
+ // Chat provider config
+ const provider: ChatProvider = useMemo(
+ () => ({
+ type: 'openai',
+ apiKey,
+ model: 'gpt-4o-mini',
+ }),
+ [apiKey]
+ );
+
+ useEffect(() => {
+ if (canContinue) {
+ Animated.spring(buttonOpacity, {
+ toValue: 1,
+ useNativeDriver: true,
+ tension: 50,
+ friction: 8,
+ }).start();
+ }
+ }, [canContinue, buttonOpacity]);
+
+ // Watch for phase markers in chat messages
+ // This is a simplified approach - in production you'd use function calling
+ const checkForMarkers = useCallback((message: string) => {
+ const lowerMessage = message.toLowerCase();
+
+ if (message.includes('[INELIGIBLE]') || lowerMessage.includes("unfortunately") && lowerMessage.includes("not eligible")) {
+ setPhase('ineligible');
+ // Navigate to ineligible screen after a brief delay
+ setTimeout(() => {
+ router.replace('/(onboarding)/ineligible' as Href);
+ }, 2000);
+ } else if (message.includes('[ELIGIBLE]') || (lowerMessage.includes("eligible") && lowerMessage.includes("great news"))) {
+ setPhase('medical_history');
+ } else if (message.includes('[HISTORY_COMPLETE]') || (lowerMessage.includes("all set") && lowerMessage.includes("continue"))) {
+ setPhase('complete');
+ setCanContinue(true);
+ }
+ }, [router]);
+
+ const handleContinue = async () => {
+ // Save collected data (in a real app, you'd parse the chat transcript)
+ await OnboardingService.updateData({
+ eligibility: {
+ hasIPhone: true,
+ hasAppleWatch: true,
+ hasBPHDiagnosis: true,
+ consideringSurgery: true,
+ willingToUseThrone: true,
+ isEligible: true,
+ },
+ });
+
+ await OnboardingService.goToStep(OnboardingStep.CONSENT);
+ router.push('/(onboarding)/consent' as Href);
+ };
+
+ const getPhaseText = () => {
+ switch (phase) {
+ case 'eligibility':
+ return 'Checking eligibility...';
+ case 'medical_history':
+ return 'Collecting medical history...';
+ case 'complete':
+ return 'Ready to continue!';
+ case 'ineligible':
+ return 'Checking eligibility...';
+ default:
+ return '';
+ }
+ };
+
+ // If no API key, show a placeholder
+ if (!apiKey) {
+ return (
+
+
+
+
+
+
+
+ Chat Not Available
+
+
+ OpenAI API key not configured. For demo purposes, tap Continue to proceed.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {getPhaseText()}
+
+
+
+
+
+
+ Starting conversation...
+
+
+ }
+ />
+
+ {canContinue && (
+
+
+ Great! You're ready for the next step.
+
+
+
+ )}
+
+ {/* TEMPORARY: Development-only continue button to test other screens */}
+ {/* TODO: Remove this once eligibility questions are properly set up */}
+ {!canContinue && (
+
+
+ ⚠️ Temporary: Skip eligibility for testing
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ paddingTop: Spacing.sm,
+ },
+ phaseIndicator: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: Spacing.sm,
+ },
+ phaseDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ marginRight: 8,
+ },
+ phaseText: {
+ fontSize: 13,
+ fontWeight: '500',
+ },
+ emptyState: {
+ alignItems: 'center',
+ gap: Spacing.sm,
+ },
+ emptyStateText: {
+ fontSize: 15,
+ },
+ continueContainer: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.lg,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: 'rgba(0,0,0,0.1)',
+ gap: Spacing.sm,
+ },
+ continueHint: {
+ fontSize: 14,
+ textAlign: 'center',
+ },
+ noApiKey: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: Spacing.screenHorizontal,
+ },
+ noApiKeyTitle: {
+ fontSize: 20,
+ fontWeight: '600',
+ marginTop: Spacing.md,
+ marginBottom: Spacing.sm,
+ },
+ noApiKeyText: {
+ fontSize: 15,
+ textAlign: 'center',
+ lineHeight: 22,
+ },
+});
diff --git a/homeflow/app/(onboarding)/complete.tsx b/homeflow/app/(onboarding)/complete.tsx
new file mode 100644
index 0000000..3767a78
--- /dev/null
+++ b/homeflow/app/(onboarding)/complete.tsx
@@ -0,0 +1,298 @@
+/**
+ * Onboarding Complete Screen
+ *
+ * Celebration screen showing successful enrollment.
+ * Transitions to the main app after a brief moment.
+ */
+
+import React, { useRef, useEffect, useState } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ useColorScheme,
+ Animated,
+} from 'react-native';
+import { useRouter } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+import { STUDY_INFO } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { ContinueButton } from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+export default function CompleteScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const [showButton, setShowButton] = useState(false);
+
+ // Animations
+ const checkScale = useRef(new Animated.Value(0)).current;
+ const checkOpacity = useRef(new Animated.Value(0)).current;
+ const contentFade = useRef(new Animated.Value(0)).current;
+ const contentSlide = useRef(new Animated.Value(30)).current;
+ const confettiOpacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ // Sequence of animations
+ Animated.sequence([
+ // Check icon appears
+ Animated.parallel([
+ Animated.spring(checkScale, {
+ toValue: 1,
+ tension: 50,
+ friction: 5,
+ useNativeDriver: true,
+ }),
+ Animated.timing(checkOpacity, {
+ toValue: 1,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ ]),
+ // Brief confetti flash
+ Animated.sequence([
+ Animated.timing(confettiOpacity, {
+ toValue: 1,
+ duration: 200,
+ useNativeDriver: true,
+ }),
+ Animated.timing(confettiOpacity, {
+ toValue: 0,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ ]),
+ // Content slides in
+ Animated.parallel([
+ Animated.timing(contentFade, {
+ toValue: 1,
+ duration: 400,
+ useNativeDriver: true,
+ }),
+ Animated.spring(contentSlide, {
+ toValue: 0,
+ tension: 50,
+ friction: 8,
+ useNativeDriver: true,
+ }),
+ ]),
+ ]).start(() => {
+ setShowButton(true);
+ });
+ }, [checkScale, checkOpacity, contentFade, contentSlide, confettiOpacity]);
+
+ const handleContinue = async () => {
+ // Mark onboarding as complete
+ await OnboardingService.complete();
+
+ // Navigate to main app
+ router.replace('/(tabs)');
+ };
+
+ return (
+
+
+ {/* Confetti effect (simplified) */}
+
+ {['#FF6B6B', '#4ECDC4', '#FFE66D', '#95E1D3', '#F38181'].map((color, i) => (
+
+ ))}
+
+
+ {/* Success icon */}
+
+
+
+
+
+
+ {/* Content */}
+
+
+ You're All Set!
+
+
+
+ Welcome to the {STUDY_INFO.name}
+
+
+
+
+
+
+
+
+
+
+
+ For the best experience, wear your Apple Watch regularly and keep the HomeFlow app running in the background.
+
+
+
+
+
+ {showButton && (
+
+
+
+ )}
+
+ );
+}
+
+function FeatureRow({
+ icon,
+ text,
+ colors,
+}: {
+ icon: string;
+ text: string;
+ colors: typeof Colors.light;
+}) {
+ return (
+
+
+
+
+ {text}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingTop: Spacing.xl * 2,
+ alignItems: 'center',
+ },
+ confettiContainer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ height: 300,
+ },
+ confettiDot: {
+ position: 'absolute',
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ },
+ iconContainer: {
+ marginBottom: Spacing.xl,
+ },
+ iconBackground: {
+ width: 120,
+ height: 120,
+ borderRadius: 60,
+ backgroundColor: 'rgba(52, 199, 89, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: '700',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 17,
+ textAlign: 'center',
+ marginBottom: Spacing.xl,
+ },
+ features: {
+ width: '100%',
+ gap: Spacing.md,
+ marginBottom: Spacing.xl,
+ },
+ featureRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ featureIconContainer: {
+ width: 40,
+ height: 40,
+ borderRadius: 10,
+ backgroundColor: 'rgba(140, 21, 21, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: Spacing.md,
+ },
+ featureText: {
+ fontSize: 16,
+ flex: 1,
+ },
+ tipBox: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ borderRadius: 12,
+ padding: Spacing.md,
+ gap: 12,
+ },
+ tipText: {
+ fontSize: 14,
+ lineHeight: 20,
+ flex: 1,
+ },
+ footer: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.lg,
+ },
+});
diff --git a/homeflow/app/(onboarding)/consent.tsx b/homeflow/app/(onboarding)/consent.tsx
new file mode 100644
index 0000000..294e7e1
--- /dev/null
+++ b/homeflow/app/(onboarding)/consent.tsx
@@ -0,0 +1,312 @@
+/**
+ * Consent Screen
+ *
+ * Formal informed consent document with required sections.
+ * Users must scroll through and agree before proceeding.
+ */
+
+import React, { useState, useRef, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ScrollView,
+ useColorScheme,
+ TextInput,
+} from 'react-native';
+import { useRouter, Href } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+import { OnboardingStep } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { ConsentService } from '@/lib/services/consent-service';
+import {
+ CONSENT_DOCUMENT,
+ getRequiredSections,
+ getConsentSummary,
+} from '@/lib/consent/consent-document';
+import {
+ OnboardingProgressBar,
+ ConsentSection,
+ ConsentAgreement,
+ ContinueButton,
+} from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+export default function ConsentScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const [readSections, setReadSections] = useState>(new Set());
+ const [agreed, setAgreed] = useState(false);
+ const [signature, setSignature] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const scrollViewRef = useRef(null);
+
+ const requiredSections = getRequiredSections();
+ const allRequiredRead = requiredSections.every((s) => readSections.has(s.id));
+ const canContinue = allRequiredRead && agreed && signature.trim().length > 0;
+
+ const handleSectionRead = useCallback((sectionId: string) => {
+ setReadSections((prev) => new Set([...prev, sectionId]));
+ }, []);
+
+ const handleContinue = async () => {
+ if (!canContinue) return;
+
+ setIsSubmitting(true);
+
+ try {
+ // Record consent
+ await ConsentService.recordConsent(signature);
+
+ // Update onboarding
+ await OnboardingService.goToStep(OnboardingStep.PERMISSIONS);
+
+ router.push('/(onboarding)/permissions' as Href);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {CONSENT_DOCUMENT.title}
+
+
+
+ {CONSENT_DOCUMENT.studyName}
+
+
+
+
+ {/* Introduction */}
+
+
+ Please read each section carefully. Sections marked with a red dot are required.
+ Tap a section to expand it.
+
+
+
+ {/* Consent Sections */}
+ {CONSENT_DOCUMENT.sections.map((section, index) => (
+ handleSectionRead(section.id)}
+ defaultExpanded={index === 0}
+ />
+ ))}
+
+ {/* Progress indicator */}
+
+
+ {readSections.size} of {requiredSections.length} required sections read
+
+
+
+
+
+
+ {/* Agreement checkbox */}
+ setAgreed(!agreed)}
+ />
+
+ {/* Signature */}
+ 0 ? '#34C759' : colors.border,
+ },
+ ]}
+ >
+
+ Please type your full name to sign
+
+
+
+ Date: {new Date().toLocaleDateString()}
+
+
+
+ {/* Spacer for button */}
+
+
+
+
+ {!allRequiredRead && (
+
+ Please read all required sections before continuing
+
+ )}
+
+
+ {/* TEMPORARY: Development-only continue button to test other screens */}
+ {/* TODO: Remove this once ready for production */}
+ {!canContinue && (
+
+
+ ⚠️ Temporary: Skip consent for testing
+
+
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ paddingTop: Spacing.sm,
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingBottom: Spacing.md,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ titleContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ marginTop: Spacing.sm,
+ },
+ title: {
+ fontSize: 22,
+ fontWeight: '700',
+ },
+ studyName: {
+ fontSize: 14,
+ textAlign: 'center',
+ marginTop: 4,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ padding: Spacing.screenHorizontal,
+ },
+ introBox: {
+ borderRadius: 12,
+ padding: Spacing.md,
+ marginBottom: Spacing.md,
+ },
+ introText: {
+ fontSize: 15,
+ lineHeight: 22,
+ },
+ progressContainer: {
+ marginVertical: Spacing.md,
+ },
+ progressText: {
+ fontSize: 13,
+ marginBottom: 8,
+ },
+ progressBar: {
+ height: 4,
+ borderRadius: 2,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ borderRadius: 2,
+ },
+ signatureContainer: {
+ borderRadius: 12,
+ borderWidth: 2,
+ padding: Spacing.md,
+ marginTop: Spacing.sm,
+ },
+ signatureLabel: {
+ fontSize: 15,
+ fontWeight: '600',
+ marginBottom: Spacing.sm,
+ },
+ signatureInput: {
+ fontSize: 18,
+ padding: Spacing.md,
+ borderRadius: 8,
+ borderWidth: 1,
+ fontStyle: 'italic',
+ },
+ signatureDate: {
+ fontSize: 13,
+ marginTop: Spacing.sm,
+ textAlign: 'right',
+ },
+ footer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ padding: Spacing.md,
+ paddingBottom: Spacing.lg,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: 'rgba(0,0,0,0.1)',
+ },
+ footerHint: {
+ fontSize: 13,
+ textAlign: 'center',
+ marginBottom: Spacing.sm,
+ },
+});
diff --git a/homeflow/app/(onboarding)/index.tsx b/homeflow/app/(onboarding)/index.tsx
new file mode 100644
index 0000000..ec63231
--- /dev/null
+++ b/homeflow/app/(onboarding)/index.tsx
@@ -0,0 +1,80 @@
+/**
+ * Onboarding Router
+ *
+ * Determines which onboarding screen to show based on current step.
+ * Automatically routes to the correct screen on mount.
+ */
+
+import React, { useEffect } from 'react';
+import { View, ActivityIndicator, StyleSheet } from 'react-native';
+import { Redirect, useRouter, Href } from 'expo-router';
+import { useOnboardingStep } from '@/hooks/use-onboarding-status';
+import { OnboardingStep } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { StanfordColors } from '@/constants/theme';
+
+export default function OnboardingRouter() {
+ const router = useRouter();
+ const currentStep = useOnboardingStep();
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function initializeOnboarding() {
+ const hasStarted = await OnboardingService.hasStarted();
+
+ if (!hasStarted && !cancelled) {
+ // First time - start onboarding
+ await OnboardingService.start();
+ router.replace('/(onboarding)/welcome' as Href);
+ }
+ }
+
+ initializeOnboarding();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [router]);
+
+ // Show loading while determining step
+ if (currentStep === null) {
+ return (
+
+
+
+ );
+ }
+
+ // Route based on current step
+ switch (currentStep) {
+ case OnboardingStep.WELCOME:
+ return ;
+
+ case OnboardingStep.CHAT:
+ return ;
+
+ case OnboardingStep.CONSENT:
+ return ;
+
+ case OnboardingStep.PERMISSIONS:
+ return ;
+
+ case OnboardingStep.BASELINE_SURVEY:
+ return ;
+
+ case OnboardingStep.COMPLETE:
+ return ;
+
+ default:
+ return ;
+ }
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/homeflow/app/(onboarding)/ineligible.tsx b/homeflow/app/(onboarding)/ineligible.tsx
new file mode 100644
index 0000000..0f0f478
--- /dev/null
+++ b/homeflow/app/(onboarding)/ineligible.tsx
@@ -0,0 +1,181 @@
+/**
+ * Ineligible Screen
+ *
+ * Shown when a user doesn't meet the eligibility criteria.
+ * Provides a kind explanation and contact information.
+ */
+
+import React, { useRef, useEffect } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ useColorScheme,
+ Animated,
+ Linking,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Colors, Spacing } from '@/constants/theme';
+import { STUDY_INFO } from '@/lib/constants';
+import { ContinueButton } from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+export default function IneligibleScreen() {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ // Animations
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(20)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ Animated.spring(slideAnim, {
+ toValue: 0,
+ tension: 50,
+ friction: 8,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }, [fadeAnim, slideAnim]);
+
+ const handleContact = () => {
+ Linking.openURL(`mailto:${STUDY_INFO.contactEmail}`);
+ };
+
+ const handleClose = () => {
+ // In a real app, this might clear data and exit
+ // For now, we'll just stay on this screen
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ We're Sorry
+
+
+
+ Based on your responses, you don't currently meet the eligibility criteria for the {STUDY_INFO.name}.
+
+
+
+
+ Why might I not be eligible?
+
+
+ This study requires:
+ {'\n'}{'\n'}• An iPhone with iOS 15 or later
+ {'\n'}• An Apple Watch for health monitoring
+ {'\n'}• A diagnosis of BPH or related symptoms
+ {'\n'}• Plans for or consideration of bladder outlet surgery
+
+
+
+
+ If you believe this is an error or have questions, please contact the research team.
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingTop: Spacing.xl * 2,
+ },
+ iconContainer: {
+ alignItems: 'center',
+ marginBottom: Spacing.lg,
+ },
+ iconBackground: {
+ width: 100,
+ height: 100,
+ borderRadius: 50,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ textAlign: 'center',
+ marginBottom: Spacing.md,
+ },
+ description: {
+ fontSize: 17,
+ lineHeight: 24,
+ textAlign: 'center',
+ marginBottom: Spacing.xl,
+ },
+ infoBox: {
+ borderRadius: 12,
+ padding: Spacing.md,
+ marginBottom: Spacing.lg,
+ },
+ infoTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: Spacing.sm,
+ },
+ infoText: {
+ fontSize: 15,
+ lineHeight: 22,
+ },
+ contactPrompt: {
+ fontSize: 15,
+ textAlign: 'center',
+ lineHeight: 22,
+ },
+ footer: {
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingBottom: Spacing.lg,
+ gap: Spacing.sm,
+ },
+});
diff --git a/homeflow/app/(onboarding)/permissions.tsx b/homeflow/app/(onboarding)/permissions.tsx
new file mode 100644
index 0000000..63753e2
--- /dev/null
+++ b/homeflow/app/(onboarding)/permissions.tsx
@@ -0,0 +1,298 @@
+/**
+ * Permissions Screen
+ *
+ * Request HealthKit and Throne permissions.
+ * HealthKit is required, Throne is optional.
+ */
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ScrollView,
+ useColorScheme,
+ Platform,
+ Alert,
+ Linking,
+} from 'react-native';
+import { useRouter, Href } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+import { OnboardingStep } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { ThroneService } from '@/lib/services/throne-service';
+import {
+ OnboardingProgressBar,
+ PermissionCard,
+ ContinueButton,
+ PermissionStatus,
+} from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+// Import HealthKit conditionally
+let HealthKitService: any = null;
+if (Platform.OS === 'ios') {
+ try {
+ const healthkit = require('@spezivibe/healthkit');
+ HealthKitService = healthkit.HealthKitService;
+ } catch {
+ // HealthKit not available
+ }
+}
+
+export default function PermissionsScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const [healthKitStatus, setHealthKitStatus] = useState('not_determined');
+ const [throneStatus, setThroneStatus] = useState('not_determined');
+ const [isLoading, setIsLoading] = useState(false);
+
+ // HealthKit is required, Throne is optional
+ const canContinue = healthKitStatus === 'granted' || Platform.OS !== 'ios';
+
+ useEffect(() => {
+ // Check initial status
+ async function checkStatus() {
+ if (HealthKitService?.isAvailable?.()) {
+ // Check if we already have permission
+ // Note: HealthKit doesn't expose a way to check status directly
+ // We'll just start fresh
+ }
+
+ const thronePermission = await ThroneService.getPermissionStatus();
+ setThroneStatus(thronePermission);
+ }
+
+ checkStatus();
+ }, []);
+
+ const handleHealthKitRequest = useCallback(async () => {
+ if (!HealthKitService?.isAvailable?.()) {
+ // Not on iOS or HealthKit not available
+ Alert.alert(
+ 'HealthKit Not Available',
+ 'HealthKit is only available on iOS devices. For demo purposes, you can continue.',
+ [{ text: 'OK' }]
+ );
+ setHealthKitStatus('granted');
+ return;
+ }
+
+ setHealthKitStatus('loading');
+
+ try {
+ // Import sample types
+ const { SampleType } = require('@spezivibe/healthkit');
+
+ const granted = await HealthKitService.requestAuthorization([
+ SampleType.stepCount,
+ SampleType.heartRate,
+ SampleType.sleepAnalysis,
+ SampleType.activeEnergyBurned,
+ SampleType.distanceWalkingRunning,
+ ]);
+
+ setHealthKitStatus(granted ? 'granted' : 'denied');
+
+ if (!granted) {
+ Alert.alert(
+ 'Permission Required',
+ 'HealthKit access is required for the study. Please enable it in Settings.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Open Settings', onPress: () => Linking.openSettings() },
+ ]
+ );
+ }
+ } catch (error) {
+ console.error('HealthKit error:', error);
+ setHealthKitStatus('denied');
+ Alert.alert('Error', 'Failed to request HealthKit permissions. Please try again.');
+ }
+ }, []);
+
+ const handleThroneRequest = useCallback(async () => {
+ setThroneStatus('loading');
+
+ try {
+ const status = await ThroneService.requestPermission();
+ setThroneStatus(status);
+ } catch (error) {
+ console.error('Throne error:', error);
+ setThroneStatus('denied');
+ }
+ }, []);
+
+ const handleThroneSkip = useCallback(async () => {
+ await ThroneService.skipSetup();
+ setThroneStatus('skipped');
+ }, []);
+
+ const handleContinue = async () => {
+ setIsLoading(true);
+
+ try {
+ // Save permission status
+ await OnboardingService.updateData({
+ permissions: {
+ healthKit: healthKitStatus as 'granted' | 'denied' | 'not_determined',
+ throne: throneStatus as 'granted' | 'denied' | 'not_determined' | 'skipped',
+ },
+ });
+
+ await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY);
+ router.push('/(onboarding)/baseline-survey' as Href);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ App Permissions
+
+
+
+
+ HomeFlow needs access to your health data to track your activity, sleep, and symptoms.
+ Your data is encrypted and only used for research purposes.
+
+
+ {/* HealthKit Permission */}
+
+
+ {/* Throne Permission */}
+
+
+ {/* Info box */}
+
+
+
+ You can change these permissions at any time in your device Settings.
+ Your data is never sold or shared with third parties.
+
+
+
+
+
+ {!canContinue && (
+
+ Apple Health access is required to continue
+
+ )}
+
+
+ {/* TEMPORARY: Development-only continue button to test other screens */}
+ {/* TODO: Remove this once ready for production */}
+ {!canContinue && (
+
+
+ ⚠️ Temporary: Skip permissions for testing
+
+
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ paddingTop: Spacing.sm,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ padding: Spacing.screenHorizontal,
+ },
+ titleContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 12,
+ marginBottom: Spacing.sm,
+ marginTop: Spacing.md,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ },
+ description: {
+ fontSize: 16,
+ lineHeight: 23,
+ textAlign: 'center',
+ marginBottom: Spacing.xl,
+ },
+ infoBox: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ borderRadius: 12,
+ padding: Spacing.md,
+ gap: 12,
+ marginTop: Spacing.sm,
+ },
+ infoText: {
+ fontSize: 14,
+ lineHeight: 20,
+ flex: 1,
+ },
+ footer: {
+ padding: Spacing.md,
+ paddingBottom: Spacing.lg,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: 'rgba(0,0,0,0.1)',
+ },
+ footerHint: {
+ fontSize: 13,
+ textAlign: 'center',
+ marginBottom: Spacing.sm,
+ },
+});
diff --git a/homeflow/app/(onboarding)/welcome.tsx b/homeflow/app/(onboarding)/welcome.tsx
new file mode 100644
index 0000000..de84746
--- /dev/null
+++ b/homeflow/app/(onboarding)/welcome.tsx
@@ -0,0 +1,251 @@
+/**
+ * Welcome Screen
+ *
+ * Brief introduction to the HomeFlow study.
+ * Sets the tone and explains what to expect.
+ */
+
+import React, { useRef, useEffect } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ useColorScheme,
+ Animated,
+} from 'react-native';
+import { useRouter, Href } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+import { STUDY_INFO, OnboardingStep } from '@/lib/constants';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { ContinueButton } from '@/components/onboarding';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+
+export default function WelcomeScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ // Animations
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(30)).current;
+ const iconScale = useRef(new Animated.Value(0.5)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.spring(slideAnim, {
+ toValue: 0,
+ tension: 50,
+ friction: 8,
+ useNativeDriver: true,
+ }),
+ Animated.spring(iconScale, {
+ toValue: 1,
+ tension: 50,
+ friction: 5,
+ useNativeDriver: true,
+ delay: 200,
+ }),
+ ]).start();
+ }, [fadeAnim, slideAnim, iconScale]);
+
+ const handleContinue = async () => {
+ await OnboardingService.goToStep(OnboardingStep.CHAT);
+ router.push('/(onboarding)/chat' as Href);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Welcome to HomeFlow
+
+
+ {STUDY_INFO.institution}
+
+
+
+
+
+ Thank you for your interest in the {STUDY_INFO.name}. This app will help us
+ understand how bladder outlet surgery affects your daily patterns.
+
+
+
+
+
+
+
+
+
+
+
+
+ The next few screens will check your eligibility and collect some basic information.
+
+
+
+
+ );
+}
+
+function FeatureItem({
+ icon,
+ title,
+ description,
+ colors,
+}: {
+ icon: string;
+ title: string;
+ description: string;
+ colors: typeof Colors.light;
+}) {
+ const colorScheme = useColorScheme();
+
+ return (
+
+
+
+
+
+ {title}
+ {description}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingTop: Spacing.xl,
+ },
+ iconContainer: {
+ alignItems: 'center',
+ marginBottom: Spacing.lg,
+ },
+ iconBackground: {
+ width: 100,
+ height: 100,
+ borderRadius: 24,
+ backgroundColor: 'rgba(140, 21, 21, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: '700',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 16,
+ textAlign: 'center',
+ marginBottom: Spacing.lg,
+ },
+ descriptionContainer: {
+ flex: 1,
+ },
+ description: {
+ fontSize: 17,
+ lineHeight: 24,
+ textAlign: 'center',
+ marginBottom: Spacing.xl,
+ },
+ features: {
+ gap: Spacing.md,
+ },
+ featureItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ featureIcon: {
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: Spacing.md,
+ },
+ featureText: {
+ flex: 1,
+ },
+ featureTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 2,
+ },
+ featureDescription: {
+ fontSize: 14,
+ },
+ footer: {
+ paddingHorizontal: Spacing.screenHorizontal,
+ paddingBottom: Spacing.lg,
+ gap: Spacing.md,
+ },
+ footerText: {
+ fontSize: 14,
+ textAlign: 'center',
+ lineHeight: 20,
+ },
+});
diff --git a/homeflow/app/(tabs)/index.tsx b/homeflow/app/(tabs)/index.tsx
index 566ffc9..3f80d4a 100644
--- a/homeflow/app/(tabs)/index.tsx
+++ b/homeflow/app/(tabs)/index.tsx
@@ -1,15 +1,45 @@
import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
+import { StyleSheet, Pressable, Alert } from 'react-native';
+import { useRouter, Href } from 'expo-router';
import { HelloWave } from '@/components/hello-wave';
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+import { OnboardingService } from '@/lib/services/onboarding-service';
export default function HomeScreen() {
+ const router = useRouter();
+
+ // TEMPORARY: Development-only function to reset onboarding
+ // TODO: Remove this before production release
+ const handleResetOnboarding = async () => {
+ Alert.alert(
+ 'Reset Onboarding?',
+ 'This will clear all onboarding progress and restart from the beginning. This feature is for development only.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Reset',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ // Clear all onboarding data
+ await OnboardingService.reset();
+ // Navigate back to onboarding flow
+ router.replace('/(onboarding)' as Href);
+ } catch (error) {
+ console.error('Error resetting onboarding:', error);
+ Alert.alert('Error', 'Failed to reset onboarding');
+ }
+ },
+ },
+ ]
+ );
+ };
+
return (
}>
- Welcome!
+ Welcome to HomeFlow!
+
- Step 1: Try it
+ Study Dashboard
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
-
- {Platform.select({
- ios: 'cmd + d',
- android: 'cmd + m',
- web: 'F12',
- })}
- {' '}
- to open developer tools.
+ Track your BPH symptoms, medications, and progress throughout the study.
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
- />
-
- alert('Delete pressed')}
- />
-
-
-
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
+ {/* TEMPORARY: Development-only reset button */}
+ {/* TODO: Remove this entire section before production release */}
+
+
+ ⚠️ Developer Tools
+
+
+ Temporary features for testing - remove before production
+
+
+
+ 🔄 Reset Onboarding (Dev Only)
+
+
+
+ This will clear all onboarding data and restart the flow
@@ -76,7 +91,7 @@ const styles = StyleSheet.create({
},
stepContainer: {
gap: 8,
- marginBottom: 8,
+ marginBottom: 16,
},
logo: {
height: 200,
@@ -85,4 +100,24 @@ const styles = StyleSheet.create({
bottom: -20,
left: -20,
},
+ // TEMPORARY: Dev section styles - remove before production
+ devSection: {
+ marginTop: 32,
+ padding: 16,
+ borderRadius: 12,
+ borderWidth: 2,
+ borderColor: '#FF9500',
+ backgroundColor: 'rgba(255, 149, 0, 0.1)',
+ },
+ devButton: {
+ backgroundColor: '#FF9500',
+ borderRadius: 8,
+ padding: 14,
+ alignItems: 'center',
+ },
+ devButtonText: {
+ color: '#FFFFFF',
+ fontSize: 15,
+ fontWeight: '600',
+ },
});
diff --git a/homeflow/app/_layout.tsx b/homeflow/app/_layout.tsx
index 354c4de..2939fa0 100644
--- a/homeflow/app/_layout.tsx
+++ b/homeflow/app/_layout.tsx
@@ -7,20 +7,59 @@ import 'react-native-reanimated';
import '@/assets/styles/global.css';
import { useColorScheme } from '@/hooks/use-color-scheme';
+import { useOnboardingStatus } from '@/hooks/use-onboarding-status';
import { LoadingScreen } from '@/components/ui/loading-screen';
import { ErrorBoundary } from '@/components/error-boundary';
import { StandardProvider, useStandard } from '@/lib/services/standard-context';
export const unstable_settings = {
- anchor: '(tabs)',
+ // Initial route while loading
+ initialRouteName: 'index',
};
+/**
+ * Navigation stack with onboarding and main app routes
+ */
function RootLayoutNav() {
+ const onboardingComplete = useOnboardingStatus();
+
+ // While checking onboarding status, show loading
+ if (onboardingComplete === null) {
+ return ;
+ }
+
return (
-
-
-
+ {/* Onboarding flow - shown when not complete */}
+
+
+ {/* Main app - shown when onboarding complete */}
+
+
+ {/* Modal screens */}
+
+
+
+ {/* Index route for initial redirect */}
+
);
}
@@ -36,15 +75,10 @@ function AppContent({ children }: { children: React.ReactNode }) {
}
/**
- * Root Layout - Local Mode
- *
- * This is the simplified layout for local storage mode.
- * No authentication is required - the app launches directly.
+ * Root Layout
*
- * For cloud authentication with Firebase, select the Firebase backend
- * when generating your app:
- * npx create-spezivibe-app my-app
- * # Select "Firebase" when prompted for backend
+ * Handles onboarding flow and main app navigation.
+ * Users must complete onboarding before accessing the main app.
*/
export default function RootLayout() {
const colorScheme = useColorScheme();
diff --git a/homeflow/app/index.tsx b/homeflow/app/index.tsx
new file mode 100644
index 0000000..6b9c67e
--- /dev/null
+++ b/homeflow/app/index.tsx
@@ -0,0 +1,27 @@
+/**
+ * Root Index
+ *
+ * Initial route that redirects to either onboarding or main app
+ * based on the user's onboarding status.
+ */
+
+import React from 'react';
+import { Redirect, Href } from 'expo-router';
+import { useOnboardingStatus } from '@/hooks/use-onboarding-status';
+import { LoadingScreen } from '@/components/ui/loading-screen';
+
+export default function RootIndex() {
+ const onboardingComplete = useOnboardingStatus();
+
+ // While loading, show loading screen
+ if (onboardingComplete === null) {
+ return ;
+ }
+
+ // Redirect based on onboarding status
+ if (onboardingComplete) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/homeflow/components/onboarding/ConsentSection.tsx b/homeflow/components/onboarding/ConsentSection.tsx
new file mode 100644
index 0000000..c731f87
--- /dev/null
+++ b/homeflow/components/onboarding/ConsentSection.tsx
@@ -0,0 +1,266 @@
+/**
+ * Consent Section Component
+ *
+ * Displays a section of the consent document with
+ * expandable content and read tracking.
+ */
+
+import React, { useState, useRef } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ Animated,
+ useColorScheme,
+ LayoutChangeEvent,
+} from 'react-native';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+
+interface ConsentSectionProps {
+ title: string;
+ content: string;
+ required?: boolean;
+ isRead?: boolean;
+ onRead?: () => void;
+ defaultExpanded?: boolean;
+}
+
+export function ConsentSection({
+ title,
+ content,
+ required = false,
+ isRead = false,
+ onRead,
+ defaultExpanded = false,
+}: ConsentSectionProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const [expanded, setExpanded] = useState(defaultExpanded);
+ const [contentHeight, setContentHeight] = useState(0);
+ const animatedHeight = useRef(new Animated.Value(defaultExpanded ? 1 : 0)).current;
+
+ const handleLayout = (event: LayoutChangeEvent) => {
+ const { height } = event.nativeEvent.layout;
+ if (height > 0) {
+ setContentHeight(height);
+ }
+ };
+
+ const toggle = () => {
+ const toValue = expanded ? 0 : 1;
+
+ Animated.spring(animatedHeight, {
+ toValue,
+ useNativeDriver: false,
+ tension: 50,
+ friction: 10,
+ }).start();
+
+ if (!expanded && !isRead && onRead) {
+ onRead();
+ }
+
+ setExpanded(!expanded);
+ };
+
+ const rotateIcon = animatedHeight.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['0deg', '180deg'],
+ });
+
+ const maxHeight = animatedHeight.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, contentHeight || 500],
+ });
+
+ // Parse markdown-like bold text
+ const renderContent = (text: string) => {
+ const parts = text.split(/(\*\*.*?\*\*)/g);
+ return parts.map((part, index) => {
+ if (part.startsWith('**') && part.endsWith('**')) {
+ return (
+
+ {part.slice(2, -2)}
+
+ );
+ }
+ return part;
+ });
+ };
+
+ return (
+
+
+
+ {required && (
+
+
+
+ )}
+ {title}
+
+
+
+
+
+
+
+
+
+ {renderContent(content)}
+
+
+
+
+ );
+}
+
+/**
+ * Consent Summary for final confirmation
+ */
+interface ConsentSummaryProps {
+ summary: string;
+ agreed: boolean;
+ onToggle: () => void;
+}
+
+export function ConsentAgreement({ summary, agreed, onToggle }: ConsentSummaryProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ return (
+
+ {summary}
+
+
+
+ {agreed && }
+
+
+ I have read and agree to participate in this study
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ borderRadius: 12,
+ borderWidth: 1,
+ marginBottom: Spacing.sm,
+ overflow: 'hidden',
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: Spacing.md,
+ },
+ titleContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ marginRight: Spacing.sm,
+ },
+ requiredBadge: {
+ width: 18,
+ height: 18,
+ borderRadius: 9,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 8,
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '600',
+ flex: 1,
+ },
+ contentWrapper: {
+ overflow: 'hidden',
+ },
+ content: {
+ padding: Spacing.md,
+ paddingTop: 0,
+ },
+ contentText: {
+ fontSize: 15,
+ lineHeight: 22,
+ },
+ bold: {
+ fontWeight: '600',
+ },
+ agreementContainer: {
+ borderRadius: 12,
+ borderWidth: 2,
+ padding: Spacing.md,
+ marginTop: Spacing.md,
+ },
+ summaryText: {
+ fontSize: 14,
+ lineHeight: 20,
+ marginBottom: Spacing.md,
+ },
+ checkbox: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ checkboxBox: {
+ width: 24,
+ height: 24,
+ borderRadius: 6,
+ borderWidth: 2,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 12,
+ },
+ checkboxLabel: {
+ fontSize: 15,
+ fontWeight: '500',
+ flex: 1,
+ },
+});
diff --git a/homeflow/components/onboarding/ContinueButton.tsx b/homeflow/components/onboarding/ContinueButton.tsx
new file mode 100644
index 0000000..0040d87
--- /dev/null
+++ b/homeflow/components/onboarding/ContinueButton.tsx
@@ -0,0 +1,97 @@
+/**
+ * Continue Button
+ *
+ * Primary action button for onboarding screens with loading state.
+ */
+
+import React from 'react';
+import {
+ TouchableOpacity,
+ Text,
+ StyleSheet,
+ ActivityIndicator,
+ useColorScheme,
+ ViewStyle,
+} from 'react-native';
+import { StanfordColors, Colors } from '@/constants/theme';
+
+interface ContinueButtonProps {
+ title?: string;
+ onPress: () => void;
+ disabled?: boolean;
+ loading?: boolean;
+ variant?: 'primary' | 'secondary' | 'text';
+ style?: ViewStyle;
+}
+
+export function ContinueButton({
+ title = 'Continue',
+ onPress,
+ disabled = false,
+ loading = false,
+ variant = 'primary',
+ style,
+}: ContinueButtonProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const isDisabled = disabled || loading;
+
+ const getBackgroundColor = () => {
+ if (variant === 'text') return 'transparent';
+ if (variant === 'secondary') {
+ return colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7';
+ }
+ if (isDisabled) {
+ return colorScheme === 'dark' ? '#2C2C2E' : '#E5E5EA';
+ }
+ return StanfordColors.cardinal;
+ };
+
+ const getTextColor = () => {
+ if (variant === 'text') return StanfordColors.cardinal;
+ if (variant === 'secondary') return colors.text;
+ if (isDisabled) return colors.icon;
+ return '#FFFFFF';
+ };
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ {title}
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ height: 52,
+ borderRadius: 14,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ textButton: {
+ height: 44,
+ },
+ text: {
+ fontSize: 17,
+ fontWeight: '600',
+ },
+});
diff --git a/homeflow/components/onboarding/OnboardingProgress.tsx b/homeflow/components/onboarding/OnboardingProgress.tsx
new file mode 100644
index 0000000..82c97d6
--- /dev/null
+++ b/homeflow/components/onboarding/OnboardingProgress.tsx
@@ -0,0 +1,167 @@
+/**
+ * Onboarding Progress Indicator
+ *
+ * Shows progress through the onboarding flow with animated dots.
+ */
+
+import React, { useEffect, useRef } from 'react';
+import { View, StyleSheet, Animated, useColorScheme } from 'react-native';
+import { ONBOARDING_FLOW, OnboardingStep } from '@/lib/constants';
+import { Colors, StanfordColors } from '@/constants/theme';
+
+interface OnboardingProgressProps {
+ currentStep: OnboardingStep;
+ style?: object;
+}
+
+export function OnboardingProgress({ currentStep, style }: OnboardingProgressProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const currentIndex = ONBOARDING_FLOW.indexOf(currentStep);
+ // Don't show the "complete" step in the dots
+ const totalSteps = ONBOARDING_FLOW.length - 1;
+
+ const animatedValues = useRef(
+ ONBOARDING_FLOW.slice(0, -1).map(() => new Animated.Value(0))
+ ).current;
+
+ useEffect(() => {
+ // Animate dots when step changes
+ animatedValues.forEach((anim, index) => {
+ Animated.spring(anim, {
+ toValue: index <= currentIndex ? 1 : 0,
+ useNativeDriver: true,
+ tension: 50,
+ friction: 7,
+ }).start();
+ });
+ }, [currentIndex, animatedValues]);
+
+ return (
+
+ {ONBOARDING_FLOW.slice(0, -1).map((step, index) => {
+ const isActive = index <= currentIndex;
+ const isCurrent = index === currentIndex;
+
+ const scale = animatedValues[index].interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 1.2],
+ });
+
+ return (
+
+
+ {index < totalSteps - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Minimal progress bar variant
+ */
+export function OnboardingProgressBar({
+ currentStep,
+ style,
+}: OnboardingProgressProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const currentIndex = ONBOARDING_FLOW.indexOf(currentStep);
+ const totalSteps = ONBOARDING_FLOW.length - 1;
+ const progress = (currentIndex / (totalSteps - 1)) * 100;
+
+ const animatedWidth = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.spring(animatedWidth, {
+ toValue: progress,
+ useNativeDriver: false,
+ tension: 50,
+ friction: 10,
+ }).start();
+ }, [progress, animatedWidth]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 16,
+ },
+ dotContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ dot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ },
+ currentDot: {
+ shadowColor: StanfordColors.cardinal,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.3,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ connector: {
+ width: 24,
+ height: 2,
+ marginHorizontal: 4,
+ },
+ barContainer: {
+ paddingHorizontal: 24,
+ paddingVertical: 8,
+ },
+ barBackground: {
+ height: 4,
+ borderRadius: 2,
+ overflow: 'hidden',
+ },
+ barFill: {
+ height: '100%',
+ borderRadius: 2,
+ },
+});
diff --git a/homeflow/components/onboarding/PermissionCard.tsx b/homeflow/components/onboarding/PermissionCard.tsx
new file mode 100644
index 0000000..a92e87a
--- /dev/null
+++ b/homeflow/components/onboarding/PermissionCard.tsx
@@ -0,0 +1,235 @@
+/**
+ * Permission Card
+ *
+ * Card component for requesting individual permissions
+ * with status indicator and action button.
+ */
+
+import React from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ useColorScheme,
+ ActivityIndicator,
+} from 'react-native';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors, StanfordColors, Spacing } from '@/constants/theme';
+
+export type PermissionStatus = 'not_determined' | 'granted' | 'denied' | 'skipped' | 'loading';
+
+interface PermissionCardProps {
+ title: string;
+ description: string;
+ icon: string;
+ status: PermissionStatus;
+ onRequest: () => void;
+ onSkip?: () => void;
+ optional?: boolean;
+ comingSoon?: boolean;
+}
+
+export function PermissionCard({
+ title,
+ description,
+ icon,
+ status,
+ onRequest,
+ onSkip,
+ optional = false,
+ comingSoon = false,
+}: PermissionCardProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ const getStatusColor = () => {
+ switch (status) {
+ case 'granted':
+ return '#34C759'; // iOS green
+ case 'denied':
+ return '#FF3B30'; // iOS red
+ case 'skipped':
+ return colors.icon;
+ default:
+ return colors.icon;
+ }
+ };
+
+ const getStatusIcon = (): string => {
+ switch (status) {
+ case 'granted':
+ return 'checkmark.circle.fill';
+ case 'denied':
+ return 'xmark.circle.fill';
+ case 'skipped':
+ return 'minus.circle';
+ default:
+ return 'circle';
+ }
+ };
+
+ const getButtonText = () => {
+ if (comingSoon) return 'Coming Soon';
+ switch (status) {
+ case 'granted':
+ return 'Enabled';
+ case 'denied':
+ return 'Open Settings';
+ case 'skipped':
+ return 'Skipped';
+ case 'loading':
+ return 'Requesting...';
+ default:
+ return 'Enable';
+ }
+ };
+
+ const isDisabled = status === 'granted' || status === 'loading' || comingSoon;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+ {comingSoon ? 'Throne integration coming soon. You can set this up later.' : description}
+
+
+
+
+ {status === 'loading' ? (
+
+ ) : (
+
+ {getButtonText()}
+
+ )}
+
+
+ {optional && status === 'not_determined' && onSkip && (
+
+ Skip for now
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ borderRadius: 16,
+ borderWidth: 1,
+ padding: Spacing.md,
+ marginBottom: Spacing.md,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ marginBottom: Spacing.sm,
+ },
+ iconContainer: {
+ width: 56,
+ height: 56,
+ borderRadius: 14,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ statusContainer: {
+ padding: 4,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 4,
+ },
+ description: {
+ fontSize: 14,
+ lineHeight: 20,
+ marginBottom: Spacing.md,
+ },
+ actions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.sm,
+ },
+ button: {
+ flex: 1,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 44,
+ },
+ buttonText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ skipButton: {
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ },
+ skipText: {
+ fontSize: 14,
+ },
+});
diff --git a/homeflow/components/onboarding/index.ts b/homeflow/components/onboarding/index.ts
new file mode 100644
index 0000000..3cb52da
--- /dev/null
+++ b/homeflow/components/onboarding/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Onboarding Components
+ */
+
+export { OnboardingProgress, OnboardingProgressBar } from './OnboardingProgress';
+export { PermissionCard } from './PermissionCard';
+export type { PermissionStatus } from './PermissionCard';
+export { ConsentSection, ConsentAgreement } from './ConsentSection';
+export { ContinueButton } from './ContinueButton';
diff --git a/homeflow/hooks/use-onboarding-status.ts b/homeflow/hooks/use-onboarding-status.ts
index 55fb9b2..99cd519 100644
--- a/homeflow/hooks/use-onboarding-status.ts
+++ b/homeflow/hooks/use-onboarding-status.ts
@@ -1,34 +1,138 @@
/**
- * Stub onboarding status hook for base template
- * When onboarding feature is added, this file is replaced
+ * Onboarding status hook
+ *
+ * Provides real-time onboarding status for navigation guards
+ * and UI components.
*/
-import { useState, useEffect } from 'react';
+
+import { useState, useEffect, useCallback } from 'react';
+import { OnboardingService } from '@/lib/services/onboarding-service';
+import { OnboardingStep } from '@/lib/constants';
/**
* Hook that returns onboarding completion status
- * Base implementation always returns true (onboarding complete)
+ * Returns null while loading, true if complete, false if not
*/
export function useOnboardingStatus(): boolean | null {
const [status, setStatus] = useState(null);
useEffect(() => {
- // In the base template, onboarding is considered complete
- setStatus(true);
+ let cancelled = false;
+
+ async function checkStatus() {
+ const isComplete = await OnboardingService.isComplete();
+ if (!cancelled) {
+ setStatus(isComplete);
+ }
+ }
+
+ checkStatus();
+
+ return () => {
+ cancelled = true;
+ };
}, []);
return status;
}
/**
- * Mark onboarding as completed (no-op in base template)
+ * Hook that returns the current onboarding step
+ */
+export function useOnboardingStep(): OnboardingStep | null {
+ const [step, setStep] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function getStep() {
+ const currentStep = await OnboardingService.getCurrentStep();
+ if (!cancelled) {
+ setStep(currentStep);
+ }
+ }
+
+ getStep();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return step;
+}
+
+/**
+ * Hook that provides onboarding navigation controls
+ */
+export function useOnboardingNavigation() {
+ const [currentStep, setCurrentStep] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function init() {
+ const step = await OnboardingService.getCurrentStep();
+ if (!cancelled) {
+ setCurrentStep(step);
+ setIsLoading(false);
+ }
+ }
+
+ init();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const nextStep = useCallback(async () => {
+ setIsLoading(true);
+ const next = await OnboardingService.nextStep();
+ setCurrentStep(next);
+ setIsLoading(false);
+ return next;
+ }, []);
+
+ const goToStep = useCallback(async (step: OnboardingStep) => {
+ setIsLoading(true);
+ await OnboardingService.goToStep(step);
+ setCurrentStep(step);
+ setIsLoading(false);
+ }, []);
+
+ const complete = useCallback(async () => {
+ setIsLoading(true);
+ await OnboardingService.complete();
+ setCurrentStep(OnboardingStep.COMPLETE);
+ setIsLoading(false);
+ }, []);
+
+ const getProgress = useCallback(() => {
+ return OnboardingService.getProgress();
+ }, []);
+
+ return {
+ currentStep,
+ isLoading,
+ nextStep,
+ goToStep,
+ complete,
+ getProgress,
+ };
+}
+
+/**
+ * Mark onboarding as completed
*/
export async function markOnboardingCompleted(): Promise {
- // No-op in base template
+ await OnboardingService.complete();
}
/**
- * Reset onboarding status (no-op in base template)
+ * Reset onboarding status (for testing)
*/
export async function resetOnboardingStatus(): Promise {
- // No-op in base template
+ await OnboardingService.reset();
}
diff --git a/homeflow/lib/consent/consent-document.ts b/homeflow/lib/consent/consent-document.ts
new file mode 100644
index 0000000..213e591
--- /dev/null
+++ b/homeflow/lib/consent/consent-document.ts
@@ -0,0 +1,233 @@
+/**
+ * Consent Document
+ *
+ * Informed consent text for the HomeFlow BPH study.
+ * Structured for display in the consent screen.
+ *
+ * Note: This is a template - actual consent text should be
+ * reviewed and approved by your IRB.
+ */
+
+import { STUDY_INFO, CONSENT_VERSION } from '../constants';
+
+/**
+ * Section of the consent document
+ */
+export interface ConsentSection {
+ id: string;
+ title: string;
+ content: string;
+ required?: boolean; // Must scroll through to proceed
+}
+
+/**
+ * Full consent document structure
+ */
+export interface ConsentDocument {
+ version: string;
+ title: string;
+ studyName: string;
+ institution: string;
+ principalInvestigator: string;
+ irbProtocol: string;
+ sections: ConsentSection[];
+ signatureRequired: boolean;
+}
+
+/**
+ * HomeFlow study consent document
+ */
+export const CONSENT_DOCUMENT: ConsentDocument = {
+ version: CONSENT_VERSION,
+ title: 'Informed Consent',
+ studyName: STUDY_INFO.name,
+ institution: STUDY_INFO.institution,
+ principalInvestigator: STUDY_INFO.principalInvestigator,
+ irbProtocol: STUDY_INFO.irbProtocol,
+ signatureRequired: true,
+
+ sections: [
+ {
+ id: 'overview',
+ title: 'Study Overview',
+ required: true,
+ content: `You are being asked to participate in a research study. This document provides important information about the study, including its purpose, what participation involves, and the risks and benefits.
+
+**Study Title:** ${STUDY_INFO.name}
+
+**Principal Investigator:** ${STUDY_INFO.principalInvestigator}
+
+**Institution:** ${STUDY_INFO.institution}
+
+**IRB Protocol:** ${STUDY_INFO.irbProtocol}
+
+Please read this information carefully and take as much time as you need. You may ask questions at any time by contacting the research team.`,
+ },
+ {
+ id: 'purpose',
+ title: 'Purpose of the Study',
+ required: true,
+ content: `The purpose of this research study is to understand how voiding patterns, physical activity, and sleep change before and after bladder outlet surgery for benign prostatic hyperplasia (BPH).
+
+By collecting data passively using your iPhone, Apple Watch, and optional Throne uroflowmetry device, we hope to:
+
+- Better understand recovery patterns after BPH surgery
+- Identify factors that predict surgical outcomes
+- Develop tools to help patients and doctors monitor recovery
+
+This study will enroll approximately 100 participants over 2 years.`,
+ },
+ {
+ id: 'procedures',
+ title: 'Study Procedures',
+ required: true,
+ content: `If you agree to participate, you will be asked to:
+
+**During Enrollment (Today)**
+- Complete eligibility screening
+- Provide informed consent (this document)
+- Share access to Apple Health data
+- Optionally connect your Throne device
+- Complete a baseline symptom questionnaire (IPSS)
+
+**During the Study (6 months)**
+- Allow the app to passively collect health data:
+ - Step count and activity levels
+ - Sleep duration and patterns
+ - Heart rate (if available)
+ - Voiding data from Throne (if connected)
+- Complete brief symptom surveys periodically (about 5 minutes each)
+- Continue using your devices normally
+
+**What We Collect**
+- Health data from Apple Watch and iPhone
+- Uroflow measurements from Throne (optional)
+- Survey responses about your symptoms
+- Basic demographic information`,
+ },
+ {
+ id: 'risks',
+ title: 'Risks and Discomforts',
+ required: true,
+ content: `**Minimal Physical Risk**
+This study involves no physical interventions. You will continue your normal medical care.
+
+**Privacy Risk**
+There is a risk that your personal health information could be accessed by unauthorized individuals. We take extensive measures to protect your data (see Privacy section).
+
+**Time and Inconvenience**
+Completing surveys takes a small amount of time. The app runs in the background and should not interfere with normal phone use.
+
+**Emotional Discomfort**
+Some participants may feel uncomfortable answering questions about urinary symptoms. You may skip any question you prefer not to answer.`,
+ },
+ {
+ id: 'benefits',
+ title: 'Benefits',
+ required: true,
+ content: `**Potential Benefits to You**
+- Track your symptoms and recovery over time
+- Receive personalized insights about your health patterns
+- Contribute to research that may help future patients
+
+**Benefits to Society**
+- Improve understanding of BPH surgery outcomes
+- Help develop better monitoring tools for patients
+- Advance scientific knowledge in urology`,
+ },
+ {
+ id: 'privacy',
+ title: 'Privacy and Data Protection',
+ required: true,
+ content: `**How We Protect Your Data**
+- All data is encrypted in transit and at rest
+- Data is stored on secure, HIPAA-compliant servers
+- Your identity is separated from your health data
+- Only authorized researchers can access study data
+
+**What We Share**
+- De-identified data may be shared with other researchers
+- We will never sell your data
+- We will never share identifiable data without your permission
+
+**Your Rights**
+- You can request a copy of your data at any time
+- You can request deletion of your data
+- You can withdraw from the study at any time
+
+**Data Retention**
+- Study data will be retained for 7 years after study completion
+- After this period, data will be securely destroyed`,
+ },
+ {
+ id: 'compensation',
+ title: 'Compensation',
+ required: false,
+ content: `There is no direct compensation for participating in this study. However, you will receive:
+
+- Free access to the HomeFlow app during the study
+- Personalized health insights based on your data
+- Summary of your symptom trends over time
+
+If you are injured as a result of being in this study, ${STUDY_INFO.institution} does not have a program to compensate you.`,
+ },
+ {
+ id: 'voluntary',
+ title: 'Voluntary Participation',
+ required: true,
+ content: `Your participation in this study is completely voluntary.
+
+- You may choose not to participate
+- You may withdraw at any time without penalty
+- Withdrawing will not affect your medical care
+- You may skip any questions you don't want to answer
+
+To withdraw from the study, contact the research team or use the "Withdraw from Study" option in the app settings.`,
+ },
+ {
+ id: 'contact',
+ title: 'Contact Information',
+ required: true,
+ content: `**Questions About the Study**
+Contact the research team:
+- Email: ${STUDY_INFO.contactEmail}
+- Phone: ${STUDY_INFO.contactPhone}
+
+**Questions About Your Rights as a Participant**
+Contact the ${STUDY_INFO.institution} Institutional Review Board (IRB):
+- Protocol Number: ${STUDY_INFO.irbProtocol}
+
+**Medical Concerns**
+For any medical concerns, contact your healthcare provider directly.`,
+ },
+ ],
+};
+
+/**
+ * Get consent sections that require reading
+ */
+export function getRequiredSections(): ConsentSection[] {
+ return CONSENT_DOCUMENT.sections.filter((s) => s.required);
+}
+
+/**
+ * Get all consent sections
+ */
+export function getAllSections(): ConsentSection[] {
+ return CONSENT_DOCUMENT.sections;
+}
+
+/**
+ * Generate a summary of consent for confirmation
+ */
+export function getConsentSummary(): string {
+ return `By signing below, I confirm that:
+
+- I have read and understood the consent document
+- I have had the opportunity to ask questions
+- I understand the risks and benefits of participation
+- I understand I can withdraw at any time
+- I agree to participate in the ${STUDY_INFO.name}
+
+Version: ${CONSENT_VERSION}`;
+}
diff --git a/homeflow/lib/constants.ts b/homeflow/lib/constants.ts
index 40e71a9..969c02b 100644
--- a/homeflow/lib/constants.ts
+++ b/homeflow/lib/constants.ts
@@ -5,10 +5,78 @@
/**
* Storage keys
*/
+export const STORAGE_KEYS = {
+ // Onboarding
+ ONBOARDING_STEP: '@homeflow_onboarding_step',
+ ONBOARDING_DATA: '@homeflow_onboarding_data',
+
+ // Consent
+ CONSENT_GIVEN: '@homeflow_consent_given',
+ CONSENT_DATE: '@homeflow_consent_date',
+ CONSENT_VERSION: '@homeflow_consent_version',
+
+ // Account
+ ACCOUNT_PROFILE: '@homeflow_account_profile',
+
+ // Medical history (collected via chatbot)
+ MEDICAL_HISTORY: '@homeflow_medical_history',
+
+ // Eligibility
+ ELIGIBILITY_RESPONSES: '@homeflow_eligibility_responses',
+
+ // IPSS baseline
+ IPSS_BASELINE: '@homeflow_ipss_baseline',
+
+ // Permissions
+ PERMISSIONS_STATUS: '@homeflow_permissions_status',
+} as const;
+
+// Legacy keys for backwards compatibility
export const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
export const CONSENT_KEY = '@consent_given';
+/**
+ * Onboarding steps - defines the flow order
+ */
+export enum OnboardingStep {
+ WELCOME = 'welcome',
+ CHAT = 'chat', // Combined eligibility + medical history
+ CONSENT = 'consent',
+ PERMISSIONS = 'permissions',
+ BASELINE_SURVEY = 'baseline_survey',
+ COMPLETE = 'complete',
+}
+
+/**
+ * Ordered array of onboarding steps for navigation
+ */
+export const ONBOARDING_FLOW: OnboardingStep[] = [
+ OnboardingStep.WELCOME,
+ OnboardingStep.CHAT,
+ OnboardingStep.CONSENT,
+ OnboardingStep.PERMISSIONS,
+ OnboardingStep.BASELINE_SURVEY,
+ OnboardingStep.COMPLETE,
+];
+
/**
* FHIR identifier system for task IDs
*/
export const SPEZIVIBE_TASK_ID_SYSTEM = 'http://spezivibe.com/fhir/identifier/task-id';
+
+/**
+ * Consent document version - increment when consent text changes
+ */
+export const CONSENT_VERSION = '1.0.0';
+
+/**
+ * Study information
+ */
+export const STUDY_INFO = {
+ name: 'HomeFlow BPH Study',
+ institution: 'Stanford University',
+ principalInvestigator: 'Dr. [PI Name]',
+ irbProtocol: '[IRB Protocol Number]',
+ contactEmail: 'homeflow-study@stanford.edu',
+ contactPhone: '(650) 123-4567',
+} as const;
diff --git a/homeflow/lib/questionnaires/eligibility-questionnaire.ts b/homeflow/lib/questionnaires/eligibility-questionnaire.ts
new file mode 100644
index 0000000..30ff046
--- /dev/null
+++ b/homeflow/lib/questionnaires/eligibility-questionnaire.ts
@@ -0,0 +1,167 @@
+/**
+ * Eligibility Questionnaire
+ *
+ * Defines the eligibility criteria for the HomeFlow BPH study.
+ * Used by the chatbot to validate eligibility conversationally.
+ *
+ * Note: The chatbot uses this as a reference for what to ask.
+ * The actual conversation is more natural than a form.
+ */
+
+import type { Questionnaire } from 'fhir/r4';
+import { QuestionnaireBuilder } from '@spezivibe/questionnaire';
+
+/**
+ * Eligibility criteria for the study
+ */
+export interface EligibilityCriteria {
+ hasIPhone: boolean;
+ hasAppleWatch: boolean;
+ hasBPHDiagnosis: boolean;
+ consideringSurgery: boolean;
+ willingToUseThrone: boolean;
+ age?: number;
+}
+
+/**
+ * Check if criteria meet eligibility requirements
+ */
+export function checkEligibility(criteria: EligibilityCriteria): {
+ isEligible: boolean;
+ reasons: string[];
+} {
+ const reasons: string[] = [];
+
+ if (!criteria.hasIPhone) {
+ reasons.push('An iPhone with iOS 15 or later is required for the study.');
+ }
+
+ if (!criteria.hasAppleWatch) {
+ reasons.push('An Apple Watch is required for continuous health monitoring.');
+ }
+
+ if (!criteria.hasBPHDiagnosis) {
+ reasons.push('This study is for individuals diagnosed with BPH or experiencing BPH symptoms.');
+ }
+
+ if (!criteria.consideringSurgery) {
+ reasons.push('This study focuses on patients scheduled for or considering bladder outlet surgery.');
+ }
+
+ // Throne is optional but encouraged
+ if (!criteria.willingToUseThrone) {
+ // This is a soft requirement - we can proceed without it
+ }
+
+ return {
+ isEligible: reasons.length === 0,
+ reasons,
+ };
+}
+
+/**
+ * Eligibility Questionnaire - FHIR R4 compliant
+ * Used as a reference for the chatbot conversation
+ */
+export const ELIGIBILITY_QUESTIONNAIRE: Questionnaire = new QuestionnaireBuilder('eligibility')
+ .title('Study Eligibility Screening')
+ .description('Please answer these questions to determine if you are eligible for the HomeFlow study.')
+ .version('1.0.0')
+ .addBoolean('has_iphone', 'Do you have an iPhone with iOS 15 or later?', {
+ required: true,
+ })
+ .addBoolean('has_apple_watch', 'Do you have an Apple Watch?', {
+ required: true,
+ })
+ .addBoolean('has_bph_diagnosis', 'Have you been diagnosed with BPH (benign prostatic hyperplasia) or are you experiencing urinary symptoms?', {
+ required: true,
+ })
+ .addBoolean('considering_surgery', 'Are you scheduled for or considering bladder outlet surgery (such as TURP, laser therapy, or other BPH procedures)?', {
+ required: true,
+ })
+ .addBoolean('willing_throne', 'Are you willing to use a Throne uroflowmetry device to track your voiding patterns? (This is optional but recommended)', {
+ required: true,
+ })
+ .addInteger('age', 'What is your age?', {
+ required: false,
+ min: 18,
+ max: 120,
+ })
+ .build();
+
+/**
+ * System prompt for the eligibility chatbot
+ * This guides the AI to collect eligibility information conversationally
+ */
+export const ELIGIBILITY_CHATBOT_PROMPT = `You are a friendly research assistant helping to screen participants for the HomeFlow BPH study. Your goal is to determine eligibility through natural conversation.
+
+## Study Information
+- Name: HomeFlow BPH Study
+- Purpose: Track voiding patterns and symptoms before/after bladder outlet surgery
+- Requires: iPhone with iOS 15+, Apple Watch
+- Focus: Men with BPH who are considering or scheduled for surgery
+
+## Your Task
+Ask about eligibility criteria naturally. Don't read a checklist - have a conversation.
+
+## Eligibility Criteria (must collect all):
+1. Has iPhone with iOS 15+ (required)
+2. Has Apple Watch (required)
+3. Has BPH diagnosis OR experiencing urinary symptoms (required)
+4. Considering or scheduled for bladder outlet surgery (required)
+5. Willing to use Throne device (optional but encouraged)
+
+## Guidelines
+- Be warm and conversational, not clinical
+- Ask one thing at a time
+- If they mention symptoms, acknowledge them empathetically
+- If they're not eligible, be kind but clear
+- Once eligibility is determined, summarize and confirm
+
+## Example Opening
+"Hi! I'm here to help you join the HomeFlow study. First, I'd love to learn a bit about you. Are you currently using an iPhone?"
+
+## After Eligibility (if eligible)
+Once you've confirmed they're eligible, say something like:
+"Great news - you're eligible for the study! Next, I'd like to learn a bit about your medical history to personalize your experience. Is that okay?"
+
+Then transition to collecting medical history.`;
+
+/**
+ * System prompt for medical history collection
+ * Used after eligibility is confirmed
+ */
+export const MEDICAL_HISTORY_CHATBOT_PROMPT = `You are continuing to help a participant who has been confirmed eligible for the HomeFlow BPH study. Now collect their medical history through natural conversation.
+
+## Information to Collect
+1. Current medications (especially for BPH/urinary symptoms)
+2. Other medical conditions
+3. Allergies
+4. Previous surgeries (especially prostate/urinary related)
+5. BPH treatment history (medications tried, procedures done)
+
+## Guidelines
+- Be conversational and empathetic
+- If they mention something concerning, acknowledge it but don't give medical advice
+- It's okay if they don't remember everything - get what you can
+- Focus especially on BPH-related history
+
+## Example Flow
+"Now I'd like to learn about your health history. Are you currently taking any medications for your prostate or urinary symptoms?"
+
+[After medications]
+"Got it. And do you have any other medical conditions I should know about?"
+
+[After conditions]
+"Thanks for sharing that. Any allergies to medications?"
+
+[After allergies]
+"Have you had any surgeries before, especially related to your prostate or bladder?"
+
+[After surgeries]
+"Last question - what treatments have you tried for your BPH symptoms? This could include medications like Flomax or Proscar, or any procedures."
+
+## When Complete
+Once you have the key information, summarize what you've collected and let them know they're ready for the next step:
+
+"Thanks for sharing all of that! I've noted your [summarize key points]. You're all set to continue with the consent form. Tap 'Continue' when you're ready."`;
diff --git a/homeflow/lib/questionnaires/ipss-questionnaire.ts b/homeflow/lib/questionnaires/ipss-questionnaire.ts
new file mode 100644
index 0000000..05a00a0
--- /dev/null
+++ b/homeflow/lib/questionnaires/ipss-questionnaire.ts
@@ -0,0 +1,184 @@
+/**
+ * International Prostate Symptom Score (IPSS) Questionnaire
+ *
+ * The IPSS is a validated 8-question instrument for assessing
+ * lower urinary tract symptoms (LUTS) in men with BPH.
+ *
+ * Scoring:
+ * - Questions 1-7: 0-5 scale (symptom frequency)
+ * - Question 8 (QoL): 0-6 scale (quality of life)
+ * - Total IPSS Score: 0-35 (sum of Q1-Q7)
+ * - Mild: 0-7
+ * - Moderate: 8-19
+ * - Severe: 20-35
+ *
+ * Reference: Barry MJ, et al. The American Urological Association
+ * Symptom Index for Benign Prostatic Hyperplasia. J Urol. 1992.
+ */
+
+import type { Questionnaire } from 'fhir/r4';
+import { QuestionnaireBuilder } from '@spezivibe/questionnaire';
+
+/**
+ * Standard IPSS frequency answer options (0-5)
+ */
+const FREQUENCY_OPTIONS = [
+ { value: 0, display: 'Not at all' },
+ { value: 1, display: 'Less than 1 time in 5' },
+ { value: 2, display: 'Less than half the time' },
+ { value: 3, display: 'About half the time' },
+ { value: 4, display: 'More than half the time' },
+ { value: 5, display: 'Almost always' },
+];
+
+/**
+ * Quality of Life answer options (0-6)
+ */
+const QOL_OPTIONS = [
+ { value: 0, display: 'Delighted' },
+ { value: 1, display: 'Pleased' },
+ { value: 2, display: 'Mostly satisfied' },
+ { value: 3, display: 'Mixed - about equally satisfied and dissatisfied' },
+ { value: 4, display: 'Mostly dissatisfied' },
+ { value: 5, display: 'Unhappy' },
+ { value: 6, display: 'Terrible' },
+];
+
+/**
+ * IPSS Questionnaire - FHIR R4 compliant
+ */
+export const IPSS_QUESTIONNAIRE: Questionnaire = new QuestionnaireBuilder('ipss')
+ .title('International Prostate Symptom Score (IPSS)')
+ .description(
+ 'Please answer the following questions about your urinary symptoms over the past month.'
+ )
+ .version('1.0.0')
+ .addDisplay(
+ 'instructions',
+ 'For each question, select the answer that best describes your experience over the past month.'
+ )
+ // Question 1: Incomplete Emptying
+ .addChoice('q1_incomplete_emptying',
+ 'Over the past month, how often have you had a sensation of not emptying your bladder completely after you finished urinating?',
+ {
+ required: true,
+ answerOption: FREQUENCY_OPTIONS,
+ }
+ )
+ // Question 2: Frequency
+ .addChoice('q2_frequency',
+ 'Over the past month, how often have you had to urinate again less than two hours after you finished urinating?',
+ {
+ required: true,
+ answerOption: FREQUENCY_OPTIONS,
+ }
+ )
+ // Question 3: Intermittency
+ .addChoice('q3_intermittency',
+ 'Over the past month, how often have you found you stopped and started again several times when you urinated?',
+ {
+ required: true,
+ answerOption: FREQUENCY_OPTIONS,
+ }
+ )
+ // Question 4: Urgency
+ .addChoice('q4_urgency',
+ 'Over the past month, how often have you found it difficult to postpone urination?',
+ {
+ required: true,
+ answerOption: FREQUENCY_OPTIONS,
+ }
+ )
+ // Question 5: Weak Stream
+ .addChoice('q5_weak_stream',
+ 'Over the past month, how often have you had a weak urinary stream?',
+ {
+ required: true,
+ answerOption: FREQUENCY_OPTIONS,
+ }
+ )
+ // Question 6: Straining
+ .addChoice('q6_straining',
+ 'Over the past month, how often have you had to push or strain to begin urination?',
+ {
+ required: true,
+ answerOption: FREQUENCY_OPTIONS,
+ }
+ )
+ // Question 7: Nocturia
+ .addChoice('q7_nocturia',
+ 'Over the past month, how many times did you most typically get up to urinate from the time you went to bed at night until the time you got up in the morning?',
+ {
+ required: true,
+ answerOption: [
+ { value: 0, display: 'None' },
+ { value: 1, display: '1 time' },
+ { value: 2, display: '2 times' },
+ { value: 3, display: '3 times' },
+ { value: 4, display: '4 times' },
+ { value: 5, display: '5 or more times' },
+ ],
+ }
+ )
+ // Question 8: Quality of Life
+ .addChoice('q8_quality_of_life',
+ 'If you were to spend the rest of your life with your urinary condition just the way it is now, how would you feel about that?',
+ {
+ required: true,
+ answerOption: QOL_OPTIONS,
+ }
+ )
+ .build();
+
+/**
+ * Calculate IPSS total score from questionnaire response
+ */
+export function calculateIPSSScore(answers: Record): {
+ totalScore: number;
+ qolScore: number;
+ severity: 'mild' | 'moderate' | 'severe';
+} {
+ // Sum questions 1-7
+ const symptomQuestions = [
+ 'q1_incomplete_emptying',
+ 'q2_frequency',
+ 'q3_intermittency',
+ 'q4_urgency',
+ 'q5_weak_stream',
+ 'q6_straining',
+ 'q7_nocturia',
+ ];
+
+ const totalScore = symptomQuestions.reduce((sum, q) => {
+ const value = answers[q];
+ return sum + (typeof value === 'number' ? value : 0);
+ }, 0);
+
+ const qolScore = answers['q8_quality_of_life'] ?? 0;
+
+ // Determine severity
+ let severity: 'mild' | 'moderate' | 'severe';
+ if (totalScore <= 7) {
+ severity = 'mild';
+ } else if (totalScore <= 19) {
+ severity = 'moderate';
+ } else {
+ severity = 'severe';
+ }
+
+ return { totalScore, qolScore, severity };
+}
+
+/**
+ * Get a human-readable description of the IPSS severity
+ */
+export function getIPSSSeverityDescription(severity: 'mild' | 'moderate' | 'severe'): string {
+ switch (severity) {
+ case 'mild':
+ return 'Your symptoms are mild. Continue monitoring and discuss with your healthcare provider at your next visit.';
+ case 'moderate':
+ return 'Your symptoms are moderate. Consider discussing treatment options with your healthcare provider.';
+ case 'severe':
+ return 'Your symptoms are severe. We recommend consulting with your healthcare provider about treatment options.';
+ }
+}
diff --git a/homeflow/lib/services/account-service.ts b/homeflow/lib/services/account-service.ts
new file mode 100644
index 0000000..a872a03
--- /dev/null
+++ b/homeflow/lib/services/account-service.ts
@@ -0,0 +1,135 @@
+/**
+ * Account Service
+ *
+ * Local account management for the research study.
+ * Provides a Firebase-ready interface but stores locally for the MVP.
+ *
+ * Note: For production with Firebase auth, replace the implementation
+ * while keeping the same interface.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { STORAGE_KEYS } from '../constants';
+
+/**
+ * User profile structure
+ */
+export interface UserProfile {
+ id: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ dateOfBirth?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/**
+ * Account service interface (Firebase-compatible)
+ */
+export interface IAccountService {
+ isAuthenticated(): Promise;
+ getCurrentUser(): Promise;
+ createAccount(profile: Omit): Promise;
+ updateProfile(updates: Partial): Promise;
+ deleteAccount(): Promise;
+}
+
+class LocalAccountService implements IAccountService {
+ private profile: UserProfile | null = null;
+ private initialized = false;
+
+ /**
+ * Initialize by loading profile from storage
+ */
+ private async initialize(): Promise {
+ if (this.initialized) return;
+
+ try {
+ const data = await AsyncStorage.getItem(STORAGE_KEYS.ACCOUNT_PROFILE);
+ if (data) {
+ this.profile = JSON.parse(data);
+ }
+ this.initialized = true;
+ } catch (error) {
+ console.error('Failed to initialize account service:', error);
+ this.initialized = true;
+ }
+ }
+
+ /**
+ * Generate a local user ID
+ */
+ private generateId(): string {
+ return `local_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
+ }
+
+ /**
+ * Check if user is authenticated (has a profile)
+ */
+ async isAuthenticated(): Promise {
+ await this.initialize();
+ return this.profile !== null;
+ }
+
+ /**
+ * Get the current user profile
+ */
+ async getCurrentUser(): Promise {
+ await this.initialize();
+ return this.profile;
+ }
+
+ /**
+ * Create a new account
+ */
+ async createAccount(
+ profile: Omit
+ ): Promise {
+ const now = new Date().toISOString();
+
+ this.profile = {
+ ...profile,
+ id: this.generateId(),
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ await AsyncStorage.setItem(STORAGE_KEYS.ACCOUNT_PROFILE, JSON.stringify(this.profile));
+ return this.profile;
+ }
+
+ /**
+ * Update the user profile
+ */
+ async updateProfile(updates: Partial): Promise {
+ await this.initialize();
+
+ if (!this.profile) {
+ throw new Error('No account exists. Create an account first.');
+ }
+
+ this.profile = {
+ ...this.profile,
+ ...updates,
+ updatedAt: new Date().toISOString(),
+ };
+
+ await AsyncStorage.setItem(STORAGE_KEYS.ACCOUNT_PROFILE, JSON.stringify(this.profile));
+ return this.profile;
+ }
+
+ /**
+ * Delete the account
+ */
+ async deleteAccount(): Promise {
+ this.profile = null;
+ this.initialized = false;
+ await AsyncStorage.removeItem(STORAGE_KEYS.ACCOUNT_PROFILE);
+ }
+}
+
+/**
+ * Singleton instance of the account service
+ */
+export const AccountService = new LocalAccountService();
diff --git a/homeflow/lib/services/consent-service.ts b/homeflow/lib/services/consent-service.ts
new file mode 100644
index 0000000..d4b7875
--- /dev/null
+++ b/homeflow/lib/services/consent-service.ts
@@ -0,0 +1,135 @@
+/**
+ * Consent Service
+ *
+ * Manages informed consent recording and verification for the research study.
+ * Stores consent status, version, and timestamp for IRB compliance.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { STORAGE_KEYS, CONSENT_VERSION, STUDY_INFO } from '../constants';
+
+/**
+ * Consent record structure
+ */
+export interface ConsentRecord {
+ given: boolean;
+ version: string;
+ timestamp: string;
+ participantSignature?: string; // Could be typed name or actual signature
+ studyName: string;
+ irbProtocol: string;
+}
+
+class ConsentServiceImpl {
+ private consentRecord: ConsentRecord | null = null;
+ private initialized = false;
+
+ /**
+ * Initialize by loading consent status from storage
+ */
+ async initialize(): Promise {
+ if (this.initialized) return;
+
+ try {
+ const given = await AsyncStorage.getItem(STORAGE_KEYS.CONSENT_GIVEN);
+ const date = await AsyncStorage.getItem(STORAGE_KEYS.CONSENT_DATE);
+ const version = await AsyncStorage.getItem(STORAGE_KEYS.CONSENT_VERSION);
+
+ if (given === 'true' && date && version) {
+ this.consentRecord = {
+ given: true,
+ version,
+ timestamp: date,
+ studyName: STUDY_INFO.name,
+ irbProtocol: STUDY_INFO.irbProtocol,
+ };
+ }
+
+ this.initialized = true;
+ } catch (error) {
+ console.error('Failed to initialize consent service:', error);
+ this.initialized = true;
+ }
+ }
+
+ /**
+ * Check if consent has been given
+ */
+ async hasConsented(): Promise {
+ await this.initialize();
+ return this.consentRecord?.given === true;
+ }
+
+ /**
+ * Check if consent is current (matches current version)
+ */
+ async isConsentCurrent(): Promise {
+ await this.initialize();
+
+ if (!this.consentRecord?.given) return false;
+ return this.consentRecord.version === CONSENT_VERSION;
+ }
+
+ /**
+ * Record consent
+ */
+ async recordConsent(signature?: string): Promise {
+ const now = new Date().toISOString();
+
+ this.consentRecord = {
+ given: true,
+ version: CONSENT_VERSION,
+ timestamp: now,
+ participantSignature: signature,
+ studyName: STUDY_INFO.name,
+ irbProtocol: STUDY_INFO.irbProtocol,
+ };
+
+ await AsyncStorage.setItem(STORAGE_KEYS.CONSENT_GIVEN, 'true');
+ await AsyncStorage.setItem(STORAGE_KEYS.CONSENT_DATE, now);
+ await AsyncStorage.setItem(STORAGE_KEYS.CONSENT_VERSION, CONSENT_VERSION);
+ }
+
+ /**
+ * Withdraw consent (for participant withdrawal)
+ */
+ async withdrawConsent(): Promise {
+ this.consentRecord = null;
+
+ await AsyncStorage.multiRemove([
+ STORAGE_KEYS.CONSENT_GIVEN,
+ STORAGE_KEYS.CONSENT_DATE,
+ STORAGE_KEYS.CONSENT_VERSION,
+ ]);
+ }
+
+ /**
+ * Get the consent record
+ */
+ async getConsentRecord(): Promise {
+ await this.initialize();
+ return this.consentRecord;
+ }
+
+ /**
+ * Get the current consent version
+ */
+ getCurrentVersion(): string {
+ return CONSENT_VERSION;
+ }
+
+ /**
+ * Check if re-consent is needed (version mismatch)
+ */
+ async needsReconsent(): Promise {
+ await this.initialize();
+
+ if (!this.consentRecord?.given) return false;
+ return this.consentRecord.version !== CONSENT_VERSION;
+ }
+}
+
+/**
+ * Singleton instance of the consent service
+ */
+export const ConsentService = new ConsentServiceImpl();
diff --git a/homeflow/lib/services/onboarding-service.ts b/homeflow/lib/services/onboarding-service.ts
new file mode 100644
index 0000000..23a3347
--- /dev/null
+++ b/homeflow/lib/services/onboarding-service.ts
@@ -0,0 +1,284 @@
+/**
+ * Onboarding Service
+ *
+ * State machine for managing onboarding flow progress.
+ * Persists state to AsyncStorage so users can resume from any step.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { STORAGE_KEYS, OnboardingStep, ONBOARDING_FLOW } from '../constants';
+
+/**
+ * Data collected during onboarding
+ */
+export interface OnboardingData {
+ // Eligibility responses (from chatbot)
+ eligibility?: {
+ hasIPhone: boolean;
+ hasAppleWatch: boolean;
+ hasBPHDiagnosis: boolean;
+ consideringSurgery: boolean;
+ willingToUseThrone: boolean;
+ isEligible: boolean;
+ };
+
+ // Medical history (from chatbot)
+ medicalHistory?: {
+ medications: string[];
+ conditions: string[];
+ allergies: string[];
+ surgicalHistory: string[];
+ bphTreatmentHistory: string[];
+ rawTranscript?: string; // Full chat transcript for reference
+ };
+
+ // Account info
+ account?: {
+ firstName: string;
+ lastName: string;
+ email: string;
+ dateOfBirth?: string;
+ };
+
+ // Permissions status
+ permissions?: {
+ healthKit: 'granted' | 'denied' | 'not_determined';
+ throne: 'granted' | 'denied' | 'not_determined' | 'skipped';
+ };
+
+ // IPSS baseline score
+ ipssBaseline?: {
+ score: number;
+ qolScore: number;
+ completedAt: string;
+ responseId: string;
+ };
+}
+
+/**
+ * Onboarding state stored in AsyncStorage
+ */
+interface OnboardingState {
+ currentStep: OnboardingStep;
+ data: OnboardingData;
+ startedAt: string;
+ lastUpdatedAt: string;
+}
+
+class OnboardingServiceImpl {
+ private state: OnboardingState | null = null;
+ private initialized = false;
+
+ /**
+ * Initialize the service by loading state from AsyncStorage
+ */
+ async initialize(): Promise {
+ if (this.initialized) return;
+
+ try {
+ const stepData = await AsyncStorage.getItem(STORAGE_KEYS.ONBOARDING_STEP);
+ const savedData = await AsyncStorage.getItem(STORAGE_KEYS.ONBOARDING_DATA);
+
+ if (stepData) {
+ this.state = {
+ currentStep: stepData as OnboardingStep,
+ data: savedData ? JSON.parse(savedData) : {},
+ startedAt: new Date().toISOString(),
+ lastUpdatedAt: new Date().toISOString(),
+ };
+ }
+
+ this.initialized = true;
+ } catch (error) {
+ console.error('Failed to initialize onboarding service:', error);
+ this.initialized = true;
+ }
+ }
+
+ /**
+ * Get the current onboarding step
+ */
+ async getCurrentStep(): Promise {
+ await this.initialize();
+ return this.state?.currentStep ?? null;
+ }
+
+ /**
+ * Check if onboarding is complete
+ */
+ async isComplete(): Promise {
+ await this.initialize();
+ return this.state?.currentStep === OnboardingStep.COMPLETE;
+ }
+
+ /**
+ * Check if onboarding has been started
+ */
+ async hasStarted(): Promise {
+ await this.initialize();
+ return this.state !== null;
+ }
+
+ /**
+ * Start onboarding from the beginning
+ */
+ async start(): Promise {
+ const now = new Date().toISOString();
+ this.state = {
+ currentStep: OnboardingStep.WELCOME,
+ data: {},
+ startedAt: now,
+ lastUpdatedAt: now,
+ };
+ await this.persistState();
+ }
+
+ /**
+ * Move to the next step in the flow
+ */
+ async nextStep(): Promise {
+ await this.initialize();
+
+ if (!this.state) {
+ await this.start();
+ return OnboardingStep.WELCOME;
+ }
+
+ const currentIndex = ONBOARDING_FLOW.indexOf(this.state.currentStep);
+ const nextIndex = Math.min(currentIndex + 1, ONBOARDING_FLOW.length - 1);
+ const nextStep = ONBOARDING_FLOW[nextIndex];
+
+ this.state.currentStep = nextStep;
+ this.state.lastUpdatedAt = new Date().toISOString();
+ await this.persistState();
+
+ return nextStep;
+ }
+
+ /**
+ * Go to a specific step (for navigation)
+ */
+ async goToStep(step: OnboardingStep): Promise {
+ await this.initialize();
+
+ if (!this.state) {
+ await this.start();
+ }
+
+ this.state!.currentStep = step;
+ this.state!.lastUpdatedAt = new Date().toISOString();
+ await this.persistState();
+ }
+
+ /**
+ * Update onboarding data
+ */
+ async updateData(data: Partial): Promise {
+ await this.initialize();
+
+ if (!this.state) {
+ await this.start();
+ }
+
+ this.state!.data = { ...this.state!.data, ...data };
+ this.state!.lastUpdatedAt = new Date().toISOString();
+ await this.persistState();
+ }
+
+ /**
+ * Get all collected onboarding data
+ */
+ async getData(): Promise {
+ await this.initialize();
+ return this.state?.data ?? {};
+ }
+
+ /**
+ * Mark user as ineligible and stop onboarding
+ */
+ async markIneligible(): Promise {
+ await this.initialize();
+
+ if (this.state) {
+ this.state.data.eligibility = {
+ ...this.state.data.eligibility,
+ isEligible: false,
+ } as OnboardingData['eligibility'];
+ await this.persistState();
+ }
+ }
+
+ /**
+ * Complete onboarding
+ */
+ async complete(): Promise {
+ await this.initialize();
+
+ if (this.state) {
+ this.state.currentStep = OnboardingStep.COMPLETE;
+ this.state.lastUpdatedAt = new Date().toISOString();
+ await this.persistState();
+ }
+ }
+
+ /**
+ * Reset onboarding (for testing or re-enrollment)
+ */
+ async reset(): Promise {
+ this.state = null;
+ this.initialized = false;
+
+ await AsyncStorage.multiRemove([
+ STORAGE_KEYS.ONBOARDING_STEP,
+ STORAGE_KEYS.ONBOARDING_DATA,
+ STORAGE_KEYS.CONSENT_GIVEN,
+ STORAGE_KEYS.CONSENT_DATE,
+ STORAGE_KEYS.CONSENT_VERSION,
+ STORAGE_KEYS.MEDICAL_HISTORY,
+ STORAGE_KEYS.ELIGIBILITY_RESPONSES,
+ STORAGE_KEYS.IPSS_BASELINE,
+ STORAGE_KEYS.PERMISSIONS_STATUS,
+ ]);
+ }
+
+ /**
+ * Get the step after the current one (for preview)
+ */
+ getNextStepName(): OnboardingStep | null {
+ if (!this.state) return OnboardingStep.WELCOME;
+
+ const currentIndex = ONBOARDING_FLOW.indexOf(this.state.currentStep);
+ if (currentIndex >= ONBOARDING_FLOW.length - 1) return null;
+
+ return ONBOARDING_FLOW[currentIndex + 1];
+ }
+
+ /**
+ * Get progress as a percentage
+ */
+ getProgress(): number {
+ if (!this.state) return 0;
+
+ const currentIndex = ONBOARDING_FLOW.indexOf(this.state.currentStep);
+ return Math.round((currentIndex / (ONBOARDING_FLOW.length - 1)) * 100);
+ }
+
+ /**
+ * Persist state to AsyncStorage
+ */
+ private async persistState(): Promise {
+ if (!this.state) return;
+
+ try {
+ await AsyncStorage.setItem(STORAGE_KEYS.ONBOARDING_STEP, this.state.currentStep);
+ await AsyncStorage.setItem(STORAGE_KEYS.ONBOARDING_DATA, JSON.stringify(this.state.data));
+ } catch (error) {
+ console.error('Failed to persist onboarding state:', error);
+ }
+ }
+}
+
+/**
+ * Singleton instance of the onboarding service
+ */
+export const OnboardingService = new OnboardingServiceImpl();
diff --git a/homeflow/lib/services/throne-service.ts b/homeflow/lib/services/throne-service.ts
new file mode 100644
index 0000000..a1c5ba0
--- /dev/null
+++ b/homeflow/lib/services/throne-service.ts
@@ -0,0 +1,190 @@
+/**
+ * Throne Service (Stubbed)
+ *
+ * Integration with Throne uroflowmetry device.
+ * This is a stub implementation for the MVP - replace with real API when available.
+ *
+ * Throne provides:
+ * - Void timestamp and voided volume
+ * - Maximum flow rate (Qmax) and average flow rate (Qavg)
+ * - Flow curve shape
+ * - Voiding frequency and nocturia events
+ * - Patient annotations (straining, urgency)
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { STORAGE_KEYS } from '../constants';
+
+/**
+ * Throne permission status
+ */
+export type ThronePermissionStatus = 'granted' | 'denied' | 'not_determined' | 'skipped';
+
+/**
+ * Throne device connection status
+ */
+export type ThroneConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'not_setup';
+
+/**
+ * Uroflow measurement data
+ */
+export interface UroflowMeasurement {
+ id: string;
+ timestamp: string;
+ voidedVolume: number; // mL
+ maxFlowRate: number; // mL/s (Qmax)
+ avgFlowRate: number; // mL/s (Qavg)
+ flowTime: number; // seconds
+ voidingTime: number; // seconds
+ timeToMaxFlow: number; // seconds
+ annotations?: {
+ straining?: boolean;
+ urgency?: boolean;
+ incomplete?: boolean;
+ notes?: string;
+ };
+}
+
+/**
+ * Throne service interface
+ */
+export interface IThroneService {
+ getPermissionStatus(): Promise;
+ requestPermission(): Promise;
+ skipSetup(): Promise;
+ getConnectionStatus(): Promise;
+ getMeasurements(startDate?: Date, endDate?: Date): Promise;
+ getLatestMeasurement(): Promise;
+}
+
+class StubThroneService implements IThroneService {
+ private permissionStatus: ThronePermissionStatus = 'not_determined';
+ private initialized = false;
+
+ /**
+ * Initialize by loading status from storage
+ */
+ private async initialize(): Promise {
+ if (this.initialized) return;
+
+ try {
+ const data = await AsyncStorage.getItem(STORAGE_KEYS.PERMISSIONS_STATUS);
+ if (data) {
+ const permissions = JSON.parse(data);
+ this.permissionStatus = permissions.throne || 'not_determined';
+ }
+ this.initialized = true;
+ } catch (error) {
+ console.error('Failed to initialize throne service:', error);
+ this.initialized = true;
+ }
+ }
+
+ /**
+ * Persist permission status
+ */
+ private async persistStatus(): Promise {
+ try {
+ const data = await AsyncStorage.getItem(STORAGE_KEYS.PERMISSIONS_STATUS);
+ const permissions = data ? JSON.parse(data) : {};
+ permissions.throne = this.permissionStatus;
+ await AsyncStorage.setItem(STORAGE_KEYS.PERMISSIONS_STATUS, JSON.stringify(permissions));
+ } catch (error) {
+ console.error('Failed to persist throne status:', error);
+ }
+ }
+
+ /**
+ * Get current permission status
+ */
+ async getPermissionStatus(): Promise {
+ await this.initialize();
+ return this.permissionStatus;
+ }
+
+ /**
+ * Request permission to access Throne data
+ * STUB: Always returns 'granted' after a delay to simulate API call
+ */
+ async requestPermission(): Promise {
+ await this.initialize();
+
+ // Simulate API delay
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ // In stub mode, we'll simulate successful permission
+ // In production, this would open Throne's OAuth flow
+ this.permissionStatus = 'granted';
+ await this.persistStatus();
+
+ return this.permissionStatus;
+ }
+
+ /**
+ * Skip Throne setup (user can set up later)
+ */
+ async skipSetup(): Promise {
+ await this.initialize();
+ this.permissionStatus = 'skipped';
+ await this.persistStatus();
+ }
+
+ /**
+ * Get device connection status
+ * STUB: Returns 'not_setup' until permissions are granted
+ */
+ async getConnectionStatus(): Promise {
+ await this.initialize();
+
+ if (this.permissionStatus !== 'granted') {
+ return 'not_setup';
+ }
+
+ // In production, this would check actual device connection
+ return 'disconnected';
+ }
+
+ /**
+ * Get uroflow measurements
+ * STUB: Returns empty array (no mock data in stub mode)
+ */
+ async getMeasurements(_startDate?: Date, _endDate?: Date): Promise {
+ await this.initialize();
+
+ if (this.permissionStatus !== 'granted') {
+ return [];
+ }
+
+ // In production, this would fetch from Throne API
+ return [];
+ }
+
+ /**
+ * Get the latest uroflow measurement
+ * STUB: Returns null (no mock data in stub mode)
+ */
+ async getLatestMeasurement(): Promise {
+ await this.initialize();
+
+ if (this.permissionStatus !== 'granted') {
+ return null;
+ }
+
+ // In production, this would fetch from Throne API
+ return null;
+ }
+}
+
+/**
+ * Singleton instance of the Throne service
+ */
+export const ThroneService = new StubThroneService();
+
+/**
+ * Check if Throne integration is available
+ * In production, this would check for the Throne SDK
+ */
+export function isThroneAvailable(): boolean {
+ // Stub: Always return true (UI will show "Coming Soon")
+ return true;
+}