Mobile

Mobile — ui/ios (Expo)

The mobile application uses the Expo managed workflow with React Native and TypeScript.


Core Stack

AspectChoice
FrameworkExpo SDK (latest stable)
Navigationexpo-router
LanguageTypeScript (strict mode)
AuthBetter Auth (Expo client → the applications-UI server)
TestingVitest (React Native environment) + @testing-library/react-native
Coverage≥ 80%

Auth — Better Auth

The mobile app authenticates against the same Better Auth instance the web applications use — hosted by the applications-UI server (see Frontend), backed by Neon. Use the Better Auth Expo client plugin; a login screen is scaffolded at app/(auth)/login.tsx via expo-router. The app never talks to Neon directly — it calls the FastAPI API with the Better Auth session token.

No PHI is cached on-device without review. Tokens live in secure storage (expo-secure-store).


Environment Configuration

Environment variables are provided via app.config.ts reading EXPO_PUBLIC_* vars:

export default {
  expo: {
    extra: {
      apiUrl: process.env.EXPO_PUBLIC_API_URL,
      betterAuthUrl: process.env.EXPO_PUBLIC_BETTER_AUTH_URL,
    },
  },
};

Dependencies

  • expo — Expo SDK
  • expo-router — File-based routing
  • better-auth + @better-auth/expo — auth client
  • expo-secure-store — secure token storage
  • posthog-react-native — product analytics + session replay (masking on — see below)

Dev

  • vitest + @testing-library/react-native — component testing (React Native environment)

Analytics — PostHog (posthog-react-native)

The mobile app uses posthog-react-native for product analytics, feature flags, experiments, and session replay, feeding the same PostHog project as the web app. PostHog's full role across the stack is described in Observability.

The same masking discipline applies — replay can capture clinical screens, so:

  • Enable session-replay masking: maskAllTextInputs: true and maskAllImages: true, plus wrap PHI-bearing views in <PostHogMaskView> (or the ph-no-capture marker).
  • Identify by opaque user IDs only — never a name, email, or MRN. Keep no PHI in event names, property keys, or flag keys. See HIPAA Compliance.
// app/_layout.tsx — wrap the router in the PostHog provider
import { PostHogProvider } from 'posthog-react-native'
 
export default function RootLayout() {
  return (
    <PostHogProvider
      apiKey={process.env.EXPO_PUBLIC_POSTHOG_KEY!}
      options={{ host: process.env.EXPO_PUBLIC_POSTHOG_HOST }}
      autocapture={{
        captureScreens: true,
        // Session replay masks PHI by default on-device.
        sessionReplay: { maskAllTextInputs: true, maskAllImages: true },
      }}
    >
      {/* expo-router <Slot /> / <Stack /> */}
    </PostHogProvider>
  )
}

Testing

Tests run on Vitest with @testing-library/react-native, enforcing ≥ 80% coverage via Vitest's v8 provider.

Because React Native ships no DOM, Vitest needs an RN-aware setup rather than the old jest-expo preset: alias react-native to react-native-web (or an RN-compatible resolver) so components resolve under Node, register @testing-library/react-native in a setup file, and run in a jsdom environment. Example vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config'
 
export default defineConfig({
  // Resolve the RN entrypoint under Node — RN's native modules can't load in Vitest.
  resolve: {
    alias: {
      'react-native': 'react-native-web',
    },
  },
  test: {
    // jsdom gives react-native-web a DOM to render into.
    environment: 'jsdom',
    // Registers @testing-library/react-native matchers + cleanup and RN globals.
    setupFiles: ['./vitest.setup.ts'],
    globals: true,
    coverage: {
      provider: 'v8',
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
})

The setup file registers the RN matchers and auto-cleanup so every test file gets them without repeating boilerplate:

// vitest.setup.ts
import '@testing-library/react-native/extend-expect' // toBeVisible(), toHaveTextContent(), …
import { cleanup } from '@testing-library/react-native'
import { afterEach, vi } from 'vitest'
 
// Unmount the rendered tree after each test to avoid cross-test state leaks.
afterEach(() => cleanup())
 
// Native-only modules aren't available under Node — stub the ones tests touch.
vi.mock('expo-secure-store', () => ({
  getItemAsync: vi.fn(),
  setItemAsync: vi.fn(),
  deleteItemAsync: vi.fn(),
}))