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>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user