diff --git a/AGENTS.md b/AGENTS.md
index a26b4bb..9e0c4ad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/README.md b/README.md
index c2e0b5e..05d1ab6 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/app.json b/app.json
index 6a1f64f..c7601e4 100644
--- a/app.json
+++ b/app.json
@@ -48,7 +48,8 @@
[
"expo-widgets",
{
- "groupIdentifier": "group.com.beenvoice.app"
+ "groupIdentifier": "group.com.beenvoice.app",
+ "bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget"
}
],
[
diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx
index 912ba4a..bba5587 100644
--- a/app/(app)/index.tsx
+++ b/app/(app)/index.tsx
@@ -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() {
- {running.description || "Timer running"}
+ {resolveClockDescription(running.description)}
{running.client?.name ?? "No client"}
diff --git a/app/(app)/invoices/edit/[id].tsx b/app/(app)/invoices/edit/[id].tsx
index 1007263..90b3ebf 100644
--- a/app/(app)/invoices/edit/[id].tsx
+++ b/app/(app)/invoices/edit/[id].tsx
@@ -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 ;
@@ -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() {
+ {lineItemsError ? {lineItemsError} : null}
{error ? {error} : null}
-
+
{status !== "paid" ? (
diff --git a/app/(auth)/reset-password.tsx b/app/(auth)/reset-password.tsx
index d6c65e2..10acc7c 100644
--- a/app/(auth)/reset-password.tsx
+++ b/app/(auth)/reset-password.tsx
@@ -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(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() {
← Back
+
+
Set new password
@@ -101,6 +112,8 @@ export default function ResetPasswordScreen() {
value={token}
onChangeText={setToken}
placeholder="Paste token from email"
+ required
+ error={tokenError}
/>
{error ? {error} : null}
-
+
)}
diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx
index 43f6f4c..a75cd9e 100644
--- a/app/(auth)/sign-in.tsx
+++ b/app/(auth)/sign-in.tsx
@@ -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(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"
>
+
@@ -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}
/>
touch("password")}
placeholder="••••••••"
+ required
+ error={visible("password") ? passwordValidationError : undefined}
/>
router.push("/(auth)/forgot-password")}>
@@ -110,7 +137,12 @@ export default function SignInScreen() {
{error}
) : null}
-
+
diff --git a/app/_layout.tsx b/app/_layout.tsx
index b7eed02..10c72e7 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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;
}
diff --git a/components/AccountSwitcher.tsx b/components/AccountSwitcher.tsx
new file mode 100644
index 0000000..84dd852
--- /dev/null
+++ b/components/AccountSwitcher.tsx
@@ -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 (
+ <>
+ setOpen(true)}
+ style={styles.hit}
+ >
+
+
+
+ {avatar}
+
+
+
+ {label}
+
+
+
+
+
+ setOpen(false)} transparent visible={open}>
+ setOpen(false)}>
+ event.stopPropagation()}
+ >
+
+ Accounts
+ setOpen(false)}>
+ Done
+
+
+
+
+ {accounts.map((account) => {
+ const isActive = account.id === activeAccountId;
+ return (
+ void handleSwitch(account.id)}
+ style={({ pressed }) => [
+ styles.accountRow,
+ {
+ borderBottomColor: colors.border,
+ backgroundColor: isActive ? colors.muted : "transparent",
+ },
+ pressed && styles.pressed,
+ ]}
+ >
+
+
+ {initials(account.name, account.email)}
+
+
+
+
+ {account.name || account.email}
+
+
+ {account.email}
+
+
+ {formatServerHost(account.instanceUrl)}
+
+
+ {isActive ? (
+
+ ) : null}
+
+ );
+ })}
+
+ void handleAddAccount()}
+ style={({ pressed }) => [
+ styles.addRow,
+ { borderTopColor: colors.border },
+ pressed && styles.pressed,
+ ]}
+ >
+
+ Add account
+
+
+
+
+
+ >
+ );
+}
+
+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,
+ },
+});
diff --git a/components/AppLockOverlay.tsx b/components/AppLockOverlay.tsx
index 59d1693..a189719 100644
--- a/components/AppLockOverlay.tsx
+++ b/components/AppLockOverlay.tsx
@@ -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() {
-
- beenvoice is locked
+
+ Locked
Enter your PIN to continue
@@ -104,20 +111,18 @@ export function AppLockOverlay() {
{error ? {error} : null}
- void submitPin()} disabled={pin.length < 4} />
+
+ void submitPin()} disabled={pin.length < 4} />
- {biometricEnabled && biometricAvailable ? (
- void tryBiometric()}
- style={styles.biometricButton}
- >
-
-
- Unlock with {biometricLabel}
-
-
- ) : null}
+ {biometricAvailable ? (
+ void tryBiometric()}
+ style={styles.biometricButton}
+ />
+ ) : null}
+
@@ -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%",
},
});
diff --git a/components/AuthServerPicker.tsx b/components/AuthServerPicker.tsx
new file mode 100644
index 0000000..65acd57
--- /dev/null
+++ b/components/AuthServerPicker.tsx
@@ -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(() => resolveServerMode(apiUrl));
+ const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
+ resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
+ );
+ const [urlError, setUrlError] = useState(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 (
+
+ setExpanded((open) => !open)}
+ hitSlop={8}
+ style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
+ >
+
+ Server ·{" "}
+
+ {modeSummary(mode, selfHostedUrl)}
+
+
+
+
+
+ {expanded ? (
+
+ {SERVER_MODE_OPTIONS.map((option) => {
+ const selected = option.value === mode;
+ return (
+ void applyMode(option.value)}
+ style={({ pressed }) => [
+ styles.option,
+ {
+ borderColor: colors.border,
+ backgroundColor: selected ? colors.muted : "transparent",
+ },
+ pressed && styles.pressed,
+ ]}
+ >
+
+ {option.label}
+
+ {selected ? (
+
+ ) : null}
+
+ );
+ })}
+
+ {mode === "self-hosted" ? (
+ <>
+ {
+ 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}
+ />
+
+ Use your Mac's LAN IP on a physical device.
+
+ >
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+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,
+ },
+});
diff --git a/components/PinPrompt.tsx b/components/PinPrompt.tsx
index 7e74eef..2afe5aa 100644
--- a/components/PinPrompt.tsx
+++ b/components/PinPrompt.tsx
@@ -145,7 +145,6 @@ const styles = StyleSheet.create({
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
- letterSpacing: 6,
},
error: {
fontSize: 13,
diff --git a/components/TopChrome.tsx b/components/TopChrome.tsx
index ed54797..2bed165 100644
--- a/components/TopChrome.tsx
+++ b/components/TopChrome.tsx
@@ -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 (
-
+
);
}
diff --git a/components/TopChromeBar.tsx b/components/TopChromeBar.tsx
index 0c4d432..198b668 100644
--- a/components/TopChromeBar.tsx
+++ b/components/TopChromeBar.tsx
@@ -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();
diff --git a/components/businesses/BusinessForm.tsx b/components/businesses/BusinessForm.tsx
index 42d153f..fdd9a7c 100644
--- a/components/businesses/BusinessForm.tsx
+++ b/components/businesses/BusinessForm.tsx
@@ -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 (
- patch("name", v)} />
+ patch("name", v)}
+ required
+ error={nameError}
+ />
{mode === "edit" ? (
diff --git a/components/clients/ClientForm.tsx b/components/clients/ClientForm.tsx
index 69ad303..85aaeb8 100644
--- a/components/clients/ClientForm.tsx
+++ b/components/clients/ClientForm.tsx
@@ -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 (
- patch("name", v)} />
+ patch("name", v)}
+ required
+ error={nameError}
+ />
patch("defaultHourlyRate", v)}
keyboardType="decimal-pad"
placeholder="Optional"
+ error={rateError}
/>
{mode === "edit" ? (
diff --git a/components/time-clock/TimeClockPanel.tsx b/components/time-clock/TimeClockPanel.tsx
index 4684be8..9a58a50 100644
--- a/components/time-clock/TimeClockPanel.tsx
+++ b/components/time-clock/TimeClockPanel.tsx
@@ -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({
{formatElapsedSeconds(elapsed)}
- {description.trim() || "No description"}
+ {resolveClockDescription(description)}
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}
/>
@@ -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}
/>
) : (
-
+
)}
{todayEntries.length > 0 ? (
@@ -387,7 +410,7 @@ export function TimeClockPanel({
const row = (
<>
- {entry.description || "No description"}
+ {resolveClockDescription(entry.description)}
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx
index 737f62a..3fbe057 100644
--- a/components/ui/Input.tsx
+++ b/components/ui/Input.tsx
@@ -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 (
- {label}
+
+ {label}
+ {required ? * : null}
+
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 (
- {label}
+
+ {label}
+ {required ? * : null}
+
[
styles.trigger,
{
- borderColor: colors.borderGlass,
+ borderColor: error ? colors.destructive : colors.borderGlass,
backgroundColor: colors.cardGlass,
},
disabled && styles.triggerDisabled,
@@ -122,6 +129,9 @@ export function SelectField({
+ {error ? (
+ {error}
+ ) : null}
);
}
@@ -134,6 +144,10 @@ const styles = StyleSheet.create({
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
+ error: {
+ fontSize: 13,
+ fontFamily: fonts.body,
+ },
trigger: {
minHeight: 44,
borderWidth: 1,
diff --git a/contexts/AppLockContext.tsx b/contexts/AppLockContext.tsx
index beda12d..716fb62 100644
--- a/contexts/AppLockContext.tsx
+++ b/contexts/AppLockContext.tsx
@@ -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(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 4–6 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 4–6 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) {
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..120b0fe
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,266 @@
+# beenvoice-app architecture
+
+Dense reference for the Expo 56 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
+
+## Stack
+
+| Layer | Technology |
+|-------|------------|
+| Framework | Expo 56, expo-router 56 (file-based routes) |
+| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
+| API | tRPC 11 + TanStack Query, SuperJSON |
+| Auth | better-auth + `@better-auth/expo` → `expo-secure-store` |
+| Types | `AppRouter` imported from `../beenvoice/src/server/api/root` |
+
+## Boot sequence
+
+```
+app/_layout.tsx
+ SafeAreaProvider
+ ThemeProvider ← AsyncStorage color mode
+ BrandBackground + StatusBar
+ AccountsProvider ← load accounts, active id, apiUrl
+ AppServices ← key={activeAccountId:apiUrl}
+ AuthProvider ← better-auth client (storagePrefix)
+ TRPCProvider ← cookie header on /api/trpc
+ RootNavigator
+ session? → (app) | (auth)
+```
+
+`AccountsProvider` blocks on `LoadingScreen` until AsyncStorage hydrates. `AuthProvider` + `TRPCProvider` **remount** when `activeAccountId` or `apiUrl` changes so each account uses isolated SecureStore session keys.
+
+## Routing
+
+### Auth group `app/(auth)/`
+
+| Screen | File | Notes |
+|--------|------|-------|
+| redirect | `index.tsx` | → sign-in |
+| Sign in | `sign-in.tsx` | Server picker, deferred validation |
+| Register | `register.tsx` | REST register + sign-in + finalize account |
+| Forgot password | `forgot-password.tsx` | REST |
+| Reset password | `reset-password.tsx` | Deep link `beenvoice://reset-password?token=` |
+
+### App group `app/(app)/`
+
+`NativeTabs` (5 tabs) in `_layout.tsx`. Wrapped in `AppLockProvider` + `AppLockOverlay`.
+
+| Tab | Screen | Features |
+|-----|--------|----------|
+| Dashboard | `index.tsx` | Stats, running timer chip, recent invoices |
+| Timer | `timer.tsx` | `TimeClockPanel` |
+| Entities | `entities/*` | Clients + businesses CRUD stacks |
+| Invoices | `invoices/*` | List, create, edit, status |
+| Settings | `settings.tsx` | Profile, accounts, theme, app lock, sign out |
+
+Nested stacks: `entities/_layout.tsx`, `invoices/_layout.tsx`.
+
+## Multi-account model
+
+**Storage** — `lib/accounts.ts` (AsyncStorage):
+
+| Key | Content |
+|-----|---------|
+| `beenvoice:accounts` | `SavedAccount[]` |
+| `beenvoice:active-account-id` | current account id or absent |
+| `beenvoice:draft-instance-url` | server URL before first login |
+
+**Account id**: `{hostname}::{userId}` — host from instance URL without protocol/trailing slash.
+
+**Auth storage prefix** (SecureStore, better-auth):
+
+| Mode | Prefix |
+|------|--------|
+| Guest (signed out / adding account) | `beenvoice:guest` |
+| Per account | `beenvoice:auth:{accountId}` |
+
+Keys written by `@better-auth/expo`: `{prefix}_cookie`, `{prefix}_session_data`, `{prefix}_last_login_method` (colons normalized to `_` in SecureStore). Large cookies are chunked (`\x01ba-chunks:N` + `.{i}` keys).
+
+### Sign-in flow (critical)
+
+1. User signs in while `AuthProvider` uses **guest** (or current) prefix.
+2. Session lands in that prefix's SecureStore.
+3. `finalizeAuthenticatedAccount()` (`lib/auth-storage.ts`):
+ - `migrateAuthStorage(sourcePrefix → targetPrefix)` — copy session keys
+ - `registerAccount()` — set active account, persist metadata
+4. `AuthProvider` remounts with account prefix; session already migrated → `RootNavigator` shows `(app)`.
+
+Without migration, remounting loses the session and forces a second login.
+
+### Account switcher
+
+`components/AccountSwitcher.tsx` (header):
+
+- **Switch**: `switchAccount(id)` → remount auth/tRPC with that account's URL + prefix (session must already exist in target prefix).
+- **Add account**: `signOut()` + `clearActiveAccount()` → auth stack on guest prefix.
+
+## Server picker
+
+`components/AuthServerPicker.tsx` + `lib/server-mode.ts`:
+
+- **Official** — `DEFAULT_API_URL` (`https://beenvoice.soconnor.dev` in `lib/config.ts`)
+- **Self-hosted** — user URL, normalized via `lib/instance-url.ts` (adds `http://` for localhost/LAN)
+
+`setInstanceUrl()` updates runtime API (`lib/config.ts` `setRuntimeApiUrl`) and draft or active account URL.
+
+## API URL resolution
+
+`lib/config.ts` priority:
+
+1. Runtime override (`setRuntimeApiUrl` from AccountsContext)
+2. `EXPO_PUBLIC_API_URL` from `.env`
+3. Dev: Metro host IP + `:3000`
+4. `DEFAULT_API_URL`
+
+## tRPC client
+
+`lib/trpc.tsx`:
+
+```ts
+httpBatchLink({
+ url: `${apiUrl}/api/trpc`,
+ transformer: SuperJSON,
+ headers: () => ({ cookie: authClient.getCookie() }),
+})
+```
+
+Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`.
+
+## App lock (per account)
+
+`lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`:
+
+- `beenvoice:app-lock:{id}:enabled|pin|biometric`
+- One-time migration from legacy global keys `beenvoice_app_lock_*`
+
+`contexts/AppLockContext.tsx`:
+
+- Hydrates on account change
+- Locks when returning from background (if enabled)
+- PIN 4–6 digits; Face ID / Touch ID via `expo-local-authentication`
+- Only active inside `(app)/_layout.tsx` — auth screens never locked
+
+UI: `AppLockOverlay.tsx`, `PinPrompt.tsx`, settings toggles.
+
+## Time clock
+
+`components/time-clock/TimeClockPanel.tsx`:
+
+- Client required; description optional (defaults to **"Clock In"** via `lib/time-clock.ts`)
+- Optional invoice, hourly rate, backdated start
+- `clockOut` sends optional description update
+- Syncs iOS Live Activity every 30s while running
+
+### Live Activity
+
+| File | Role |
+|------|------|
+| `widgets/TimeClockActivity.tsx` | SwiftUI widget (`expo-widgets`); must keep all UI **inside** the `"widget"` function (babel preset serializes only that) |
+| `lib/time-clock-live-activity.ts` | `syncTimeClockLiveActivity`, `endTimeClockLiveActivity` |
+| `app.json` | Plugin `expo-widgets`, app group `group.com.beenvoice.app` |
+
+Does not work in Expo Go — use `bun run ios` dev build.
+
+## Theming
+
+| File | Role |
+|------|------|
+| `lib/beenvoice-theme.ts` | Light tokens mirrored from web `globals.css` |
+| `lib/theme-palette.ts` | Light + dark `ThemeColors` |
+| `constants/theme.ts` | spacing, radii, fonts (Playfair / Inter / SpaceMono) |
+| `contexts/ThemeContext.tsx` | system / light / dark → AsyncStorage `beenvoice:color-mode` |
+| `components/BrandBackground.tsx` | Grid + animated blob |
+| `lib/use-themed-styles.ts` | Memoized StyleSheet factory |
+
+## Contexts summary
+
+| Context | File |
+|---------|------|
+| Auth | `contexts/AuthContext.tsx` |
+| Accounts | `contexts/AccountsContext.tsx` |
+| App lock | `contexts/AppLockContext.tsx` |
+| Theme | `contexts/ThemeContext.tsx` |
+
+## `lib/` module index
+
+| Module | Purpose |
+|--------|---------|
+| `accounts.ts` | Account registry types + AsyncStorage |
+| `auth-storage.ts` | Session migration, `finalizeAuthenticatedAccount` |
+| `auth-api.ts` | REST register / forgot / reset |
+| `config.ts` | API URL |
+| `instance-url.ts` | URL normalization + persistence |
+| `server-mode.ts` | Official vs self-hosted |
+| `trpc.tsx` | tRPC provider |
+| `app-lock.ts` | Per-account PIN storage |
+| `form-validation.ts` | Validators + `useFieldVisibility` |
+| `format.ts` | Currency, dates |
+| `invoice-status.ts` | Status colors |
+| `invoice-number.ts` | Number generation |
+| `time-clock.ts` | Timer formatting, clock-out copy |
+| `time-clock-live-activity*.ts` | Live Activity bridge |
+| `tab-layout.ts`, `tab-bar-insets.ts`, `top-chrome-insets.ts` | Layout metrics |
+
+## Components layout
+
+```
+components/
+├── ui/ # Button, Card, Input, SelectField, DateTimeField
+├── time-clock/ # TimeClockPanel
+├── clients/ # ClientForm
+├── businesses/ # BusinessForm
+├── invoices/ # LineItemEditor
+├── AuthServerPicker, AccountSwitcher, TopChrome, AppLockOverlay, Logo, …
+```
+
+## Native config
+
+**`app.json`**
+
+- `scheme`: `beenvoice`
+- `bundleIdentifier`: `com.beenvoice.app`
+- Plugins: dev-client, router, secure-store, widgets, local-authentication
+- iOS Face ID usage strings; custom `beenvoice.icon`
+
+**`eas.json`**
+
+- Profiles: development, preview, production
+- `cli.appVersionSource`: remote
+- User does not require EAS for local `expo run:ios`
+
+**Metro**: port **8082** (`package.json` scripts) to avoid collisions.
+
+## Deep links
+
+| URL | Handler |
+|-----|---------|
+| `beenvoice://reset-password?token=…` | `reset-password.tsx` |
+| `beenvoice://timer` | Live Activity tap → timer tab |
+
+## Development commands
+
+```bash
+bun run ios # build + run simulator, Metro :8082
+bun run start # Metro only (--dev-client --port 8082)
+bunx expo prebuild --platform ios --clean # after native dep / icon changes
+```
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---------|----------------|
+| `PlatformConstants` / runtime not ready | Wrong Metro port, stale native build, or Expo Go instead of dev client |
+| Login twice | Session not migrated — see `finalizeAuthenticatedAccount` |
+| Live Activity blank | Widget UI outside `"widget"` function; rebuild native |
+| API unreachable on device | Use LAN IP in `EXPO_PUBLIC_API_URL`, not `localhost` |
+| Auth fails on device | `BETTER_AUTH_URL` on server must match reachable host |
+
+## Server dependency
+
+Requires beenvoice with:
+
+- `@better-auth/expo` in `src/lib/auth.ts`
+- `trustedOrigins` including `beenvoice://` and `exp://`
+- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`)
+
+See [beenvoice/docs/ARCHITECTURE.md](../../beenvoice/docs/ARCHITECTURE.md).
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..a337c3b
--- /dev/null
+++ b/docs/README.md
@@ -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
diff --git a/lib/app-lock.ts b/lib/app-lock.ts
index bbf49b3..82c7a61 100644
--- a/lib/app-lock.ts
+++ b/lib/app-lock.ts
@@ -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 {
- 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 {
+ 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 {
+ await migrateLegacyLockIfNeeded(accountId);
+ const value = await SecureStore.getItemAsync(lockKey(accountId, "enabled"));
return value === "1";
}
-export async function setAppLockEnabled(enabled: boolean): Promise {
+export async function setAppLockEnabled(accountId: string, enabled: boolean): Promise {
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 {
- return SecureStore.getItemAsync(PIN_KEY);
+export async function getStoredPin(accountId: string): Promise {
+ return SecureStore.getItemAsync(lockKey(accountId, "pin"));
}
-export async function setStoredPin(pin: string): Promise {
- await SecureStore.setItemAsync(PIN_KEY, pin);
+export async function setStoredPin(accountId: string, pin: string): Promise {
+ await SecureStore.setItemAsync(lockKey(accountId, "pin"), pin);
}
-export async function clearStoredPin(): Promise {
- await SecureStore.deleteItemAsync(PIN_KEY);
+export async function clearStoredPin(accountId: string): Promise {
+ await SecureStore.deleteItemAsync(lockKey(accountId, "pin"));
}
-export async function getBiometricEnabled(): Promise {
- const value = await SecureStore.getItemAsync(BIOMETRIC_KEY);
+export async function getBiometricEnabled(accountId: string): Promise {
+ const value = await SecureStore.getItemAsync(lockKey(accountId, "biometric"));
return value === "1";
}
-export async function setBiometricEnabled(enabled: boolean): Promise {
+export async function setBiometricEnabled(accountId: string, enabled: boolean): Promise {
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"));
}
}
diff --git a/lib/auth-storage.ts b/lib/auth-storage.ts
new file mode 100644
index 0000000..cd0b2ed
--- /dev/null
+++ b/lib/auth-storage.ts
@@ -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 {
+ 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 {
+ 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;
+}): Promise {
+ 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,
+ });
+}
diff --git a/lib/form-validation.ts b/lib/form-validation.ts
new file mode 100644
index 0000000..63eef9d
--- /dev/null
+++ b/lib/form-validation.ts
@@ -0,0 +1,66 @@
+import { useCallback, useState } from "react";
+
+export function useFieldVisibility() {
+ const [touched, setTouched] = useState>({});
+ 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;
+}
diff --git a/lib/secure-store-keys.ts b/lib/secure-store-keys.ts
new file mode 100644
index 0000000..f99c56f
--- /dev/null
+++ b/lib/secure-store-keys.ts
@@ -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, "_");
+}
diff --git a/lib/server-mode.ts b/lib/server-mode.ts
new file mode 100644
index 0000000..9df9ab5
--- /dev/null
+++ b/lib/server-mode.ts
@@ -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);
+}
diff --git a/lib/time-clock-live-activity.ts b/lib/time-clock-live-activity.ts
index e3ca3a3..6eba195 100644
--- a/lib/time-clock-live-activity.ts
+++ b/lib/time-clock-live-activity.ts
@@ -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;
}
}
diff --git a/lib/time-clock-live-activity.types.ts b/lib/time-clock-live-activity.types.ts
index b79d2a6..008b6a4 100644
--- a/lib/time-clock-live-activity.types.ts
+++ b/lib/time-clock-live-activity.types.ts
@@ -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;
};
diff --git a/lib/time-clock.ts b/lib/time-clock.ts
index 3666665..2f0feda 100644
--- a/lib/time-clock.ts
+++ b/lib/time-clock.ts
@@ -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);
diff --git a/lib/widget-brand-assets.ts b/lib/widget-brand-assets.ts
deleted file mode 100644
index b2d3c0a..0000000
--- a/lib/widget-brand-assets.ts
+++ /dev/null
@@ -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;
-}
diff --git a/widgets/TimeClockActivity.tsx b/widgets/TimeClockActivity.tsx
index 5c5c556..94e91bf 100644
--- a/widgets/TimeClockActivity.tsx
+++ b/widgets/TimeClockActivity.tsx
@@ -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 (
-
- {value}
-
- );
-}
-
-function BrandMark({ uri, size }: { uri?: string; size: number }) {
- if (uri) {
- return ;
- }
-
- return ;
-}
-
-function BrandLogo({ uri, height = 18 }: { uri?: string; height?: number }) {
- if (uri) {
- return ;
- }
-
- return (
-
- beenvoice
-
- );
-}
-
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: (
-
-
-
+
+
+ beenvoice
+ {props.elapsedShort}
),
- compactLeading: ,
- compactTrailing: ,
- minimal: ,
+ bannerSmall: (
+
+
+ beenvoice
+ {props.elapsedShort}
+
+ ),
+ compactLeading: (
+
+ ),
+ compactTrailing: {props.elapsedShort},
+ minimal: (
+
+ ),
expandedLeading: (
-
-
-
- {props.clockTime}
-
-
- ),
- expandedTrailing: (
-
-
- elapsed
-
- ),
- expandedBottom: (
-
-
- {title}
-
- {subtitle ? (
- {subtitle}
- ) : null}
-
-
- total
-
-
+
),
+ expandedTrailing: {props.elapsedShort},
+ expandedBottom: {detailLine},
};
}