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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
[
|
||||
"expo-widgets",
|
||||
{
|
||||
"groupIdentifier": "group.com.beenvoice.app"
|
||||
"groupIdentifier": "group.com.beenvoice.app",
|
||||
"bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget"
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
+2
-2
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 }]}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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%",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -145,7 +145,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 20,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
textAlign: "center",
|
||||
letterSpacing: 6,
|
||||
},
|
||||
error: {
|
||||
fontSize: 13,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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
@@ -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 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) {
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "_");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user