Files
beenvoice/docs/ARCHITECTURE.md
T

277 lines
11 KiB
Markdown
Raw 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-web/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.app` 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"`.
## Offline mode
Read queries are cached per account + API URL in AsyncStorage (`lib/offline-cache.ts`). `TRPCProvider` restores that cache before mounting app screens, then persists successful query data for up to 7 days. SuperJSON is used for persistence so API payloads keep Date values and other transformed types.
`lib/network-status.ts` connects `expo-network` to TanStack Query's `onlineManager`. When the device is offline, queries pause instead of repeatedly failing; when connectivity returns, React Query reconnect behavior refreshes stale data. The better-auth Expo client also keeps its SecureStore session cache enabled so a previously signed-in account can get through app boot while offline, then reconcile with the server when connectivity returns.
Account removal clears that account's persisted query cache alongside SecureStore auth and time-clock preferences.
Current offline scope: previously loaded dashboard, invoices, clients, businesses, expenses, reports, recurring invoices, and time entries can be viewed offline from cache. Mutations still require the API; timer actions, invoice edits/status changes, receipt uploads, reminders, emails, and onboarding writes are not persisted as an offline queue yet because they need conflict and side-effect rules.
## 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 when timer metadata changes (client, invoice, description); timer uses native `timerInterval` on the lock screen
### 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-web/docs/ARCHITECTURE.md](../../beenvoice-web/docs/ARCHITECTURE.md).