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:
2026-06-18 01:23:36 -04:00
parent e6ea3d7c5d
commit 32ffe782ea
35 changed files with 1659 additions and 442 deletions
+32 -2
View File
@@ -1,3 +1,33 @@
# Expo HAS CHANGED
# beenvoice-app — agent notes
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
Expo SDK **56**. Read [Expo v56 docs](https://docs.expo.dev/versions/v56.0.0/) before changing native config.
## Read first
- [README.md](./README.md) — setup and run
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — routing, auth, accounts, tRPC, widgets
## Conventions
- **Package manager**: Bun only
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice/src/*`)
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
- **Widgets**: all Live Activity UI must be inside the `"widget"` function in `widgets/TimeClockActivity.tsx`
- **Metro**: port 8082; dev client required (not Expo Go)
## Key files
| Concern | Path |
|---------|------|
| Root providers | `app/_layout.tsx` |
| Multi-account | `contexts/AccountsContext.tsx`, `lib/accounts.ts` |
| Session migration | `lib/auth-storage.ts` |
| tRPC | `lib/trpc.tsx` |
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
| Time clock | `components/time-clock/TimeClockPanel.tsx` |
## Server repo
Sibling `../beenvoice` — run `bun run dev` on :3000 before mobile dev.
+71 -33
View File
@@ -1,12 +1,15 @@
# beenvoice Mobile
Expo companion app for [beenvoice](../beenvoice) — dashboard, time clock, invoices, and account settings.
Expo companion for [beenvoice](../beenvoice) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API** and **better-auth** sessions as the web app.
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
## Prerequisites
- [Bun](https://bun.sh) 1.3+
- beenvoice server running (see `../beenvoice/README.md`)
- iOS development build for Live Activities (`expo-widgets`)
- beenvoice API running ([setup](../beenvoice/README.md))
- Xcode + iOS Simulator (or device) for native dev build
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
## Setup
@@ -16,68 +19,103 @@ bun install
cp .env.example .env
```
Edit `.env` and set your API URL:
`.env`:
```env
# Simulator
EXPO_PUBLIC_API_URL=http://localhost:3000
# Physical iPhone (use your Mac's LAN IP)
# Physical iPhone Mac LAN IP
EXPO_PUBLIC_API_URL=http://192.168.1.42:3000
```
The beenvoice server must have the Expo auth plugin enabled (`@better-auth/expo` in `beenvoice/src/lib/auth.ts`).
Omit `EXPO_PUBLIC_API_URL` in production builds to default to `https://beenvoice.soconnor.dev`.
Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `beenvoice://` in `trustedOrigins`.
## Run
```bash
# Terminal 1 — API server
# Terminal 1 — API
cd ../beenvoice && bun run dev
# Terminal 2 — mobile app (development build)
cd beenvoice-app
bun run ios
# Terminal 2 — mobile (builds native app if needed)
cd beenvoice-app && bun run ios
```
This uses port **8082** for Metro so it does not collide with other Expo projects on 8081.
Metro uses port **8082** (avoids other Expo projects on 8081).
If you already built the app and only need Metro:
Metro only (app already installed):
```bash
bun run start -- --clear
```
Then open the **beenvoice** app on the simulator (not Expo Go).
Open the **beenvoice** dev build on the simulator not Expo Go.
Live Activities require a native build (`bun run ios`). They do not work in Expo Go.
### After native changes
After changing `assets/beenvoice.icon`, rebuild iOS:
Icon (`assets/beenvoice.icon`), widgets, or new native modules:
```bash
bunx expo prebuild --platform ios --clean
bun run ios
```
### Troubleshooting `PlatformConstants` / `[runtime not ready]`
Usually one of:
1. **Wrong Metro bundler** — another project's dev server is on the same port. Stop it or use `--port 8082`.
2. **Stale native build** — after adding native modules, rebuild:
```bash
bunx expo prebuild --platform ios --clean
bun run ios
```
3. **Expo Go** — native modules like widgets need the custom dev build from `bun run ios`, not Expo Go.
## Features
- **Auth** — sign in, register, forgot password, reset password; multiple saved accounts
- **Dashboard** — revenue, pending, overdue, recent invoices
- **Timer** — clock in/out with client, invoice, and hourly rate; iOS Live Activity (dev build)
- **Invoices** — list, filter by status, tap to update status
- **Settings** — profile, accounts, theme (system/light/dark), server URL, sign out
| Area | Details |
|------|---------|
| **Auth** | Sign in, register, forgot/reset password; official or self-hosted server |
| **Multi-account** | Bitwarden-style switcher; per-account session in SecureStore |
| **Dashboard** | Revenue, pending, overdue, running timer, recent invoices |
| **Timer** | Clock in/out, client + invoice + rate; optional description (default "Clock In"); iOS Live Activity |
| **Entities** | Clients and businesses — list, create, edit |
| **Invoices** | List, filter, create, edit, status updates |
| **Settings** | Profile, accounts, theme, per-account app lock (PIN + Face ID), sign out |
| **App lock** | Per-account; locks on background return |
## Auth & accounts (summary)
- **Guest** auth storage: `beenvoice:guest` until first successful login
- **Per account**: `beenvoice:auth:{host::userId}` in SecureStore
- After login, `finalizeAuthenticatedAccount()` migrates session keys before activating the account (avoids double login)
- **Server picker**: Official (`beenvoice.soconnor.dev`) or custom URL on auth screens
Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model)
## Deep links
- `beenvoice://reset-password?token=...` — open reset password screen with token prefilled
| Scheme | Screen |
|--------|--------|
| `beenvoice://reset-password?token=…` | Reset password |
| `beenvoice://timer` | Timer tab (from Live Activity) |
## Project layout
```
app/
_layout.tsx # Providers, auth guard
(auth)/ # sign-in, register, password flows
(app)/ # tab shell + nested stacks
components/ # UI, forms, chrome, time clock
contexts/ # Auth, Accounts, AppLock, Theme
lib/ # tRPC, auth storage, config, theming
widgets/ # iOS Live Activity (TimeClockActivity)
```
## Troubleshooting
| Issue | Fix |
|-------|-----|
| `PlatformConstants` / runtime not ready | Stop other Metro on 8081/8082; rebuild with `prebuild --clean` |
| Expo Go | Use `bun run ios` dev build |
| API errors on device | `EXPO_PUBLIC_API_URL` = Mac LAN IP; server `BETTER_AUTH_URL` must match |
| Live Activity empty | Rebuild iOS; widget UI must live inside `"widget"` function |
| Login twice | Server + app versions with session migration (`lib/auth-storage.ts`) |
## Related
- [beenvoice README](../beenvoice/README.md)
- [beenvoice ARCHITECTURE](../beenvoice/docs/ARCHITECTURE.md)
- [Workspace root README](../README.md)
+2 -1
View File
@@ -48,7 +48,8 @@
[
"expo-widgets",
{
"groupIdentifier": "group.com.beenvoice.app"
"groupIdentifier": "group.com.beenvoice.app",
"bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget"
}
],
[
+2 -2
View File
@@ -18,7 +18,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
@@ -88,7 +88,7 @@ export default function DashboardScreen() {
<View style={styles.runningDot} />
<View style={styles.runningMeta}>
<Text style={styles.runningTitle}>
{running.description || "Timer running"}
{resolveClockDescription(running.description)}
</Text>
<Text style={styles.runningSub}>
{running.client?.name ?? "No client"}
+13 -17
View File
@@ -22,6 +22,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { validateLineItems } from "@/lib/form-validation";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
@@ -100,6 +101,8 @@ export default function InvoiceEditScreen() {
const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
const lineItemsError = validateLineItems(items);
const canSave = !lineItemsError;
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
@@ -170,6 +173,7 @@ export default function InvoiceEditScreen() {
}
function handleSave() {
if (!canSave) return;
setError(null);
const parsedItems: Array<{
@@ -180,25 +184,11 @@ export default function InvoiceEditScreen() {
}> = [];
for (const item of items) {
const hours = Number(item.hours);
const rate = Number(item.rate);
if (!item.description.trim()) {
setError("Each line needs a description");
return;
}
if (Number.isNaN(hours) || hours < 0) {
setError("Hours must be a valid number");
return;
}
if (Number.isNaN(rate) || rate < 0) {
setError("Rate must be a valid number");
return;
}
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours,
rate,
hours: Number(item.hours),
rate: Number(item.rate),
});
}
@@ -274,10 +264,16 @@ export default function InvoiceEditScreen() {
</View>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
<Button
title="Save changes"
loading={updateInvoice.isPending}
disabled={!canSave}
onPress={handleSave}
/>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
+29 -34
View File
@@ -23,6 +23,11 @@ import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import {
isRequiredString,
isValidTaxRate,
validateLineItems,
} from "@/lib/form-validation";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
@@ -104,6 +109,19 @@ export default function NewInvoiceScreen() {
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
: "Invoice number is required";
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items);
const canCreate =
clientOptions.length > 0 &&
!clientError &&
!invoiceNumberError &&
!taxError &&
!lineItemsError;
if (clientsQuery.isLoading) {
return <LoadingScreen message="Loading…" />;
}
@@ -140,18 +158,9 @@ export default function NewInvoiceScreen() {
}
function handleCreate() {
if (!canCreate) return;
setError(null);
if (!clientId) {
setError("Select a client");
return;
}
if (!invoiceNumber.trim()) {
setError("Invoice number is required");
return;
}
const parsedItems: Array<{
date: Date;
description: string;
@@ -160,41 +169,21 @@ export default function NewInvoiceScreen() {
}> = [];
for (const item of items) {
const hours = Number(item.hours);
const rate = Number(item.rate);
if (!item.description.trim()) {
setError("Each line needs a description");
return;
}
if (Number.isNaN(hours) || hours < 0) {
setError("Hours must be a valid number");
return;
}
if (Number.isNaN(rate) || rate < 0) {
setError("Rate must be a valid number");
return;
}
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours,
rate,
hours: Number(item.hours),
rate: Number(item.rate),
});
}
const tax = Number(taxRate);
if (Number.isNaN(tax) || tax < 0 || tax > 100) {
setError("Tax rate must be between 0 and 100");
return;
}
createInvoice.mutate({
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
dueDate,
notes,
taxRate: tax,
taxRate: Number(taxRate),
currency,
items: parsedItems,
status: "draft",
@@ -232,6 +221,8 @@ export default function NewInvoiceScreen() {
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
onValueChange={setClientId}
/>
)}
@@ -240,6 +231,8 @@ export default function NewInvoiceScreen() {
value={invoiceNumber}
onChangeText={setInvoiceNumber}
autoCapitalize="characters"
required
error={invoiceNumberError}
/>
<DateTimeField
label="Issue date"
@@ -256,6 +249,7 @@ export default function NewInvoiceScreen() {
value={taxRate}
onChangeText={setTaxRate}
keyboardType="decimal-pad"
error={taxError}
/>
<Input
label="Notes"
@@ -298,12 +292,13 @@ export default function NewInvoiceScreen() {
</View>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Create invoice"
loading={createInvoice.isPending}
disabled={clientOptions.length === 0}
disabled={!canCreate}
onPress={handleCreate}
/>
</ScrollView>
+24 -1
View File
@@ -11,6 +11,7 @@ import {
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -18,6 +19,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { requestPasswordReset } from "@/lib/auth-api";
import { isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function ForgotPasswordScreen() {
const { colors } = useAppTheme();
@@ -25,8 +27,19 @@ export default function ForgotPasswordScreen() {
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const emailValidationError = !email.trim()
? "Email is required"
: isValidEmail(email)
? undefined
: "Enter a valid email";
const canSubmit = isValidEmail(email) && serverReady;
async function handleSubmit() {
markSubmitted();
if (!canSubmit) return;
setError(null);
setMessage(null);
setLoading(true);
@@ -53,6 +66,8 @@ export default function ForgotPasswordScreen() {
<Text style={[styles.back, { color: colors.mutedForeground }]}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="md" />
@@ -70,7 +85,10 @@ export default function ForgotPasswordScreen() {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
{error ? (
@@ -80,7 +98,12 @@ export default function ForgotPasswordScreen() {
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
) : null}
<Button title="Send reset link" loading={loading} onPress={handleSubmit} />
<Button
title="Send reset link"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
<Button
title="Have a reset token?"
variant="ghost"
+52 -8
View File
@@ -1,4 +1,4 @@
import { Link, router } from "expo-router";
import { Link } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
@@ -10,6 +10,7 @@ import {
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -19,10 +20,12 @@ import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
export default function RegisterScreen() {
const authClient = useAuthClient();
const { apiUrl, registerAccount: saveAccount } = useAccounts();
const { apiUrl, activeAccountId, registerAccount: saveAccount } = useAccounts();
const { colors } = useAppTheme();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
@@ -30,8 +33,31 @@ export default function RegisterScreen() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const firstNameError = isRequiredString(firstName) ? undefined : "First name is required";
const lastNameError = isRequiredString(lastName) ? undefined : "Last name is required";
const emailValidationError = isValidEmail(email)
? undefined
: email.trim()
? "Enter a valid email"
: "Email is required";
const passwordValidationError = isValidPassword(password)
? undefined
: password
? "Password must be at least 8 characters"
: "Password is required";
const canRegister =
isRequiredString(firstName) &&
isRequiredString(lastName) &&
isValidEmail(email) &&
isValidPassword(password) &&
serverReady;
async function handleRegister() {
markSubmitted();
if (!canRegister) return;
setError(null);
setLoading(true);
@@ -49,22 +75,22 @@ export default function RegisterScreen() {
});
if (signInError) {
router.replace("/(auth)/sign-in");
setError(signInError.message || "Account created but sign-in failed. Try signing in.");
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await saveAccount({
instanceUrl: apiUrl,
await finalizeAuthenticatedAccount({
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount: saveAccount,
});
}
router.replace("/(app)");
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
@@ -83,6 +109,7 @@ export default function RegisterScreen() {
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
@@ -99,8 +126,11 @@ export default function RegisterScreen() {
label="First name"
value={firstName}
onChangeText={setFirstName}
onBlur={() => touch("firstName")}
autoComplete="given-name"
placeholder="Jane"
required
error={visible("firstName") ? firstNameError : undefined}
/>
</View>
<View style={styles.half}>
@@ -108,8 +138,11 @@ export default function RegisterScreen() {
label="Last name"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
@@ -121,7 +154,10 @@ export default function RegisterScreen() {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
@@ -129,14 +165,22 @@ export default function RegisterScreen() {
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button title="Create Account" loading={loading} onPress={handleRegister} />
<Button
title="Create Account"
loading={loading}
disabled={!canRegister}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
+33 -11
View File
@@ -11,6 +11,7 @@ import {
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -20,6 +21,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { resetPassword } from "@/lib/auth-api";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, isValidPassword } from "@/lib/form-validation";
export default function ResetPasswordScreen() {
const styles = useThemedStyles(createResetPasswordStyles);
@@ -30,6 +32,7 @@ export default function ResetPasswordScreen() {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
useEffect(() => {
if (typeof tokenParam === "string" && tokenParam.length > 0) {
@@ -37,19 +40,25 @@ export default function ResetPasswordScreen() {
}
}, [tokenParam]);
const tokenError = isRequiredString(token) ? undefined : "Reset token is required";
const passwordError = isValidPassword(password)
? undefined
: password
? "Password must be at least 8 characters"
: "Password is required";
const confirmError =
confirmPassword && password !== confirmPassword ? "Passwords do not match" : undefined;
const canSubmit =
serverReady &&
isRequiredString(token) &&
isValidPassword(password) &&
password === confirmPassword &&
confirmPassword.length > 0;
async function handleSubmit() {
if (!canSubmit) return;
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setLoading(true);
try {
@@ -74,6 +83,8 @@ export default function ResetPasswordScreen() {
<Text style={styles.back}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<HeadingText style={styles.title}>Set new password</HeadingText>
@@ -101,6 +112,8 @@ export default function ResetPasswordScreen() {
value={token}
onChangeText={setToken}
placeholder="Paste token from email"
required
error={tokenError}
/>
<Input
label="New password"
@@ -108,6 +121,8 @@ export default function ResetPasswordScreen() {
value={password}
onChangeText={setPassword}
placeholder="At least 8 characters"
required
error={passwordError}
/>
<Input
label="Confirm password"
@@ -115,11 +130,18 @@ export default function ResetPasswordScreen() {
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repeat password"
required
error={confirmError}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title="Update password" loading={loading} onPress={handleSubmit} />
<Button
title="Update password"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
</View>
)}
</Card>
+58 -26
View File
@@ -10,6 +10,7 @@ import {
View,
} from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { FullScreen } from "@/components/Screen";
import { Button } from "@/components/ui/Button";
@@ -19,46 +20,65 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
const authClient = useAuthClient();
const { apiUrl, registerAccount } = useAccounts();
const { apiUrl, activeAccountId, registerAccount } = useAccounts();
const { colors } = useAppTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const emailValidationError = !email.trim()
? "Email is required"
: isValidEmail(email)
? undefined
: "Enter a valid email";
const passwordValidationError = password.trim() ? undefined : "Password is required";
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
async function handleSignIn() {
markSubmitted();
if (!canSignIn) return;
setError(null);
setLoading(true);
const { error: signInError } = await authClient.signIn.email({ email: email.trim(), password });
if (signInError) {
setLoading(false);
const message = signInError.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
} else {
setError(message || "Invalid email or password");
}
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await registerAccount({
instanceUrl: apiUrl,
userId: user.id,
email: user.email,
name: user.name,
try {
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
}
setLoading(false);
router.replace("/(app)");
if (signInError) {
const message = signInError.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
} else {
setError(message || "Invalid email or password");
}
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await finalizeAuthenticatedAccount({
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount,
});
}
} finally {
setLoading(false);
}
}
return (
@@ -72,6 +92,7 @@ export default function SignInScreen() {
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
@@ -89,7 +110,10 @@ export default function SignInScreen() {
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
@@ -97,7 +121,10 @@ export default function SignInScreen() {
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
@@ -110,7 +137,12 @@ export default function SignInScreen() {
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button title="Sign In" loading={loading} onPress={handleSignIn} />
<Button
title="Sign In"
loading={loading}
disabled={!canSignIn}
onPress={handleSignIn}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
+1 -9
View File
@@ -12,7 +12,7 @@ import {
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, type ReactNode } from "react";
import { Platform, View } from "react-native";
import { View } from "react-native";
import { StatusBar } from "expo-status-bar";
import "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -23,8 +23,6 @@ import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
import { TRPCProvider } from "@/lib/trpc";
import { ensureWidgetBrandAssets } from "@/lib/widget-brand-assets";
export { ErrorBoundary } from "expo-router";
SplashScreen.preventAutoHideAsync();
@@ -77,12 +75,6 @@ export default function RootLayout() {
}
}, [loaded]);
useEffect(() => {
if (Platform.OS === "ios") {
void ensureWidgetBrandAssets();
}
}, []);
if (!loaded) {
return null;
}
+264
View File
@@ -0,0 +1,264 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode";
function initials(name: string, email: string) {
const source = name.trim() || email.trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
}
return (source[0] ?? "?").toUpperCase();
}
function displayName(name: string, email: string) {
const trimmed = name.trim();
if (trimmed) return trimmed.split(/\s+/)[0] ?? trimmed;
return email.split("@")[0] ?? email;
}
/** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() {
const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
switchAccount,
clearActiveAccount,
} = useAccounts();
const [open, setOpen] = useState(false);
const label = displayName(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
const avatar = initials(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
async function handleAddAccount() {
setOpen(false);
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}
async function handleSwitch(accountId: string) {
if (accountId === activeAccountId) {
setOpen(false);
return;
}
setOpen(false);
await switchAccount(accountId);
}
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Switch account"
hitSlop={8}
onPress={() => setOpen(true)}
style={styles.hit}
>
<View style={[styles.row, { backgroundColor: colors.muted }]}>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{avatar}
</Text>
</View>
<Text
style={[styles.name, { color: colors.foreground }]}
numberOfLines={1}
>
{label}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.mutedForeground} />
</View>
</Pressable>
<Modal animationType="fade" onRequestClose={() => setOpen(false)} transparent visible={open}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.background }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<Pressable
key={account.id}
accessibilityRole="button"
onPress={() => void handleSwitch(account.id)}
style={({ pressed }) => [
styles.accountRow,
{
borderBottomColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{initials(account.name, account.email)}
</Text>
</View>
<View style={styles.accountMeta}>
<Text style={[styles.accountName, { color: colors.foreground }]}>
{account.name || account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
{account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
{formatServerHost(account.instanceUrl)}
</Text>
</View>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
<Pressable
accessibilityRole="button"
onPress={() => void handleAddAccount()}
style={({ pressed }) => [
styles.addRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
hit: {
flexShrink: 1,
maxWidth: "58%",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingLeft: 4,
paddingRight: 8,
minHeight: 32,
borderRadius: radii.pill,
},
avatar: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
avatarText: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
},
name: {
flexShrink: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
lineHeight: 18,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
maxHeight: "70%",
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
},
sheetTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
done: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
accountRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountName: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
accountSub: {
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
},
addRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
paddingVertical: spacing.lg,
borderTopWidth: StyleSheet.hairlineWidth,
},
addLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
pressed: {
opacity: 0.75,
},
});
+39 -35
View File
@@ -1,15 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { LogoMark } from "@/components/Logo";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppLock } from "@/contexts/AppLockContext";
@@ -28,11 +26,13 @@ export function AppLockOverlay() {
} = useAppLock();
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const promptedRef = useRef(false);
useEffect(() => {
if (!isLocked) {
setPin("");
setError("");
promptedRef.current = false;
}
}, [isLocked]);
@@ -40,12 +40,18 @@ export function AppLockOverlay() {
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
return;
}
if (promptedRef.current) return;
void unlockWithBiometric().then((success) => {
if (!success) return;
setPin("");
setError("");
});
const timer = setTimeout(() => {
promptedRef.current = true;
void unlockWithBiometric().then((success) => {
if (!success) return;
setPin("");
setError("");
});
}, 400);
return () => clearTimeout(timer);
}, [enabled, isLocked, biometricEnabled, biometricAvailable, unlockWithBiometric]);
if (!enabled || !isLocked) {
@@ -64,6 +70,7 @@ export function AppLockOverlay() {
}
async function tryBiometric() {
promptedRef.current = true;
const success = await unlockWithBiometric();
if (!success) {
setError(`Could not unlock with ${biometricLabel}`);
@@ -74,8 +81,8 @@ export function AppLockOverlay() {
<Modal visible animationType="fade" transparent={false}>
<View style={[styles.screen, { backgroundColor: colors.background }]}>
<View style={styles.content}>
<LogoMark size={56} />
<Text style={[styles.title, { color: colors.foreground }]}>beenvoice is locked</Text>
<Logo size="md" />
<Text style={[styles.title, { color: colors.foreground }]}>Locked</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Enter your PIN to continue
</Text>
@@ -104,20 +111,18 @@ export function AppLockOverlay() {
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
<View style={styles.actions}>
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
{biometricEnabled && biometricAvailable ? (
<Pressable
accessibilityRole="button"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
>
<Ionicons name="finger-print-outline" size={20} color={colors.primary} />
<Text style={[styles.biometricLabel, { color: colors.primary }]}>
Unlock with {biometricLabel}
</Text>
</Pressable>
) : null}
{biometricAvailable ? (
<Button
title={`Unlock with ${biometricLabel}`}
variant="secondary"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
/>
) : null}
</View>
</View>
</View>
</Modal>
@@ -133,6 +138,9 @@ const styles = StyleSheet.create({
content: {
alignItems: "center",
gap: spacing.md,
width: "100%",
maxWidth: 320,
alignSelf: "center",
},
title: {
fontSize: 22,
@@ -147,28 +155,24 @@ const styles = StyleSheet.create({
},
pinInput: {
width: "100%",
maxWidth: 280,
borderWidth: 1,
borderRadius: 12,
minHeight: 52,
paddingHorizontal: spacing.md,
fontSize: 24,
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
letterSpacing: 8,
},
error: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
textAlign: "center",
},
actions: {
width: "100%",
gap: spacing.sm,
},
biometricButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
},
biometricLabel: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
width: "100%",
},
});
+227
View File
@@ -0,0 +1,227 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { DEFAULT_API_URL } from "@/lib/config";
import {
formatServerHost,
isServerConfigValid,
resolveServerMode,
resolveServerUrl,
SERVER_MODE_OPTIONS,
type ServerMode,
} from "@/lib/server-mode";
type AuthServerPickerProps = {
onReadyChange?: (ready: boolean) => void;
};
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
if (mode === "official") return "Official";
const host = formatServerHost(selfHostedUrl);
return host || "Self-hosted";
}
export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(false);
const [mode, setMode] = useState<ServerMode>(() => resolveServerMode(apiUrl));
const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
);
const [urlError, setUrlError] = useState<string | null>(null);
const ready = isServerConfigValid(mode, selfHostedUrl);
useEffect(() => {
onReadyChange?.(ready);
}, [ready, onReadyChange]);
useEffect(() => {
const nextMode = resolveServerMode(apiUrl);
setMode(nextMode);
if (nextMode === "self-hosted") {
setSelfHostedUrl(apiUrl);
}
}, [apiUrl]);
async function applyMode(nextMode: ServerMode) {
setMode(nextMode);
setUrlError(null);
if (nextMode === "official") {
try {
await setInstanceUrl(DEFAULT_API_URL);
setExpanded(false);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not set server");
}
return;
}
setExpanded(true);
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) return;
try {
await setInstanceUrl(resolved);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not set server");
}
}
async function commitSelfHostedUrl() {
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) {
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
return;
}
try {
const saved = await setInstanceUrl(resolved);
setSelfHostedUrl(saved);
setUrlError(null);
setExpanded(false);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View style={styles.wrapper}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={() => setExpanded((open) => !open)}
hitSlop={8}
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
>
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
Server ·{" "}
<Text style={[styles.summary, { color: colors.foreground }]}>
{modeSummary(mode, selfHostedUrl)}
</Text>
</Text>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{expanded ? (
<View
style={[
styles.panel,
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
]}
>
{SERVER_MODE_OPTIONS.map((option) => {
const selected = option.value === mode;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={() => void applyMode(option.value)}
style={({ pressed }) => [
styles.option,
{
borderColor: colors.border,
backgroundColor: selected ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
{option.label}
</Text>
{selected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
{mode === "self-hosted" ? (
<>
<Input
label="Server URL"
value={selfHostedUrl}
onChangeText={(value) => {
setSelfHostedUrl(value);
setUrlError(null);
}}
onBlur={() => void commitSelfHostedUrl()}
onSubmitEditing={() => void commitSelfHostedUrl()}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
required
error={urlError ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;s LAN IP on a physical device.
</Text>
</>
) : null}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
marginBottom: spacing.md,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
pressed: {
opacity: 0.7,
},
triggerText: {
fontSize: 13,
fontFamily: fonts.body,
},
summary: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
panel: {
borderWidth: 1,
borderRadius: 14,
padding: spacing.md,
gap: spacing.sm,
},
option: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
paddingHorizontal: spacing.md,
borderRadius: 10,
borderWidth: 1,
},
optionLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
-1
View File
@@ -145,7 +145,6 @@ const styles = StyleSheet.create({
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
letterSpacing: 6,
},
error: {
fontSize: 13,
+3 -3
View File
@@ -1,19 +1,19 @@
import { StyleSheet, View } from "react-native";
import { ClockedInIndicator } from "@/components/ClockedInIndicator";
import { AccountSwitcher } from "@/components/AccountSwitcher";
import { Logo } from "@/components/Logo";
import { spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
/** Wordmark left, clocked-in indicator right — sits on TopChromeBar blur. */
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome() {
const { isDark } = useAppTheme();
return (
<View style={styles.row}>
<Logo size="xs" onDark={isDark} />
<ClockedInIndicator />
<AccountSwitcher />
</View>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets";
/** Blurred status-bar chrome with logo + clocked-in indicator. */
/** Blurred status-bar chrome with logo + account switcher. */
export function TopChromeBar() {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
+12 -5
View File
@@ -17,6 +17,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString } from "@/lib/form-validation";
import { api } from "@/lib/trpc";
type BusinessFormValues = {
@@ -153,10 +154,7 @@ export function BusinessForm({
}
function handleSave() {
if (!values.name.trim()) {
setFieldError("Business name is required");
return;
}
if (!canSave) return;
const payload = buildPayload();
@@ -186,6 +184,8 @@ export function BusinessForm({
}
const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required";
const canSave = isRequiredString(values.name);
return (
<KeyboardAvoidingView
@@ -199,7 +199,13 @@ export function BusinessForm({
keyboardShouldPersistTaps="handled"
>
<Card title="Profile">
<Input label="Name" value={values.name} onChangeText={(v) => patch("name", v)} />
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Nickname"
value={values.nickname}
@@ -281,6 +287,7 @@ export function BusinessForm({
<Button
title={mode === "create" ? "Create business" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
+18 -5
View File
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, parseNonNegativeNumber } from "@/lib/form-validation";
import { api } from "@/lib/trpc";
export type ClientFormValues = {
@@ -124,10 +125,7 @@ export function ClientForm({
}
function handleSave() {
if (!values.name.trim()) {
setFieldError("Name is required");
return;
}
if (!canSave) return;
const rate = values.defaultHourlyRate.trim()
? Number(values.defaultHourlyRate)
@@ -178,6 +176,13 @@ export function ClientForm({
const saving = createClient.isPending || updateClient.isPending;
const nameError = values.name.trim() ? undefined : "Name is required";
const rateError =
values.defaultHourlyRate.trim() && parseNonNegativeNumber(values.defaultHourlyRate) === null
? "Hourly rate must be a valid number"
: undefined;
const canSave = isRequiredString(values.name) && !rateError;
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
@@ -190,7 +195,13 @@ export function ClientForm({
keyboardShouldPersistTaps="handled"
>
<Card title="Contact">
<Input label="Name" value={values.name} onChangeText={(v) => patch("name", v)} />
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Email"
value={values.email}
@@ -238,6 +249,7 @@ export function ClientForm({
onChangeText={(v) => patch("defaultHourlyRate", v)}
keyboardType="decimal-pad"
placeholder="Optional"
error={rateError}
/>
<Input
label="Currency"
@@ -254,6 +266,7 @@ export function ClientForm({
<Button
title={mode === "create" ? "Create client" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
+36 -13
View File
@@ -14,13 +14,14 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { tabLayout } from "@/lib/tab-layout";
import { formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { describeClockOutOutcome, formatElapsedSeconds } from "@/lib/time-clock";
import { DEFAULT_CLOCK_DESCRIPTION, describeClockOutOutcome, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
@@ -48,7 +49,7 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [description, setDescription] = useState(DEFAULT_CLOCK_DESCRIPTION);
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
@@ -109,7 +110,7 @@ export function TimeClockPanel({
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
setDescription(DEFAULT_CLOCK_DESCRIPTION);
},
});
@@ -117,7 +118,7 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description);
setDescription(running.description?.trim() || DEFAULT_CLOCK_DESCRIPTION);
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
@@ -146,7 +147,7 @@ export function TimeClockPanel({
};
sync();
const interval = setInterval(sync, 30_000);
const interval = setInterval(sync, 15_000);
return () => clearInterval(interval);
}, [running, description]);
@@ -169,12 +170,24 @@ export function TimeClockPanel({
[billableInvoices],
);
const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string } = {};
if (!clientId) next.clientId = "Select a client";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
return next;
}, [clientId, rateText]);
const canClockIn = Object.keys(clockInErrors).length === 0;
async function handleClockIn() {
if (!canClockIn) return;
try {
const backdated =
Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined;
await clockIn.mutateAsync({
description: description.trim(),
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: rate || undefined,
@@ -188,7 +201,9 @@ export function TimeClockPanel({
async function handleClockOut() {
try {
await clockOut.mutateAsync({ description: description.trim() || undefined });
await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
@@ -268,7 +283,7 @@ export function TimeClockPanel({
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>
{description.trim() || "No description"}
{resolveClockDescription(description)}
</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
@@ -307,7 +322,7 @@ export function TimeClockPanel({
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholder={DEFAULT_CLOCK_DESCRIPTION}
/>
</View>
</Card>
@@ -319,6 +334,8 @@ export function TimeClockPanel({
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clockInErrors.clientId}
onValueChange={(next) => {
setClientId(next);
setInvoiceId("");
@@ -342,7 +359,7 @@ export function TimeClockPanel({
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholder={DEFAULT_CLOCK_DESCRIPTION}
/>
<Input
@@ -350,7 +367,8 @@ export function TimeClockPanel({
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder="0.00"
placeholder="Optional"
error={clockInErrors.rate}
/>
<DateTimeField
@@ -374,7 +392,12 @@ export function TimeClockPanel({
onPress={handleClockOut}
/>
) : (
<Button title="Clock in" loading={clockIn.isPending} onPress={handleClockIn} />
<Button
title="Clock in"
loading={clockIn.isPending}
disabled={!canClockIn}
onPress={handleClockIn}
/>
)}
{todayEntries.length > 0 ? (
@@ -387,7 +410,7 @@ export function TimeClockPanel({
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{entry.description || "No description"}</Text>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
+6 -2
View File
@@ -12,14 +12,18 @@ import { fonts, radii, spacing } from "@/constants/theme";
type InputProps = TextInputProps & {
label: string;
error?: string;
required?: boolean;
};
export function Input({ label, error, style, ...props }: InputProps) {
export function Input({ label, error, required, style, ...props }: InputProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
+16 -2
View File
@@ -23,6 +23,8 @@ type SelectFieldProps = {
value: string;
options: SelectOption[];
disabled?: boolean;
required?: boolean;
error?: string;
onValueChange: (value: string) => void;
};
@@ -32,6 +34,8 @@ export function SelectField({
value,
options,
disabled,
required,
error,
onValueChange,
}: SelectFieldProps) {
const { colors } = useAppTheme();
@@ -40,7 +44,10 @@ export function SelectField({
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
disabled={disabled}
@@ -48,7 +55,7 @@ export function SelectField({
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.borderGlass,
borderColor: error ? colors.destructive : colors.borderGlass,
backgroundColor: colors.cardGlass,
},
disabled && styles.triggerDisabled,
@@ -122,6 +129,9 @@ export function SelectField({
</Pressable>
</Pressable>
</Modal>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
</View>
);
}
@@ -134,6 +144,10 @@ const styles = StyleSheet.create({
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
trigger: {
minHeight: 44,
borderWidth: 1,
+114 -61
View File
@@ -11,6 +11,7 @@ import {
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import {
clearStoredPin,
getAppLockEnabled,
@@ -41,6 +42,7 @@ type AppLockContextValue = {
const AppLockContext = createContext<AppLockContextValue | null>(null);
export function AppLockProvider({ children }: { children: ReactNode }) {
const { activeAccountId } = useAccounts();
const [enabled, setEnabled] = useState(false);
const [biometricEnabled, setBiometricEnabledState] = useState(false);
const [hasPin, setHasPin] = useState(false);
@@ -51,14 +53,25 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
const hydrated = useRef(false);
useEffect(() => {
if (!activeAccountId) {
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
hydrated.current = false;
return;
}
let cancelled = false;
hydrated.current = false;
const accountId = activeAccountId;
async function hydrate() {
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes] =
await Promise.all([
getAppLockEnabled(),
getStoredPin(),
getBiometricEnabled(),
getAppLockEnabled(accountId),
getStoredPin(accountId),
getBiometricEnabled(accountId),
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
@@ -86,11 +99,11 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
};
}, []);
}, [activeAccountId]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled) return;
if (!hydrated.current || !enabled || !activeAccountId) return;
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
@@ -103,83 +116,123 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
});
return () => subscription.remove();
}, [enabled]);
}, [enabled, activeAccountId]);
const unlockWithPin = useCallback(async (pin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== pin) {
return false;
}
setIsLocked(false);
return true;
}, []);
const unlockWithPin = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
setIsLocked(false);
return true;
},
[activeAccountId],
);
const unlockWithBiometric = useCallback(async () => {
if (!biometricEnabled || !biometricAvailable) {
if (!biometricAvailable || !activeAccountId) {
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: true,
disableDeviceFallback: false,
biometricsSecurityLevel: "weak",
});
if (!result.success) {
return false;
}
if (!biometricEnabled) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
setIsLocked(false);
return true;
}, [biometricAvailable, biometricEnabled]);
}, [biometricAvailable, biometricEnabled, activeAccountId]);
const enableLock = useCallback(async (pin: string) => {
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
await setStoredPin(pin);
await setAppLockEnabled(true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
}, []);
const enableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) {
throw new Error("No active account");
}
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
const disableLock = useCallback(async (pin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== pin) {
return false;
}
await setAppLockEnabled(false);
await clearStoredPin();
await setBiometricEnabled(false);
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
return true;
}, []);
const [hasHardware, isEnrolled] = await Promise.all([
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
]);
const bioAvailable = hasHardware && isEnrolled;
const changePin = useCallback(async (currentPin: string, nextPin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
return false;
}
await setStoredPin(nextPin);
return true;
}, []);
await setStoredPin(activeAccountId, pin);
await setAppLockEnabled(activeAccountId, true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
const setUseBiometric = useCallback(async (next: boolean) => {
if (next) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `Enable ${biometricLabel}`,
cancelLabel: "Cancel",
disableDeviceFallback: true,
});
if (!result.success) return;
}
await setBiometricEnabled(next);
setBiometricEnabledState(next);
}, [biometricLabel]);
if (bioAvailable) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
},
[activeAccountId],
);
const disableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
await setAppLockEnabled(activeAccountId, false);
await clearStoredPin(activeAccountId);
await setBiometricEnabled(activeAccountId, false);
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
return true;
},
[activeAccountId],
);
const changePin = useCallback(
async (currentPin: string, nextPin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
return false;
}
await setStoredPin(activeAccountId, nextPin);
return true;
},
[activeAccountId],
);
const setUseBiometric = useCallback(
async (next: boolean) => {
if (!activeAccountId) return;
if (next) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `Enable ${biometricLabel}`,
cancelLabel: "Cancel",
disableDeviceFallback: true,
});
if (!result.success) return;
}
await setBiometricEnabled(activeAccountId, next);
setBiometricEnabledState(next);
},
[biometricLabel, activeAccountId],
);
const lock = useCallback(() => {
if (enabled) {
+266
View File
@@ -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 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).
+12
View File
@@ -0,0 +1,12 @@
# beenvoice-app documentation
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Routing, contexts, auth/accounts, tRPC, app lock, Live Activity, theming |
| [../README.md](../README.md) | Setup, run, troubleshooting |
| [../AGENTS.md](../AGENTS.md) | Agent conventions |
## Related
- [beenvoice docs](../../beenvoice/docs/README.md) — server API and web app
- [Workspace README](../../README.md) — full-stack layout
+51 -19
View File
@@ -1,44 +1,76 @@
import * as SecureStore from "expo-secure-store";
const ENABLED_KEY = "beenvoice_app_lock_enabled";
const PIN_KEY = "beenvoice_app_lock_pin";
const BIOMETRIC_KEY = "beenvoice_app_lock_biometric";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
export async function getAppLockEnabled(): Promise<boolean> {
const value = await SecureStore.getItemAsync(ENABLED_KEY);
function lockKey(accountId: string, field: "enabled" | "pin" | "biometric") {
return normalizeSecureStoreKey(`beenvoice.app-lock.${accountId}.${field}`);
}
const LEGACY_ENABLED_KEY = "beenvoice_app_lock_enabled";
const LEGACY_PIN_KEY = "beenvoice_app_lock_pin";
const LEGACY_BIOMETRIC_KEY = "beenvoice_app_lock_biometric";
async function migrateLegacyLockIfNeeded(accountId: string): Promise<void> {
const [legacyEnabled, legacyPin, legacyBiometric, accountEnabled] = await Promise.all([
SecureStore.getItemAsync(LEGACY_ENABLED_KEY),
SecureStore.getItemAsync(LEGACY_PIN_KEY),
SecureStore.getItemAsync(LEGACY_BIOMETRIC_KEY),
SecureStore.getItemAsync(lockKey(accountId, "enabled")),
]);
if (accountEnabled != null || legacyEnabled !== "1") return;
if (legacyPin) {
await setStoredPin(accountId, legacyPin);
}
await setAppLockEnabled(accountId, true);
if (legacyBiometric === "1") {
await setBiometricEnabled(accountId, true);
}
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_ENABLED_KEY),
SecureStore.deleteItemAsync(LEGACY_PIN_KEY),
SecureStore.deleteItemAsync(LEGACY_BIOMETRIC_KEY),
]);
}
export async function getAppLockEnabled(accountId: string): Promise<boolean> {
await migrateLegacyLockIfNeeded(accountId);
const value = await SecureStore.getItemAsync(lockKey(accountId, "enabled"));
return value === "1";
}
export async function setAppLockEnabled(enabled: boolean): Promise<void> {
export async function setAppLockEnabled(accountId: string, enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(ENABLED_KEY, "1");
await SecureStore.setItemAsync(lockKey(accountId, "enabled"), "1");
} else {
await SecureStore.deleteItemAsync(ENABLED_KEY);
await SecureStore.deleteItemAsync(lockKey(accountId, "enabled"));
}
}
export async function getStoredPin(): Promise<string | null> {
return SecureStore.getItemAsync(PIN_KEY);
export async function getStoredPin(accountId: string): Promise<string | null> {
return SecureStore.getItemAsync(lockKey(accountId, "pin"));
}
export async function setStoredPin(pin: string): Promise<void> {
await SecureStore.setItemAsync(PIN_KEY, pin);
export async function setStoredPin(accountId: string, pin: string): Promise<void> {
await SecureStore.setItemAsync(lockKey(accountId, "pin"), pin);
}
export async function clearStoredPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_KEY);
export async function clearStoredPin(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(lockKey(accountId, "pin"));
}
export async function getBiometricEnabled(): Promise<boolean> {
const value = await SecureStore.getItemAsync(BIOMETRIC_KEY);
export async function getBiometricEnabled(accountId: string): Promise<boolean> {
const value = await SecureStore.getItemAsync(lockKey(accountId, "biometric"));
return value === "1";
}
export async function setBiometricEnabled(enabled: boolean): Promise<void> {
export async function setBiometricEnabled(accountId: string, enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(BIOMETRIC_KEY, "1");
await SecureStore.setItemAsync(lockKey(accountId, "biometric"), "1");
} else {
await SecureStore.deleteItemAsync(BIOMETRIC_KEY);
await SecureStore.deleteItemAsync(lockKey(accountId, "biometric"));
}
}
+71
View File
@@ -0,0 +1,71 @@
import * as SecureStore from "expo-secure-store";
import { authStoragePrefix, buildAccountId } from "@/lib/accounts";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
export const GUEST_AUTH_STORAGE_PREFIX = "beenvoice:guest";
const CHUNK_MARKER = "\u0001ba-chunks:";
const AUTH_STORAGE_SUFFIXES = ["_cookie", "_session_data", "_last_login_method"] as const;
function storageKeyForPrefix(prefix: string, suffix: (typeof AUTH_STORAGE_SUFFIXES)[number]) {
return normalizeSecureStoreKey(`${prefix}${suffix}`);
}
async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<void> {
const value = await SecureStore.getItemAsync(fromKey);
if (value == null) return;
await SecureStore.setItemAsync(toKey, value);
if (!value.startsWith(CHUNK_MARKER)) return;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return;
for (let i = 0; i < count; i += 1) {
const chunk = await SecureStore.getItemAsync(`${fromKey}.${i}`);
if (chunk != null) {
await SecureStore.setItemAsync(`${toKey}.${i}`, chunk);
}
}
}
export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise<void> {
if (fromPrefix === toPrefix) return;
await Promise.all(
AUTH_STORAGE_SUFFIXES.map((suffix) =>
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
),
);
}
export async function finalizeAuthenticatedAccount(input: {
apiUrl: string;
userId: string;
email: string;
name: string;
activeAccountId: string | null;
registerAccount: (input: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<unknown>;
}): Promise<void> {
const accountId = buildAccountId(input.apiUrl, input.userId);
const targetPrefix = authStoragePrefix(accountId);
const sourcePrefix = input.activeAccountId
? authStoragePrefix(input.activeAccountId)
: GUEST_AUTH_STORAGE_PREFIX;
await migrateAuthStorage(sourcePrefix, targetPrefix);
await input.registerAccount({
instanceUrl: input.apiUrl,
userId: input.userId,
email: input.email,
name: input.name,
});
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
export function useFieldVisibility() {
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [submitted, setSubmitted] = useState(false);
const touch = useCallback((field: string) => {
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
}, []);
const visible = useCallback(
(field: string) => submitted || Boolean(touched[field]),
[submitted, touched],
);
const markSubmitted = useCallback(() => setSubmitted(true), []);
return { touch, visible, markSubmitted };
}
export function isRequiredString(value: string): boolean {
return value.trim().length > 0;
}
export function isValidEmail(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
}
export function isValidPassword(value: string): boolean {
return value.length >= 8;
}
/** Parses a non-negative decimal, or null if empty/invalid. */
export function parseNonNegativeNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
if (Number.isNaN(n) || n < 0) return null;
return n;
}
export function isValidTaxRate(value: string): boolean {
const n = parseNonNegativeNumber(value);
if (n === null) return false;
return n <= 100;
}
export type LineItemInput = {
description: string;
hours: string;
rate: string;
};
export function validateLineItems(items: LineItemInput[]): string | null {
if (items.length === 0) return "Add at least one line item";
for (const item of items) {
if (!isRequiredString(item.description)) return "Each line needs a description";
if (parseNonNegativeNumber(item.hours) === null) return "Hours must be a valid number";
if (parseNonNegativeNumber(item.rate) === null) return "Rate must be a valid number";
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
/**
* expo-secure-store keys must be non-empty and match [A-Za-z0-9._-]+
* @see https://docs.expo.dev/versions/latest/sdk/securestore/
*/
export function normalizeSecureStoreKey(key: string): string {
return key.replace(/[^A-Za-z0-9._-]/g, "_");
}
+35
View File
@@ -0,0 +1,35 @@
import { DEFAULT_API_URL } from "@/lib/config";
import { normalizeInstanceUrl } from "@/lib/instance-url";
export type ServerMode = "official" | "self-hosted";
export const SERVER_MODE_OPTIONS: { value: ServerMode; label: string }[] = [
{ value: "official", label: "Official" },
{ value: "self-hosted", label: "Self-hosted" },
];
export function isOfficialServerUrl(url: string): boolean {
return url.replace(/\/$/, "") === DEFAULT_API_URL.replace(/\/$/, "");
}
export function resolveServerMode(url: string): ServerMode {
return isOfficialServerUrl(url) ? "official" : "self-hosted";
}
export function formatServerHost(url: string): string {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
}
export function isServerConfigValid(mode: ServerMode, selfHostedUrl: string): boolean {
if (mode === "official") return true;
return normalizeInstanceUrl(selfHostedUrl) !== null;
}
export function resolveServerUrl(mode: ServerMode, selfHostedUrl: string): string | null {
if (mode === "official") return DEFAULT_API_URL;
return normalizeInstanceUrl(selfHostedUrl);
}
+8 -10
View File
@@ -1,12 +1,11 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import { Platform } from "react-native";
import { formatElapsedHoursMinutes, formatElapsedSeconds } from "@/lib/time-clock";
import { formatElapsedHoursMinutes, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock";
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
import { ensureWidgetBrandAssets, getWidgetBrandAssetUris } from "@/lib/widget-brand-assets";
type RunningEntry = {
description: string;
startedAt: Date | string;
client?: { name: string } | null;
invoice?: { invoicePrefix: string | null; invoiceNumber: string } | null;
};
@@ -55,21 +54,19 @@ export function buildTimeClockActivityProps(
elapsedSeconds: number,
): TimeClockActivityProps {
const invoice = running.invoice;
const brand = getWidgetBrandAssetUris();
return {
startedAtMs: new Date(running.startedAt).getTime(),
elapsed: formatElapsedSeconds(elapsedSeconds),
elapsedShort: formatElapsedHoursMinutes(elapsedSeconds),
clockTime: new Date().toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
}),
description: running.description,
description: resolveClockDescription(running.description),
clientName: running.client?.name ?? "",
invoiceLabel: invoice
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
: "",
markImageUri: brand?.markUri,
logoImageUri: brand?.logoUri,
};
}
@@ -86,7 +83,6 @@ export async function syncTimeClockLiveActivity(
}
try {
await ensureWidgetBrandAssets();
const props = buildTimeClockActivityProps(running, elapsedSeconds);
const instances = factory.getInstances();
@@ -96,8 +92,10 @@ export async function syncTimeClockLiveActivity(
}
factory.start(props, "beenvoice://timer");
} catch {
// Native module can disappear between checks (e.g. hot reload in Expo Go).
} catch (error) {
if (__DEV__) {
console.warn("[LiveActivity] sync failed:", error);
}
factoryCache = undefined;
}
}
+3 -5
View File
@@ -1,5 +1,7 @@
export type TimeClockActivityProps = {
/** Full elapsed timer, e.g. 01:23:45 */
/** Unix ms when the timer started — drives native live-updating Text timers */
startedAtMs: number;
/** Full elapsed timer, e.g. 01:23:45 (updated on sync) */
elapsed: string;
/** Hours:minutes only for compact chrome, e.g. 1:23 */
elapsedShort: string;
@@ -8,8 +10,4 @@ export type TimeClockActivityProps = {
description: string;
clientName: string;
invoiceLabel: string;
/** file:// URI to square dollar mark in the app-group widgets folder */
markImageUri?: string;
/** file:// URI to wordmark PNG in the app-group widgets folder */
logoImageUri?: string;
};
+7
View File
@@ -4,6 +4,13 @@ export type ClockOutOutcome =
| "saved_no_client"
| "zero_hours";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
return trimmed || DEFAULT_CLOCK_DESCRIPTION;
}
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
-55
View File
@@ -1,55 +0,0 @@
import { Asset } from "expo-asset";
import { File } from "expo-file-system";
import { widgetsDirectory } from "expo-widgets";
import { Platform } from "react-native";
const MARK_FILE = "beenvoice-live-mark.png";
const LOGO_FILE = "beenvoice-live-logo.png";
let cachedUris: { markUri: string; logoUri: string } | null = null;
let copyPromise: Promise<{ markUri: string; logoUri: string } | null> | null = null;
async function copyBrandFile(fromUri: string, toUri: string) {
await new File(fromUri).copy(new File(toUri), { overwrite: true });
}
/** Copy brand PNGs into the app-group folder so the widget extension can read them. */
export async function ensureWidgetBrandAssets(): Promise<{
markUri: string;
logoUri: string;
} | null> {
if (cachedUris) return cachedUris;
if (copyPromise) return copyPromise;
copyPromise = (async () => {
if (Platform.OS !== "ios" || !widgetsDirectory) return null;
const base = widgetsDirectory.endsWith("/") ? widgetsDirectory : `${widgetsDirectory}/`;
const markUri = `${base}${MARK_FILE}`;
const logoUri = `${base}${LOGO_FILE}`;
const markAsset = Asset.fromModule(require("@/assets/images/icon.png"));
const logoAsset = Asset.fromModule(require("@/assets/images/beenvoice-logo-dark.png"));
await Promise.all([markAsset.downloadAsync(), logoAsset.downloadAsync()]);
if (!markAsset.localUri || !logoAsset.localUri) return null;
await Promise.all([
copyBrandFile(markAsset.localUri, markUri),
copyBrandFile(logoAsset.localUri, logoUri),
]);
cachedUris = { markUri, logoUri };
return cachedUris;
})();
try {
return await copyPromise;
} finally {
copyPromise = null;
}
}
export function getWidgetBrandAssetUris() {
return cachedUris;
}
+80 -81
View File
@@ -1,105 +1,104 @@
import { HStack, Image, Text, VStack } from "@expo/ui/swift-ui";
import { HStack, Image, Text } from "@expo/ui/swift-ui";
import {
font,
foregroundStyle,
frame,
lineLimit,
minimumScaleFactor,
monospacedDigit,
padding,
widgetAccentedRenderingMode,
} from "@expo/ui/swift-ui/modifiers";
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
const TIMER_GREEN = "#4ADE80";
const SUBTLE_TEXT = "#E5E5E5";
const MUTED_TEXT = "#D4D4D4";
function ElapsedText({
value,
size,
weight = "semibold",
}: {
value: string;
size: number;
weight?: "regular" | "medium" | "semibold" | "bold";
}) {
return (
<Text
modifiers={[
font({ design: "monospaced", weight, size }),
monospacedDigit(),
foregroundStyle(TIMER_GREEN),
]}
>
{value}
</Text>
);
}
function BrandMark({ uri, size }: { uri?: string; size: number }) {
if (uri) {
return <Image uiImage={uri} modifiers={[frame({ width: size, height: size })]} />;
}
return <Image systemName="dollarsign" color="#FAFAFA" size={size} />;
}
function BrandLogo({ uri, height = 18 }: { uri?: string; height?: number }) {
if (uri) {
return <Image uiImage={uri} modifiers={[frame({ width: 132, height })]} />;
}
return (
<Text modifiers={[font({ weight: "bold", size: 16 }), foregroundStyle("#FFFFFF")]}>
beenvoice
</Text>
);
}
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
"widget";
const title = props.description.trim() || "Timer running";
const green = "green";
const title = props.description.trim() || "Clock In";
const subtitle = [props.clientName, props.invoiceLabel].filter(Boolean).join(" · ");
const detailLine = subtitle ? `${title}\n${subtitle}` : title;
const timerMods = [
font({ design: "monospaced", weight: "bold", size: 20 }),
monospacedDigit(),
foregroundStyle(green),
lineLimit(1),
minimumScaleFactor(0.85),
];
const compactTimerMods = [
font({ design: "monospaced", weight: "semibold", size: 11 }),
monospacedDigit(),
foregroundStyle(green),
lineLimit(1),
minimumScaleFactor(0.8),
];
const brandMods = [
font({ weight: "semibold", size: 13 }),
foregroundStyle(green),
lineLimit(1),
minimumScaleFactor(0.85),
];
const detailMods = [
font({ weight: "medium", size: 12 }),
foregroundStyle({ type: "hierarchical", style: "secondary" }),
lineLimit(2),
minimumScaleFactor(0.85),
];
return {
banner: (
<HStack modifiers={[padding({ all: 14 })]}>
<BrandLogo uri={props.logoImageUri} />
<ElapsedText value={props.elapsedShort} size={20} weight="bold" />
<HStack alignment="center" spacing={10} modifiers={[padding({ all: 12 })]}>
<Image
systemName="dollarsign.circle.fill"
color={green}
size={22}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
<Text modifiers={brandMods}>beenvoice</Text>
<Text modifiers={timerMods}>{props.elapsedShort}</Text>
</HStack>
),
compactLeading: <BrandMark uri={props.markImageUri} size={18} />,
compactTrailing: <ElapsedText value={props.elapsedShort} size={14} />,
minimal: <ElapsedText value={props.elapsedShort} size={12} weight="bold" />,
bannerSmall: (
<HStack alignment="center" spacing={8} modifiers={[padding({ all: 10 })]}>
<Image
systemName="dollarsign.circle.fill"
color={green}
size={18}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
<Text modifiers={brandMods}>beenvoice</Text>
<Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>
</HStack>
),
compactLeading: (
<Image
systemName="dollarsign.circle.fill"
color={green}
size={15}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
),
compactTrailing: <Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>,
minimal: (
<Image
systemName="dollarsign.circle.fill"
color={green}
size={12}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
),
expandedLeading: (
<VStack modifiers={[padding({ all: 12 })]}>
<BrandLogo uri={props.logoImageUri} height={20} />
<Text modifiers={[font({ size: 12, design: "monospaced" }), foregroundStyle(SUBTLE_TEXT)]}>
{props.clockTime}
</Text>
</VStack>
),
expandedTrailing: (
<VStack modifiers={[padding({ all: 12 })]}>
<ElapsedText value={props.elapsedShort} size={32} weight="bold" />
<Text modifiers={[font({ size: 12 }), foregroundStyle(SUBTLE_TEXT)]}>elapsed</Text>
</VStack>
),
expandedBottom: (
<VStack modifiers={[padding({ horizontal: 12, bottom: 12 })]}>
<Text modifiers={[font({ weight: "semibold", size: 15 }), foregroundStyle("#FFFFFF")]}>
{title}
</Text>
{subtitle ? (
<Text modifiers={[font({ size: 13 }), foregroundStyle(SUBTLE_TEXT)]}>{subtitle}</Text>
) : null}
<HStack>
<ElapsedText value={props.elapsed} size={12} weight="regular" />
<Text modifiers={[font({ size: 12 }), foregroundStyle(MUTED_TEXT)]}> total</Text>
</HStack>
</VStack>
<Image
systemName="dollarsign.circle.fill"
color={green}
size={20}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
),
expandedTrailing: <Text modifiers={timerMods}>{props.elapsedShort}</Text>,
expandedBottom: <Text modifiers={detailMods}>{detailLine}</Text>,
};
}