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

9.4 KiB
Raw Permalink Blame History

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/expoexpo-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

Storagelib/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:

  • OfficialDEFAULT_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:

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.

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/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.