Files
soconnor 32ffe782ea Fix Live Activity lock screen rendering and polish multi-account auth.
Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:23:36 -04:00

267 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 46 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).