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>
9.4 KiB
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)
- User signs in while
AuthProvideruses guest (or current) prefix. - Session lands in that prefix's SecureStore.
finalizeAuthenticatedAccount()(lib/auth-storage.ts):migrateAuthStorage(sourcePrefix → targetPrefix)— copy session keysregisterAccount()— set active account, persist metadata
AuthProviderremounts with account prefix; session already migrated →RootNavigatorshows(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.devinlib/config.ts) - Self-hosted — user URL, normalized via
lib/instance-url.ts(addshttp://for localhost/LAN)
setInstanceUrl() updates runtime API (lib/config.ts setRuntimeApiUrl) and draft or active account URL.
API URL resolution
lib/config.ts priority:
- Runtime override (
setRuntimeApiUrlfrom AccountsContext) EXPO_PUBLIC_API_URLfrom.env- Dev: Metro host IP +
:3000 DEFAULT_API_URL
tRPC client
lib/trpc.tsx:
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
clockOutsends 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:beenvoicebundleIdentifier: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
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/expoinsrc/lib/auth.tstrustedOriginsincludingbeenvoice://andexp://- Postgres running (
docker compose -f docker-compose.dev.yml up -d db)