# beenvoice-app architecture Dense reference for the Expo 56 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics. ## Stack | Layer | Technology | |-------|------------| | Framework | Expo 56, expo-router 56 (file-based routes) | | UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) | | API | tRPC 11 + TanStack Query, SuperJSON | | Auth | better-auth + `@better-auth/expo` → `expo-secure-store` | | Types | `AppRouter` imported from `../beenvoice/src/server/api/root` | ## Boot sequence ``` app/_layout.tsx SafeAreaProvider ThemeProvider ← AsyncStorage color mode BrandBackground + StatusBar AccountsProvider ← load accounts, active id, apiUrl AppServices ← key={activeAccountId:apiUrl} AuthProvider ← better-auth client (storagePrefix) TRPCProvider ← cookie header on /api/trpc RootNavigator session? → (app) | (auth) ``` `AccountsProvider` blocks on `LoadingScreen` until AsyncStorage hydrates. `AuthProvider` + `TRPCProvider` **remount** when `activeAccountId` or `apiUrl` changes so each account uses isolated SecureStore session keys. ## Routing ### Auth group `app/(auth)/` | Screen | File | Notes | |--------|------|-------| | redirect | `index.tsx` | → sign-in | | Sign in | `sign-in.tsx` | Server picker, deferred validation | | Register | `register.tsx` | REST register + sign-in + finalize account | | Forgot password | `forgot-password.tsx` | REST | | Reset password | `reset-password.tsx` | Deep link `beenvoice://reset-password?token=` | ### App group `app/(app)/` `NativeTabs` (5 tabs) in `_layout.tsx`. Wrapped in `AppLockProvider` + `AppLockOverlay`. | Tab | Screen | Features | |-----|--------|----------| | Dashboard | `index.tsx` | Stats, running timer chip, recent invoices | | Timer | `timer.tsx` | `TimeClockPanel` | | Entities | `entities/*` | Clients + businesses CRUD stacks | | Invoices | `invoices/*` | List, create, edit, status | | Settings | `settings.tsx` | Profile, accounts, theme, app lock, sign out | Nested stacks: `entities/_layout.tsx`, `invoices/_layout.tsx`. ## Multi-account model **Storage** — `lib/accounts.ts` (AsyncStorage): | Key | Content | |-----|---------| | `beenvoice:accounts` | `SavedAccount[]` | | `beenvoice:active-account-id` | current account id or absent | | `beenvoice:draft-instance-url` | server URL before first login | **Account id**: `{hostname}::{userId}` — host from instance URL without protocol/trailing slash. **Auth storage prefix** (SecureStore, better-auth): | Mode | Prefix | |------|--------| | Guest (signed out / adding account) | `beenvoice:guest` | | Per account | `beenvoice:auth:{accountId}` | Keys written by `@better-auth/expo`: `{prefix}_cookie`, `{prefix}_session_data`, `{prefix}_last_login_method` (colons normalized to `_` in SecureStore). Large cookies are chunked (`\x01ba-chunks:N` + `.{i}` keys). ### Sign-in flow (critical) 1. User signs in while `AuthProvider` uses **guest** (or current) prefix. 2. Session lands in that prefix's SecureStore. 3. `finalizeAuthenticatedAccount()` (`lib/auth-storage.ts`): - `migrateAuthStorage(sourcePrefix → targetPrefix)` — copy session keys - `registerAccount()` — set active account, persist metadata 4. `AuthProvider` remounts with account prefix; session already migrated → `RootNavigator` shows `(app)`. Without migration, remounting loses the session and forces a second login. ### Account switcher `components/AccountSwitcher.tsx` (header): - **Switch**: `switchAccount(id)` → remount auth/tRPC with that account's URL + prefix (session must already exist in target prefix). - **Add account**: `signOut()` + `clearActiveAccount()` → auth stack on guest prefix. ## Server picker `components/AuthServerPicker.tsx` + `lib/server-mode.ts`: - **Official** — `DEFAULT_API_URL` (`https://beenvoice.soconnor.dev` in `lib/config.ts`) - **Self-hosted** — user URL, normalized via `lib/instance-url.ts` (adds `http://` for localhost/LAN) `setInstanceUrl()` updates runtime API (`lib/config.ts` `setRuntimeApiUrl`) and draft or active account URL. ## API URL resolution `lib/config.ts` priority: 1. Runtime override (`setRuntimeApiUrl` from AccountsContext) 2. `EXPO_PUBLIC_API_URL` from `.env` 3. Dev: Metro host IP + `:3000` 4. `DEFAULT_API_URL` ## tRPC client `lib/trpc.tsx`: ```ts httpBatchLink({ url: `${apiUrl}/api/trpc`, transformer: SuperJSON, headers: () => ({ cookie: authClient.getCookie() }), }) ``` Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`. ## App lock (per account) `lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`: - `beenvoice:app-lock:{id}:enabled|pin|biometric` - One-time migration from legacy global keys `beenvoice_app_lock_*` `contexts/AppLockContext.tsx`: - Hydrates on account change - Locks when returning from background (if enabled) - PIN 4–6 digits; Face ID / Touch ID via `expo-local-authentication` - Only active inside `(app)/_layout.tsx` — auth screens never locked UI: `AppLockOverlay.tsx`, `PinPrompt.tsx`, settings toggles. ## Time clock `components/time-clock/TimeClockPanel.tsx`: - Client required; description optional (defaults to **"Clock In"** via `lib/time-clock.ts`) - Optional invoice, hourly rate, backdated start - `clockOut` sends optional description update - Syncs iOS Live Activity every 30s while running ### Live Activity | File | Role | |------|------| | `widgets/TimeClockActivity.tsx` | SwiftUI widget (`expo-widgets`); must keep all UI **inside** the `"widget"` function (babel preset serializes only that) | | `lib/time-clock-live-activity.ts` | `syncTimeClockLiveActivity`, `endTimeClockLiveActivity` | | `app.json` | Plugin `expo-widgets`, app group `group.com.beenvoice.app` | Does not work in Expo Go — use `bun run ios` dev build. ## Theming | File | Role | |------|------| | `lib/beenvoice-theme.ts` | Light tokens mirrored from web `globals.css` | | `lib/theme-palette.ts` | Light + dark `ThemeColors` | | `constants/theme.ts` | spacing, radii, fonts (Playfair / Inter / SpaceMono) | | `contexts/ThemeContext.tsx` | system / light / dark → AsyncStorage `beenvoice:color-mode` | | `components/BrandBackground.tsx` | Grid + animated blob | | `lib/use-themed-styles.ts` | Memoized StyleSheet factory | ## Contexts summary | Context | File | |---------|------| | Auth | `contexts/AuthContext.tsx` | | Accounts | `contexts/AccountsContext.tsx` | | App lock | `contexts/AppLockContext.tsx` | | Theme | `contexts/ThemeContext.tsx` | ## `lib/` module index | Module | Purpose | |--------|---------| | `accounts.ts` | Account registry types + AsyncStorage | | `auth-storage.ts` | Session migration, `finalizeAuthenticatedAccount` | | `auth-api.ts` | REST register / forgot / reset | | `config.ts` | API URL | | `instance-url.ts` | URL normalization + persistence | | `server-mode.ts` | Official vs self-hosted | | `trpc.tsx` | tRPC provider | | `app-lock.ts` | Per-account PIN storage | | `form-validation.ts` | Validators + `useFieldVisibility` | | `format.ts` | Currency, dates | | `invoice-status.ts` | Status colors | | `invoice-number.ts` | Number generation | | `time-clock.ts` | Timer formatting, clock-out copy | | `time-clock-live-activity*.ts` | Live Activity bridge | | `tab-layout.ts`, `tab-bar-insets.ts`, `top-chrome-insets.ts` | Layout metrics | ## Components layout ``` components/ ├── ui/ # Button, Card, Input, SelectField, DateTimeField ├── time-clock/ # TimeClockPanel ├── clients/ # ClientForm ├── businesses/ # BusinessForm ├── invoices/ # LineItemEditor ├── AuthServerPicker, AccountSwitcher, TopChrome, AppLockOverlay, Logo, … ``` ## Native config **`app.json`** - `scheme`: `beenvoice` - `bundleIdentifier`: `com.beenvoice.app` - Plugins: dev-client, router, secure-store, widgets, local-authentication - iOS Face ID usage strings; custom `beenvoice.icon` **`eas.json`** - Profiles: development, preview, production - `cli.appVersionSource`: remote - User does not require EAS for local `expo run:ios` **Metro**: port **8082** (`package.json` scripts) to avoid collisions. ## Deep links | URL | Handler | |-----|---------| | `beenvoice://reset-password?token=…` | `reset-password.tsx` | | `beenvoice://timer` | Live Activity tap → timer tab | ## Development commands ```bash bun run ios # build + run simulator, Metro :8082 bun run start # Metro only (--dev-client --port 8082) bunx expo prebuild --platform ios --clean # after native dep / icon changes ``` ## Troubleshooting | Symptom | Likely cause | |---------|----------------| | `PlatformConstants` / runtime not ready | Wrong Metro port, stale native build, or Expo Go instead of dev client | | Login twice | Session not migrated — see `finalizeAuthenticatedAccount` | | Live Activity blank | Widget UI outside `"widget"` function; rebuild native | | API unreachable on device | Use LAN IP in `EXPO_PUBLIC_API_URL`, not `localhost` | | Auth fails on device | `BETTER_AUTH_URL` on server must match reachable host | ## Server dependency Requires beenvoice with: - `@better-auth/expo` in `src/lib/auth.ts` - `trustedOrigins` including `beenvoice://` and `exp://` - Postgres running (`docker compose -f docker-compose.dev.yml up -d db`) See [beenvoice/docs/ARCHITECTURE.md](../../beenvoice/docs/ARCHITECTURE.md).