Compare commits

..

10 Commits

Author SHA1 Message Date
soconnor e17c4c6854 Sync ExpoWidgetsTarget version and build number with the main app on prebuild.
Fixes App Store Connect warnings when the widget extension CFBundle version
does not match the containing application.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 01:12:19 -04:00
soconnor 355b14faef Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing
fixes for distribution export. Add mobile invoice PDF preview, compact line
items, and more reliable shortcut deep-link handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 01:08:20 -04:00
soconnor 06bc91ac13 Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:17 -04:00
soconnor 0b2d65a4e9 Add Authentik sign-in, fix tab scroll insets, and polish multi-account auth.
Mobile app detects SSO per server, supports OAuth sign-in, and preserves saved
sessions when adding accounts. Tab screens get proper chrome layout and tab-bar
clearance with scrollable page headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 02:27:31 -04:00
soconnor 3daf123399 Add App Store Connect submission guide with copy and privacy answers.
Document metadata, descriptions, review notes, demo credentials, and pre-submission checklist for the iOS release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:34:44 -04:00
soconnor 32ffe782ea Fix Live Activity lock screen rendering and polish multi-account auth.
Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:23:36 -04:00
soconnor e6ea3d7c5d Link Expo project to EAS for TestFlight builds.
Register the app with EAS, including the Live Activity widget extension and biometric permissions needed for production iOS builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:51:20 -04:00
soconnor d3b73464e4 Polish Live Activity branding and add EAS build config.
Use brand mark and wordmark images in the time clock Live Activity, migrate file copies to the modern expo-file-system File API, and add eas.json for TestFlight production builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:39:01 -04:00
soconnor 6d2711e36e Polish mobile app for App Store review and expand CRUD.
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:14:58 -04:00
soconnor 14c880123c Add beenvoice mobile companion app with full dark mode support.
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 22:36:37 -04:00
154 changed files with 15952 additions and 7854 deletions
+4
View File
@@ -0,0 +1,4 @@
# beenvoice API base URL (no trailing slash)
# Omit or leave unset in production builds — app defaults to https://beenvoice.soconnor.dev
# Local dev on physical iPhone: use your Mac's LAN IP, e.g. http://192.168.1.42:3000
EXPO_PUBLIC_API_URL=http://localhost:3000
+3
View File
@@ -17,6 +17,8 @@ expo-env.d.ts
*.p12 *.p12
*.key *.key
*.mobileprovision *.mobileprovision
.ios-release.env
dist/ios-release/
# Metro # Metro
.metro-health-check* .metro-health-check*
@@ -31,6 +33,7 @@ yarn-error.*
*.pem *.pem
# local env files # local env files
.env
.env*.local .env*.local
# typescript # typescript
+28
View File
@@ -0,0 +1,28 @@
# Copy to .ios-release.env and fill in (file is gitignored).
# Used by: bun run ios:release
# Apple Developer team ID (10 chars, Membership details in developer.apple.com)
APPLE_TEAM_ID=
# Before export: create an Apple Distribution cert in Xcode
# (Settings → Accounts → your team → Manage Certificates → + → Apple Distribution)
#
# If export fails with "profile doesn't include signing certificate", regenerate App Store
# profiles at developer.apple.com for com.beenvoice.app and com.beenvoice.app.ExpoWidgetsTarget,
# then re-run the full release (not --export-only).
# Production API baked into the JS bundle (App Store / TestFlight)
EXPO_PUBLIC_API_URL=https://beenvoice.soconnor.dev
# App Store Connect API key (Users and Access → Integrations → App Store Connect API)
# Create a key with Developer role. Download the .p8 once — Apple won't show it again.
APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID=
# Path to AuthKey_XXXXXX.p8 (keep outside the repo or in a secrets folder)
APP_STORE_CONNECT_API_KEY_PATH=
# Optional: auto-increment CFBundleVersion before each archive (agvtool)
IOS_BUMP_BUILD=1
# Optional: skip `expo prebuild` when native project is already up to date
# IOS_SKIP_PREBUILD=1
+32 -2
View File
@@ -1,3 +1,33 @@
# Expo HAS CHANGED # beenvoice-app — agent notes
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code. Expo SDK **56**. Read [Expo v56 docs](https://docs.expo.dev/versions/v56.0.0/) before changing native config.
## Read first
- [README.md](./README.md) — setup and run
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — routing, auth, accounts, tRPC, widgets
## Conventions
- **Package manager**: Bun only
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice/src/*`)
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
- **Widgets**: all Live Activity UI must be inside the `"widget"` function in `widgets/TimeClockActivity.tsx`
- **Metro**: port 8082; dev client required (not Expo Go)
## Key files
| Concern | Path |
|---------|------|
| Root providers | `app/_layout.tsx` |
| Multi-account | `contexts/AccountsContext.tsx`, `lib/accounts.ts` |
| Session migration | `lib/auth-storage.ts` |
| tRPC | `lib/trpc.tsx` |
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
| Time clock | `components/time-clock/TimeClockPanel.tsx` |
## Server repo
Sibling `../beenvoice` — run `bun run dev` on :3000 before mobile dev.
+143
View File
@@ -0,0 +1,143 @@
# beenvoice Mobile
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 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
```bash
cd beenvoice-app
bun install
cp .env.example .env
```
`.env`:
```env
# Simulator
EXPO_PUBLIC_API_URL=http://localhost:3000
# Physical iPhone — Mac LAN IP
EXPO_PUBLIC_API_URL=http://192.168.1.42:3000
```
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
cd ../beenvoice && bun run dev
# Terminal 2 — mobile (builds native app if needed)
cd beenvoice-app && bun run ios
```
Metro uses port **8082** (avoids other Expo projects on 8081).
Metro only (app already installed):
```bash
bun run start -- --clear
```
Open the **beenvoice** dev build on the simulator — not Expo Go.
### After native changes
Icon (`assets/beenvoice.icon`), widgets, or new native modules:
```bash
bunx expo prebuild --platform ios --clean
bun run ios
```
## Features
| 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 & Shortcuts
| URL | Action |
|-----|--------|
| `beenvoice://reset-password?token=…` | Reset password |
| `beenvoice://timer` | Open time clock |
| `beenvoice://shortcuts/clock-in` | Clock in (last client) |
| `beenvoice://shortcuts/clock-in?title=…` | Clock in with title |
| `beenvoice://shortcuts/clock-out` | Clock out running timer |
**iOS Shortcuts / Siri** (requires native rebuild: `bunx expo prebuild --platform ios && bun run ios`):
- **Clock In** — starts the timer with your last client
- **Clock Out** — stops the running timer
- **Open Time Clock** — opens the timer tab
1. Install a fresh build on a physical iPhone (iOS 16+).
2. Open the app once while signed in (registers shortcuts with the system).
3. Shortcuts app → search **beenvoice** → add actions, or say “Hey Siri, clock in with beenvoice”.
4. Pick a client once on the Timer tab before the first clock-in shortcut.
**Test deep links:**
```bash
xcrun simctl openurl booted "beenvoice://shortcuts/clock-in"
xcrun simctl openurl booted "beenvoice://shortcuts/clock-out"
xcrun simctl openurl booted "beenvoice://timer"
```
## 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)
+84 -9
View File
@@ -1,23 +1,35 @@
{ {
"expo": { "expo": {
"name": "beenvoice-app", "name": "beenvoice",
"slug": "beenvoice-app", "slug": "beenvoice",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "beenvoiceapp", "scheme": "beenvoice",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
"buildNumber": "7",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#D9D9D9",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png", "backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT"
]
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@@ -25,18 +37,81 @@
"favicon": "./assets/images/favicon.png" "favicon": "./assets/images/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-dev-client",
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "18.0",
"buildReactNativeFromSource": true
}
}
],
"expo-router", "expo-router",
"expo-secure-store",
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"image": "./assets/images/splash-icon.png", "image": "./assets/images/splash-icon.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#D9D9D9"
} }
] ],
[
"expo-widgets",
{
"groupIdentifier": "group.com.beenvoice.app",
"bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget"
}
],
"./plugins/withSyncWidgetVersions.js",
[
"expo-local-authentication",
{
"faceIDPermission": "Unlock beenvoice with Face ID when returning to the app."
}
],
[
"expo-notifications",
{
"icon": "./assets/images/icon.png",
"color": "#18181B",
"sounds": []
}
],
"@react-native-community/datetimepicker",
"./plugins/withAppIntents.js",
"./plugins/withAppStoreSigning.js",
"expo-sharing"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
} },
"extra": {
"router": {
"origin": false
},
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"targetName": "ExpoWidgetsTarget",
"bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.beenvoice.app"
]
}
}
]
}
}
},
"projectId": "cdc31bf6-9c8d-49cd-aa28-7f56cbffd7d2"
}
},
"owner": "soconnor0919"
} }
} }
+81
View File
@@ -0,0 +1,81 @@
import { Platform } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { ShortcutHandler } from "@/components/ShortcutHandler";
import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext";
export default function AppLayout() {
const { colors, isDark } = useAppTheme();
const tintColor = colors.primary;
const labelColor = colors.mutedForeground;
const tabContentStyle = { backgroundColor: colors.background };
const tabBarBlur =
Platform.OS === "ios"
? isDark
? "systemChromeMaterialDark"
: "systemChromeMaterialLight"
: undefined;
return (
<AppLockProvider>
<NativeTabs
tintColor={tintColor}
iconColor={{
default: labelColor,
selected: tintColor,
}}
labelStyle={{ color: labelColor }}
blurEffect={tabBarBlur}
disableTransparentOnScrollEdge
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
>
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
md="grid_view"
/>
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "timer", selected: "timer" }}
md="timer"
/>
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
md="corporate_fare"
/>
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "doc.text", selected: "doc.text.fill" }}
md="description"
/>
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "gearshape", selected: "gearshape.fill" }}
md="settings"
/>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
<InvoiceReminderSync />
<ShortcutHandler />
<AppLockOverlay />
</AppLockProvider>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { Stack } from "expo-router";
import { fonts } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export default function EntitiesLayout() {
const { colors } = useAppTheme();
return (
<Stack
screenOptions={{
contentStyle: { backgroundColor: "transparent" },
headerStyle: { backgroundColor: colors.cardGlass },
headerTitleStyle: {
fontFamily: fonts.heading,
fontSize: 18,
color: colors.foreground,
},
headerShadowVisible: false,
headerTintColor: colors.foreground,
}}
>
<Stack.Screen
name="index"
options={{
title: "Entities",
headerShown: false,
statusBarTranslucent: true,
contentStyle: { flex: 1, backgroundColor: "transparent" },
}}
/>
<Stack.Screen
name="clients/new"
options={{
title: "New client",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="clients/[id]"
options={{
title: "Client",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="clients/edit/[id]"
options={{
title: "Edit client",
headerBackTitle: "Client",
}}
/>
<Stack.Screen
name="businesses/new"
options={{
title: "New business",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="businesses/[id]"
options={{
title: "Business",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="businesses/edit/[id]"
options={{
title: "Edit business",
headerBackTitle: "Business",
}}
/>
</Stack>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function BusinessDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createBusinessDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
const utils = api.useUtils();
const businessQuery = api.businesses.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const setDefault = api.businesses.setDefault.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
if (id) void utils.businesses.getById.invalidate({ id });
Alert.alert("Default updated", "This business is now your default.");
},
onError: (err) => Alert.alert("Could not set default", err.message),
});
if (!id) {
return <LoadingScreen message="Invalid business" />;
}
if (businessQuery.isLoading) {
return <LoadingScreen message="Loading business…" />;
}
const business = businessQuery.data;
if (!business) {
return <LoadingScreen message="Business not found" />;
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior="automatic"
scrollIndicatorInsets={{ bottom: scrollPadding }}
>
<View style={styles.hero}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? <Text style={styles.badge}>Default</Text> : null}
</View>
{business.nickname ? <Text style={styles.meta}>{business.nickname}</Text> : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
{business.phone ? <Text style={styles.meta}>{business.phone}</Text> : null}
{business.website ? <Text style={styles.meta}>{business.website}</Text> : null}
</View>
<Card title="Details">
{business.taxId ? (
<DetailRow label="Tax ID" value={business.taxId} />
) : null}
<DetailRow
label="Email sending"
value={business.resendDomain ? "Configured" : "Not configured"}
/>
</Card>
{(business.addressLine1 || business.city || business.state) && (
<Card title="Address">
{business.addressLine1 ? (
<Text style={styles.body}>{business.addressLine1}</Text>
) : null}
{business.addressLine2 ? (
<Text style={styles.body}>{business.addressLine2}</Text>
) : null}
{(business.city || business.state || business.postalCode) && (
<Text style={styles.body}>
{[business.city, business.state, business.postalCode].filter(Boolean).join(", ")}
</Text>
)}
{business.country ? <Text style={styles.body}>{business.country}</Text> : null}
</Card>
)}
<View style={styles.actions}>
<Button
title="Edit business"
onPress={() => router.push(`/(app)/entities/businesses/edit/${business.id}`)}
/>
{!business.isDefault ? (
<Button
title="Set as default"
variant="secondary"
loading={setDefault.isPending}
onPress={() => setDefault.mutate({ id: business.id })}
/>
) : null}
</View>
</ScrollView>
</AppBackground>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 4,
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
const createBusinessDetailStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
nameRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
name: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
badge: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.primary,
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
overflow: "hidden",
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
body: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.foreground,
lineHeight: 20,
},
actions: {
gap: spacing.sm,
},
});
@@ -0,0 +1,32 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { BusinessForm } from "@/components/businesses/BusinessForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function EditBusinessScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Business" }} />
<BusinessForm
mode="edit"
businessId={id}
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Saved", "Business updated", [
{ text: "OK", onPress: () => router.back() },
]);
}}
onDeleted={() => {
Alert.alert("Deleted", "Business removed", [
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
]);
}}
/>
</AppBackground>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { router, Stack } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { BusinessForm } from "@/components/businesses/BusinessForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function NewBusinessScreen() {
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<BusinessForm
mode="create"
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Business created", "Your business has been saved.", [
{ text: "OK", onPress: () => router.back() },
]);
}}
/>
</AppBackground>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function ClientDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createClientDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
const clientQuery = api.clients.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
if (!id) {
return <LoadingScreen message="Invalid client" />;
}
if (clientQuery.isLoading) {
return <LoadingScreen message="Loading client…" />;
}
const client = clientQuery.data;
if (!client) {
return <LoadingScreen message="Client not found" />;
}
const invoices = client.invoices ?? [];
const totalInvoiced = invoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
const currency = client.currency ?? "USD";
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior="automatic"
scrollIndicatorInsets={{ bottom: scrollPadding }}
>
<View style={styles.hero}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? <Text style={styles.meta}>{client.email}</Text> : null}
{client.phone ? <Text style={styles.meta}>{client.phone}</Text> : null}
</View>
<Card title="Summary">
<DetailRow label="Total invoiced" value={formatCurrency(totalInvoiced, currency)} />
<DetailRow label="Invoices" value={String(invoices.length)} />
{client.defaultHourlyRate != null ? (
<DetailRow
label="Default rate"
value={`${formatCurrency(client.defaultHourlyRate, currency)}/hr`}
/>
) : null}
</Card>
{(client.addressLine1 || client.city || client.state) && (
<Card title="Address">
{client.addressLine1 ? <Text style={styles.body}>{client.addressLine1}</Text> : null}
{client.addressLine2 ? <Text style={styles.body}>{client.addressLine2}</Text> : null}
{(client.city || client.state || client.postalCode) && (
<Text style={styles.body}>
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
</Text>
)}
{client.country ? <Text style={styles.body}>{client.country}</Text> : null}
</Card>
)}
<Card title="Invoices">
{invoices.length === 0 ? (
<Text style={styles.muted}>No invoices for this client yet.</Text>
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
style={styles.invoiceRow}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
>
<View style={styles.invoiceMeta}>
<Text style={styles.invoiceTitle}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.muted}>Due {formatDate(invoice.dueDate)}</Text>
</View>
<View style={styles.invoiceRight}>
<Text style={styles.invoiceAmount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<StatusBadge status={status} />
</View>
</Pressable>
);
})
)}
</Card>
<View style={styles.actions}>
<Button
title="Edit client"
onPress={() => router.push(`/(app)/entities/clients/edit/${client.id}`)}
/>
<Button
title="New invoice"
variant="secondary"
onPress={() => router.push("/(app)/invoices/new")}
/>
</View>
</ScrollView>
</AppBackground>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 4,
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
const createClientDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
name: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
body: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.foreground,
lineHeight: 20,
},
muted: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
},
invoiceRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
invoiceMeta: {
flex: 1,
gap: 2,
},
invoiceTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
invoiceRight: {
alignItems: "flex-end",
gap: spacing.sm,
},
invoiceAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
actions: {
gap: spacing.sm,
},
});
+32
View File
@@ -0,0 +1,32 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { ClientForm } from "@/components/clients/ClientForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function EditClientScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Client" }} />
<ClientForm
mode="edit"
clientId={id}
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Saved", "Client updated", [
{ text: "OK", onPress: () => router.back() },
]);
}}
onDeleted={() => {
Alert.alert("Deleted", "Client removed", [
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
]);
}}
/>
</AppBackground>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { router, Stack } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { ClientForm } from "@/components/clients/ClientForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function NewClientScreen() {
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<ClientForm
mode="create"
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Client created", "Your client has been saved.", [
{ text: "OK", onPress: () => router.back() },
]);
}}
/>
</AppBackground>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type EntityTab = "clients" | "businesses";
const tabs: Array<{ label: string; value: EntityTab }> = [
{ label: "Clients", value: "clients" },
{ label: "Businesses", value: "businesses" },
];
export default function EntitiesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createEntitiesStyles);
const [tab, setTab] = useState<EntityTab>("clients");
const clientsQuery = api.clients.getAll.useQuery();
const businessesQuery = api.businesses.getAll.useQuery();
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading =
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
if (isLoading) {
return <LoadingScreen message="Loading…" />;
}
if (activeQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load {tab}</Text>
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
</View>
</TabPage>
</AppBackground>
);
}
const clients = clientsQuery.data ?? [];
const businesses = businessesQuery.data ?? [];
function refresh() {
if (tab === "clients") void clientsQuery.refetch();
else void businessesQuery.refetch();
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader
title="Entities"
subtitle="Clients you bill and businesses you send from"
/>
}
refreshControl={
<RefreshControl
refreshing={activeQuery.isRefetching}
onRefresh={refresh}
tintColor={colors.primary}
/>
}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabs}
>
{tabs.map((item) => (
<FilterChip
key={item.value}
label={item.label}
active={tab === item.value}
onPress={() => setTab(item.value)}
/>
))}
</ScrollView>
{tab === "clients" ? (
clients.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No clients yet</Text>
<Text style={styles.emptyText}>
Add a client to start creating invoices.
</Text>
</View>
) : (
clients.map((client) => (
<Pressable
key={client.id}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</Pressable>
))
)
) : businesses.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No businesses yet</Text>
<Text style={styles.emptyText}>
Add your business profile for invoices and email sending.
</Text>
</View>
) : (
businesses.map((business) => (
<Pressable
key={business.id}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</Pressable>
))
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
onPress={() =>
router.push(
tab === "clients"
? "/(app)/entities/clients/new"
: "/(app)/entities/businesses/new",
)
}
/>
</TabPage>
</AppBackground>
);
}
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
tabScroll: {
flexGrow: 0,
marginBottom: spacing.sm,
},
tabs: {
gap: spacing.sm,
paddingRight: spacing.md,
},
card: {},
cardInner: {
padding: spacing.md,
gap: 4,
},
nameRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
name: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
badge: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.primary,
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
overflow: "hidden",
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
empty: {
padding: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
emptyTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
emptyText: {
textAlign: "center",
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+394
View File
@@ -0,0 +1,394 @@
import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { Screen } from "@/components/Screen";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
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, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export default function DashboardScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createDashboardStyles);
const statsQuery = api.dashboard.getStats.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
if (statsQuery.isLoading) {
return <LoadingScreen message="Loading dashboard…" />;
}
if (statsQuery.error) {
return (
<AppBackground>
<Screen>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load dashboard</Text>
<Text style={styles.errorText}>{statsQuery.error.message}</Text>
</View>
</Screen>
</AppBackground>
);
}
const stats = statsQuery.data;
if (!stats) {
return <LoadingScreen message="Loading dashboard…" />;
}
const running = runningQuery.data;
const revenueChange =
stats.revenueChange > 0
? `+${stats.revenueChange.toFixed(0)}% vs last month`
: stats.revenueChange < 0
? `${stats.revenueChange.toFixed(0)}% vs last month`
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.sendReminderDue ?? [];
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader title="Overview" subtitle="Your invoicing at a glance" />
}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void runningQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running ? (
<Pressable onPress={() => router.push("/(app)/timer")}>
<GlassSurface style={styles.runningGlass}>
<View style={styles.runningRow}>
<View style={styles.runningDot} />
<View style={styles.runningMeta}>
<Text style={styles.runningTitle}>
{resolveClockDescription(running.description)}
</Text>
<Text style={styles.runningSub}>
{running.client?.name ?? "No client"}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
</Text>
</View>
<Text style={styles.runningTime}>
{formatElapsedHoursMinutes(runningElapsed)}
</Text>
</View>
</GlassSurface>
</Pressable>
) : null}
{stats.overdueCount > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"}
</Text>
<Text style={styles.alertText}>
Follow up on outstanding payments from the Invoices tab.
</Text>
</View>
</GlassSurface>
) : null}
{sendReminderDue.length > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{sendReminderDue.length} draft{" "}
{sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
</Text>
<Text style={styles.alertText}>
{sendReminderDue
.slice(0, 2)
.map(
(inv) =>
`${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
)
.join(" · ")}
</Text>
</View>
</GlassSurface>
) : null}
<View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button
title="View invoices"
variant="secondary"
onPress={() => router.push("/(app)/invoices")}
/>
</View>
<View style={styles.statsGrid}>
<View style={styles.statCell}>
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
</View>
<View style={styles.statCell}>
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
</View>
<View style={styles.statCell}>
<StatCard
label="Overdue"
value={String(stats.overdueCount)}
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
/>
</View>
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
<StatCard
label="Clients"
value={String(stats.totalClients)}
hint={revenueChange}
/>
</Pressable>
</View>
<Card title="Revenue (6 months)">
<View style={styles.chart}>
{stats.revenueChartData.map((point) => {
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
return (
<View key={point.month} style={styles.chartColumn}>
<View style={styles.chartBarTrack}>
<View style={[styles.chartBar, { height: barHeight }]} />
</View>
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
<Text style={styles.chartValue}>
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
</Text>
</View>
);
})}
</View>
</Card>
<Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
) : (
stats.recentInvoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
style={styles.invoiceRow}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
>
<View style={styles.invoiceMeta}>
<Text style={styles.invoiceTitle}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.invoiceClient}>
{invoice.client?.name ?? "Client"}
</Text>
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
</View>
<View style={styles.invoiceRight}>
<Text style={styles.invoiceAmount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<StatusBadge status={status} />
</View>
</Pressable>
);
})
)}
</Card>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
safe: {
flex: 1,
},
runningGlass: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
},
runningRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.md,
},
runningDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.success,
},
runningMeta: {
flex: 1,
gap: 2,
},
runningTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
runningSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
runningTime: {
fontFamily: fonts.mono,
fontSize: 18,
color: colors.success,
},
alertBanner: {
padding: spacing.md,
gap: 4,
},
alertGlass: {
borderColor: isDark ? "rgba(251, 191, 36, 0.4)" : "#FDE68A",
},
alertTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.warning,
fontSize: 14,
},
alertText: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
},
quickActions: {
flexDirection: "row",
gap: spacing.sm,
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.md,
alignContent: "flex-start",
},
statCell: {
flexGrow: 0,
flexShrink: 0,
flexBasis: "47%",
},
chart: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.xs,
},
chartColumn: {
flex: 1,
alignItems: "center",
gap: 4,
},
chartBarTrack: {
width: "100%",
height: 80,
justifyContent: "flex-end",
alignItems: "center",
},
chartBar: {
width: "70%",
minHeight: 4,
backgroundColor: colors.primary,
borderRadius: radii.sm,
},
chartLabel: {
fontSize: 10,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
chartValue: {
fontSize: 9,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
empty: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
invoiceRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
invoiceMeta: {
flex: 1,
gap: 2,
},
invoiceTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
invoiceClient: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
},
invoiceDate: {
color: colors.mutedForeground,
fontSize: 12,
fontFamily: fonts.body,
},
invoiceRight: {
alignItems: "flex-end",
gap: spacing.sm,
},
invoiceAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+433
View File
@@ -0,0 +1,433 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Update failed", err.message),
});
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
Alert.alert("Invoice sent", data.message);
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
onSuccess: () => {
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
void utils.invoices.getById.invalidate({ id: id ?? "" });
},
onError: (err) => Alert.alert("Could not send reminder", err.message),
});
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (invoiceQuery.error || !invoiceQuery.data) {
return (
<AppBackground>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load invoice</Text>
<Text style={styles.errorText}>
{invoiceQuery.error?.message ?? "Invoice not found"}
</Text>
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
</View>
</AppBackground>
);
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const clientEmail = invoice.client?.email?.trim() ?? "";
const previewInput = useMemo(
() => buildPreviewPdfInputFromInvoice(invoice),
[invoice],
);
function promptSendInvoice() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice.id }),
},
],
);
}
function promptPaymentReminder() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client before sending payment reminders.",
);
return;
}
Alert.alert(
"Send payment reminder",
`Email a payment reminder to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
},
],
);
}
function promptStatusChange(current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
if (current !== "sent" && current !== "overdue") {
options.push({ label: "Mark as sent", status: "sent" });
}
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
if (options.length === 0) return;
Alert.alert("Update status", "Choose a new status", [
...options.map((option) => ({
text: option.label,
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
})),
{ text: "Cancel", style: "cancel" },
]);
}
return (
<AppBackground>
<Stack.Screen
options={{
headerBackTitle: "Invoices",
headerRight: () =>
status !== "paid" ? (
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={promptSendInvoice}
disabled={sendInvoice.isPending}
style={({ pressed }) => pressed && styles.headerPressed}
>
<Text style={[styles.headerAction, { color: colors.primary }]}>
{status === "draft" ? "Send" : "Resend"}
</Text>
</Pressable>
) : null,
}}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card>
<View style={styles.headerRow}>
<View style={styles.headerMeta}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
</View>
<StatusBadge status={status} />
</View>
<Text style={styles.total}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
</Card>
<InvoiceEditorSectionTabs
value={section}
onChange={setSection}
editLabel="Details"
previewLabel="PDF"
/>
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card title="Details">
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} />
{invoice.taxRate > 0 ? (
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
) : null}
{invoice.status === "draft" && invoice.sendReminderAt ? (
<DetailRow
label="Send reminder"
value={
new Date(invoice.sendReminderAt) <= new Date()
? "Due now"
: formatDate(invoice.sendReminderAt)
}
/>
) : null}
</Card>
<Card title="Line items">
{invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
))}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
taxAmount={
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
}
total={formatCurrency(invoice.totalAmount, invoice.currency)}
/>
</Card>
{invoice.notes ? (
<Card title="Notes">
<Text style={styles.notes}>{invoice.notes}</Text>
</Card>
) : null}
<View style={styles.actions}>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
{status === "sent" || status === "overdue" ? (
<Button
title="Send payment reminder"
variant="secondary"
onPress={promptPaymentReminder}
loading={sendPaymentReminder.isPending}
/>
) : null}
<Button
title="Edit invoice"
variant="secondary"
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
/>
<Button
title="Update status"
variant="ghost"
onPress={() => promptStatusChange(status)}
loading={updateStatus.isPending}
/>
<Button
title="Track time to this invoice"
variant="ghost"
onPress={() =>
router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
}
/>
</View>
</>
)}
</ScrollView>
</AppBackground>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: 4,
},
label: {
fontSize: 14,
fontFamily: fonts.body,
},
value: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
});
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
container: {
padding: spacing.md,
gap: spacing.md,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
gap: spacing.md,
},
headerMeta: {
flex: 1,
gap: 4,
},
invoiceNumber: {
fontSize: 22,
lineHeight: 26,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 15,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
total: {
marginTop: spacing.sm,
fontSize: 28,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
lineItem: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
lineMeta: {
flex: 1,
gap: 2,
},
lineDescription: {
fontFamily: fonts.bodyMedium,
color: colors.foreground,
fontSize: 14,
},
lineSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
lineAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
actions: {
gap: spacing.sm,
},
headerAction: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
headerPressed: {
opacity: 0.65,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.md,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+55
View File
@@ -0,0 +1,55 @@
import { Stack } from "expo-router";
import { fonts } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export default function InvoicesLayout() {
const { colors } = useAppTheme();
return (
<Stack
screenOptions={{
contentStyle: { backgroundColor: "transparent" },
headerStyle: { backgroundColor: colors.cardGlass },
headerTitleStyle: {
fontFamily: fonts.heading,
fontSize: 18,
color: colors.foreground,
},
headerShadowVisible: false,
headerTintColor: colors.foreground,
}}
>
<Stack.Screen
name="index"
options={{
title: "Invoices",
headerShown: false,
statusBarTranslucent: true,
contentStyle: { flex: 1, backgroundColor: "transparent" },
}}
/>
<Stack.Screen
name="new"
options={{
title: "New invoice",
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="[id]"
options={{
title: "Invoice",
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="edit/[id]"
options={{
title: "Edit invoice",
headerBackTitle: "Invoice",
}}
/>
</Stack>
);
}
+415
View File
@@ -0,0 +1,415 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { validateLineItems } from "@/lib/form-validation";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function InvoiceEditScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceEditStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date());
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const invoice = invoiceQuery.data;
if (!invoice) return;
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems(
invoice.items.map((item) => ({
id: item.id,
date: new Date(item.date),
description: item.description,
hours: String(item.hours),
rate: String(item.rate),
})),
);
}, [invoiceQuery.data]);
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.invoices.getAll.invalidate({ status: "draft" });
void utils.dashboard.getStats.invalidate();
Alert.alert("Saved", "Invoice updated", [
{ text: "OK", onPress: () => router.back() },
]);
},
onError: (err) => setError(err.message),
});
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
Alert.alert("Invoice sent", data.message);
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const invoice = invoiceQuery.data;
const isDraft = invoice?.status === "draft";
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
return sum + hours * rate;
}, 0),
[items],
);
const taxRate = invoice?.taxRate ?? 0;
const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
const lineItemsError = isDraft ? validateLineItems(items) : null;
const canSave = isDraft ? !lineItemsError : true;
const previewInput = useMemo(() => {
if (!invoice) return null;
return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix,
businessId: invoice.businessId,
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate,
status: invoice.status as "draft" | "sent" | "paid",
notes,
taxRate,
currency,
items,
});
}, [invoice, dueDate, notes, taxRate, currency, items]);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
return <LoadingScreen message="Invoice not found" />;
}
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
function promptSendInvoice() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
},
],
);
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
}
async function handleSave() {
if (!canSave) return;
setError(null);
if (isDraft && sendReminderAt) {
const granted = await ensureNotificationPermissions();
if (!granted) {
Alert.alert(
"Notifications disabled",
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
);
}
}
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours: Number(item.hours),
rate: Number(item.rate),
});
}
updateInvoice.mutate({
id,
notes,
dueDate,
sendReminderAt,
...(isDraft
? {
items: parsedItems,
}
: {}),
});
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
</View>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
{isDraft ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={setSendReminderAt}
/>
{sendReminderAt ? (
<Pressable onPress={() => setSendReminderAt(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
</Text>
) : (
<LineItemsTableHeader />
)}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined}
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</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}
disabled={!canSave}
onPress={handleSave}
/>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
variant="secondary"
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
</View>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
invoiceNumber: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
lockedHint: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
actions: {
gap: spacing.sm,
},
});
+260
View File
@@ -0,0 +1,260 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { api } from "@/lib/trpc";
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
{ label: "All", value: "all" },
{ label: "Draft", value: "draft" },
{ label: "Sent", value: "sent" },
{ label: "Paid", value: "paid" },
{ label: "Overdue", value: "overdue" },
];
export default function InvoicesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoicesStyles);
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
const utils = api.useUtils();
const invoicesQuery = api.invoices.getAll.useQuery();
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
utils.invoices.getAll.invalidate();
utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Update failed", err.message),
});
if (invoicesQuery.isLoading) {
return <LoadingScreen message="Loading invoices…" />;
}
if (invoicesQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load invoices</Text>
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text>
</View>
</TabPage>
</AppBackground>
);
}
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
if (filter === "all") return true;
return getInvoiceStatus(invoice) === filter;
});
function promptStatusChange(invoiceId: string, current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
if (current !== "sent" && current !== "overdue") {
options.push({ label: "Mark as sent", status: "sent" });
}
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
if (options.length === 0) return;
Alert.alert("Update status", "Choose a new status", [
...options.map((option) => ({
text: option.label,
onPress: () => {
updateStatus.mutate({ id: invoiceId, status: option.status });
},
})),
{ text: "Cancel", style: "cancel" },
]);
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
}
refreshControl={
<RefreshControl
refreshing={invoicesQuery.isRefetching}
onRefresh={() => invoicesQuery.refetch()}
tintColor={colors.primary}
/>
}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filterScroll}
contentContainerStyle={styles.filters}
>
{filters.map((item) => (
<FilterChip
key={item.label}
label={item.label}
active={filter === item.value}
onPress={() => setFilter(item.value)}
/>
))}
</ScrollView>
{invoices.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No invoices found</Text>
<Text style={styles.emptyText}>
Tap + to create your first invoice, or pull to refresh.
</Text>
</View>
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View>
<Text style={styles.amount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
</View>
<View style={styles.cardBottom}>
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
<StatusBadge status={status} />
</View>
</View>
</GlassSurface>
</Pressable>
);
})
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel="Create invoice"
onPress={() => router.push("/(app)/invoices/new")}
/>
</TabPage>
</AppBackground>
);
}
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
filterScroll: {
flexGrow: 0,
marginBottom: spacing.sm,
},
filters: {
gap: spacing.sm,
paddingRight: spacing.md,
},
card: {},
cardInner: {
padding: spacing.md,
gap: spacing.md,
},
cardTop: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
},
cardMeta: {
flex: 1,
gap: 4,
},
invoiceNumber: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
clientName: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
},
amount: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
cardBottom: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
date: {
color: colors.mutedForeground,
fontSize: 13,
fontFamily: fonts.body,
},
empty: {
padding: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
emptyTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
emptyText: {
textAlign: "center",
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+366
View File
@@ -0,0 +1,366 @@
import { router, Stack } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
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";
import { api } from "@/lib/trpc";
export default function NewInvoiceScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createNewInvoiceStyles);
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const clientsQuery = api.clients.getAll.useQuery();
const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date());
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>([
{
date: new Date(),
description: "",
hours: "1",
rate: "0",
},
]);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [error, setError] = useState<string | null>(null);
const clientOptions = useMemo(
() =>
(clientsQuery.data ?? []).map((client) => ({
label: client.name,
value: client.id,
})),
[clientsQuery.data],
);
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? "USD";
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
setItems((prev) =>
prev.map((item, index) =>
index === 0 && (item.rate === "0" || item.rate === "")
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
: item,
),
);
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
const createInvoice = api.invoices.create.useMutation({
onSuccess: (invoice) => {
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
Alert.alert("Invoice created", "Your draft invoice is ready.", [
{
text: "View invoice",
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
},
]);
},
onError: (err) => setError(err.message),
});
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
return sum + hours * rate;
}, 0),
[items],
);
const parsedTaxRate = Number(taxRate) || 0;
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const previewInput = useMemo(
() =>
buildPreviewPdfInput({
invoiceNumber,
clientId,
issueDate,
dueDate,
taxRate: parsedTaxRate,
currency,
notes,
items,
}),
[invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
);
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…" />;
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
}
function handleCreate() {
if (!canCreate) return;
setError(null);
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours: Number(item.hours),
rate: Number(item.rate),
});
}
createInvoice.mutate({
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
dueDate,
notes,
taxRate: Number(taxRate),
currency,
items: parsedItems,
status: "draft",
});
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card title="Details">
{clientOptions.length === 0 ? (
<View style={styles.noClients}>
<Text style={styles.noClientsText}>
Add a client before creating an invoice.
</Text>
<Button
title="Add client"
variant="secondary"
onPress={() => router.push("/(app)/entities/clients/new")}
/>
</View>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
onValueChange={setClientId}
/>
)}
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
autoCapitalize="characters"
required
error={invoiceNumberError}
/>
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
setIssueDate(date);
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
}}
/>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={setTaxRate}
keyboardType="decimal-pad"
error={taxError}
/>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items">
<LineItemsTableHeader />
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</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={!canCreate}
onPress={handleCreate}
/>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
noClients: {
gap: spacing.sm,
},
noClientsText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
lineHeight: 20,
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
+529
View File
@@ -0,0 +1,529 @@
import { useState } from "react";
import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground";
import { InstanceUrlField } from "@/components/InstanceUrlField";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PinPrompt } from "@/components/PinPrompt";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
export default function SettingsScreen() {
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
apiUrl,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const { colors, colorMode, setColorMode } = useAppTheme();
const switchProps = {
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
ios_backgroundColor: colors.switchIosBackground,
};
const {
enabled: lockEnabled,
biometricEnabled,
biometricAvailable,
biometricLabel,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
} = useAppLock();
const profileQuery = api.settings.getProfile.useQuery();
const [pinPrompt, setPinPrompt] = useState<
| { mode: "create" }
| { mode: "confirm-disable" }
| { mode: "change-current" }
| { mode: "change-next" }
| null
>(null);
const [pendingPin, setPendingPin] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [refreshingAccounts, setRefreshingAccounts] = useState(false);
async function handleRefreshAccounts() {
setRefreshingAccounts(true);
try {
await refreshAccounts();
await profileQuery.refetch();
} finally {
setRefreshingAccounts(false);
}
}
function handleRemoveAccount(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
await finishAccountRemoval({
result,
clearActiveAccount,
signOut: () => authClient.signOut(),
});
},
);
}
async function handleSignOut() {
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}
function confirmSignOut() {
Alert.alert("Sign out", "Sign out of this account on this device?", [
{ text: "Cancel", style: "cancel" },
{ text: "Sign out", style: "destructive", onPress: () => void handleSignOut() },
]);
}
function confirmInstanceChange() {
Alert.alert(
"Server updated",
"You may need to sign in again if you switched to a different instance.",
[{ text: "OK" }],
);
}
function handleLockToggle(next: boolean) {
if (next) {
setPinPrompt({ mode: "create" });
return;
}
setPinPrompt({ mode: "confirm-disable" });
}
function handleChangePin() {
setPendingPin("");
setPinPrompt({ mode: "change-current" });
}
function handleBiometricToggle(next: boolean) {
void setUseBiometric(next);
}
async function handlePinPromptSubmit(pin: string) {
if (pinPrompt?.mode === "create") {
try {
await enableLock(pin);
setPinPrompt(null);
} catch (err) {
Alert.alert("Could not enable lock", err instanceof Error ? err.message : "Try again");
}
return;
}
if (pinPrompt?.mode === "confirm-disable") {
const success = await disableLock(pin);
if (!success) {
Alert.alert("Incorrect PIN", "Could not disable app lock.");
return;
}
setPinPrompt(null);
return;
}
if (pinPrompt?.mode === "change-current") {
setPendingPin(pin);
setPinPrompt({ mode: "change-next" });
return;
}
if (pinPrompt?.mode === "change-next") {
const success = await changePin(pendingPin, pin);
if (!success) {
Alert.alert("Could not change PIN", "Check your current PIN and try again.");
return;
}
setPendingPin("");
setPinPrompt(null);
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
}
}
if (profileQuery.isLoading) {
return <LoadingScreen message="Loading profile…" />;
}
const profile = profileQuery.data;
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
return (
<AppBackground>
<TabPage>
<PinPrompt
visible={pinPrompt !== null}
title={
pinPrompt?.mode === "create"
? "Create PIN"
: pinPrompt?.mode === "confirm-disable"
? "Disable app lock"
: pinPrompt?.mode === "change-current"
? "Current PIN"
: "New PIN"
}
message={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
? "Choose a 46 digit PIN."
: pinPrompt?.mode === "confirm-disable"
? "Enter your PIN to turn off app lock."
: "Enter your current PIN."
}
confirmLabel={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next" ? "Save" : "Continue"
}
requireConfirmation={pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"}
onCancel={() => {
setPendingPin("");
setPinPrompt(null);
}}
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
/>
<TabScrollView
header={
<PageHeader title="Settings" subtitle="Account and app preferences" />
}
keyboardShouldPersistTaps="handled"
>
<Card title="Account">
<Text style={[styles.name, { color: colors.foreground }]}>
{profile?.name ?? session?.user.name ?? "User"}
</Text>
<Text style={[styles.email, { color: colors.mutedForeground }]}>
{profile?.email ?? session?.user.email}
</Text>
{profile?.role ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Role: {profile.role}
</Text>
) : null}
</Card>
<Card title="Accounts">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<View
key={account.id}
style={[
styles.accountRow,
{
borderColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
style={({ pressed }) => [styles.accountMain, pressed && styles.pressed]}
>
<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 }]}>
{account.instanceUrl.replace(/^https?:\/\//, "")}
</Text>
</View>
{isActive ? (
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
) : null}
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemoveAccount(account.id, account.name || account.email)
}
style={({ pressed }) => [styles.removeButton, pressed && styles.pressed]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
);
})}
<Button
title={refreshingAccounts ? "Refreshing…" : "Refresh accounts"}
variant="secondary"
disabled={refreshingAccounts}
onPress={() => void handleRefreshAccounts()}
/>
<Button
title="Add another account"
variant="secondary"
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
/>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Tap an account to switch. Refresh updates names from saved sign-in data.
</Text>
</Card>
<Card title="Security">
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text style={[styles.settingTitle, { color: colors.foreground }]}>App lock</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Require a PIN when reopening the app
</Text>
</View>
<Switch
value={lockEnabled}
onValueChange={handleLockToggle}
{...switchProps}
/>
</View>
{lockEnabled && biometricAvailable ? (
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text style={[styles.settingTitle, { color: colors.foreground }]}>
{biometricLabel}
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Unlock with {biometricLabel.toLowerCase()} when available
</Text>
</View>
<Switch
value={biometricEnabled}
onValueChange={handleBiometricToggle}
{...switchProps}
/>
</View>
) : null}
{lockEnabled ? (
<>
<Button title="Change PIN" variant="secondary" onPress={handleChangePin} />
<Button title="Lock now" variant="secondary" onPress={lock} />
</>
) : null}
</Card>
<Card title="Appearance">
<View style={styles.themeRow}>
{THEME_OPTIONS.map((option) => {
const selected = colorMode === option.value;
return (
<Pressable
key={option.value}
accessibilityRole="button"
onPress={() => void setColorMode(option.value)}
style={[
styles.themeChip,
{
borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected ? colors.muted : "transparent",
},
]}
>
<Text
style={[
styles.themeChipLabel,
{ color: selected ? colors.foreground : colors.mutedForeground },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</Card>
<Card title="App">
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
</View>
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"}
</Text>
</View>
</Card>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }}
onPress={() => setShowAdvanced((open) => !open)}
style={styles.advancedToggle}
>
<Text style={[styles.advancedLabel, { color: colors.mutedForeground }]}>Advanced</Text>
<Ionicons
name={showAdvanced ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{showAdvanced ? (
<Card title="Server instance">
<InstanceUrlField onSaved={confirmInstanceChange} />
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
Connected to {activeAccount?.instanceUrl ?? apiUrl}
</Text>
</Card>
) : null}
<View style={styles.actions}>
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
</View>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
name: {
fontSize: 20,
fontFamily: fonts.heading,
},
email: {
fontSize: 15,
fontFamily: fonts.body,
},
meta: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
currentServer: {
fontSize: 12,
fontFamily: fonts.mono,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
advancedLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
appRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
appValue: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
accountRow: {
borderWidth: 1,
borderRadius: 12,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.md,
},
removeButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 36,
minHeight: 36,
},
pressed: {
opacity: 0.92,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountName: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
accountSub: {
fontSize: 12,
fontFamily: fonts.body,
},
activeBadge: {
fontSize: 12,
fontFamily: fonts.bodySemiBold,
},
themeRow: {
flexDirection: "row",
gap: spacing.sm,
},
themeChip: {
flex: 1,
borderWidth: 1,
borderRadius: 10,
minHeight: 40,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.sm,
},
themeChipLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
lineHeight: 18,
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
},
settingRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
},
settingCopy: {
flex: 1,
gap: 2,
},
settingTitle: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
actions: {
marginTop: spacing.sm,
},
});
+33
View File
@@ -0,0 +1,33 @@
import { useLocalSearchParams } from "expo-router";
import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
export default function TimerScreen() {
const params = useLocalSearchParams<{
clientId?: string | string[];
invoiceId?: string | string[];
}>();
const clientId = Array.isArray(params.clientId) ? params.clientId[0] : params.clientId;
const invoiceId = Array.isArray(params.invoiceId) ? params.invoiceId[0] : params.invoiceId;
return (
<AppBackground>
<TabPage>
<TimeClockPanel
header={
<PageHeader
title="Time clock"
subtitle="Track billable hours and link them to invoices"
/>
}
defaultClientId={clientId ?? ""}
defaultInvoiceId={invoiceId ?? ""}
compact
/>
</TabPage>
</AppBackground>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
/>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { router } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} 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";
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();
const [email, setEmail] = useState("");
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);
try {
const result = await requestPasswordReset(email.trim());
setMessage(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView contentContainerStyle={styles.container}>
<Pressable onPress={() => router.back()}>
<Text style={[styles.back, { color: colors.mutedForeground }]}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="md" />
<HeadingText style={styles.title}>Reset password</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Enter your email and we&apos;ll send reset instructions if an account exists.
</Text>
</View>
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
{message ? (
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
) : null}
<Button
title="Send reset link"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
<Button
title="Have a reset token?"
variant="ghost"
onPress={() => router.push("/(auth)/reset-password")}
/>
</View>
</Card>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
padding: spacing.lg,
paddingBottom: spacing.md,
gap: spacing.md,
justifyContent: "center",
},
back: {
fontFamily: fonts.bodyMedium,
fontSize: 16,
marginBottom: spacing.sm,
},
card: { gap: spacing.lg },
header: { gap: spacing.sm },
title: { fontSize: 28 },
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
form: { gap: spacing.md },
error: {
fontSize: 14,
fontFamily: fonts.body,
},
success: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function AuthIndex() {
return <Redirect href="/(auth)/sign-in" />;
}
+246
View File
@@ -0,0 +1,246 @@
import { Link } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} 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";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
export default function RegisterScreen() {
const authClient = useAuthClient();
const { apiUrl, activeAccountId, registerAccount: saveAccount } = useAccounts();
const { colors } = useAppTheme();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
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 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);
try {
await registerAccount({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
password,
});
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
if (signInError) {
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 completeSignInAfterAuth(authClient, {
apiUrl,
activeAccountId,
registerAccount: saveAccount,
});
}
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Create your account</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Get started today
</Text>
</View>
<AuthServerPicker onReadyChange={setServerReady} embedded />
<View style={styles.form}>
<View style={styles.row}>
<View style={styles.half}>
<Input
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}>
<Input
label="Last name"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
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}
disabled={!canRegister}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Already have an account?{" "}
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
Sign in
</Link>
</Text>
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
},
card: {
gap: spacing.lg,
},
header: {
alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
},
form: { gap: spacing.md },
row: { flexDirection: "row", gap: spacing.md },
half: { flex: 1 },
error: {
fontSize: 14,
fontFamily: fonts.body,
},
footer: {
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
},
link: {
fontFamily: fonts.bodySemiBold,
},
});
+204
View File
@@ -0,0 +1,204 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} 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";
import { Input } from "@/components/ui/Input";
import { fonts, radii, spacing } from "@/constants/theme";
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);
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
const [token, setToken] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
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) {
setToken(tokenParam);
}
}, [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);
setLoading(true);
try {
await resetPassword(token.trim(), password);
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Reset failed");
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView contentContainerStyle={styles.container}>
<Pressable onPress={() => router.back()}>
<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>
<Text style={styles.subtitle}>
Paste the reset token from your email, or open the link on this device.
</Text>
</View>
{success ? (
<View style={styles.successBox}>
<Text style={styles.successTitle}>Password updated</Text>
<Text style={styles.successText}>
You can now sign in with your new password.
</Text>
<Button
title="Go to sign in"
onPress={() => router.replace("/(auth)/sign-in")}
/>
</View>
) : (
<View style={styles.form}>
<Input
label="Reset token"
autoCapitalize="none"
value={token}
onChangeText={setToken}
placeholder="Paste token from email"
required
error={tokenError}
/>
<Input
label="New password"
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="At least 8 characters"
required
error={passwordError}
/>
<Input
label="Confirm password"
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repeat password"
required
error={confirmError}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Update password"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
</View>
)}
</Card>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const createResetPasswordStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
padding: spacing.lg,
gap: spacing.md,
justifyContent: "center",
},
back: {
color: colors.mutedForeground,
fontFamily: fonts.bodyMedium,
fontSize: 16,
marginBottom: spacing.sm,
},
card: { gap: spacing.lg },
header: { gap: spacing.sm },
title: { fontSize: 28 },
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
form: { gap: spacing.md },
error: {
color: colors.destructive,
fontSize: 14,
fontFamily: fonts.body,
},
successBox: {
gap: spacing.md,
padding: spacing.lg,
backgroundColor: colors.muted,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.border,
},
successTitle: {
fontSize: 20,
fontFamily: fonts.heading,
color: colors.foreground,
},
successText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+313
View File
@@ -0,0 +1,313 @@
import { Link, router } from "expo-router";
import * as Linking from "expo-linking";
import { useEffect, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
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";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
const authClient = useAuthClient();
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 [authentikEnabled, setAuthentikEnabled] = useState(false);
const [signupsDisabled, setSignupsDisabled] = useState(false);
const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => {
let cancelled = false;
void fetchAuthCapabilities(apiUrl).then((capabilities) => {
if (cancelled) return;
setAuthentikEnabled(capabilities.authentik);
setSignupsDisabled(capabilities.signupsDisabled);
});
return () => {
cancelled = true;
};
}, [apiUrl]);
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 finishSignIn() {
const completed = await completeSignInAfterAuth(authClient, {
apiUrl,
activeAccountId,
registerAccount,
});
if (!completed) {
setError("Signed in but session was not available. Try again.");
}
}
async function handleSignIn() {
markSubmitted();
if (!canSignIn) return;
setError(null);
setLoading(true);
try {
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
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;
}
await finishSignIn();
} finally {
setLoading(false);
}
}
async function handleAuthentikSignIn() {
if (!serverReady) return;
setError(null);
setLoading(true);
try {
const { error: oauthError } = await signInWithAuthentik(
authClient,
Linking.createURL("/"),
);
if (oauthError) {
setError(oauthError.message ?? "Could not sign in with Authentik");
return;
}
await finishSignIn();
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Welcome back</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Sign in to manage invoices on the go
</Text>
</View>
<AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? (
<Text style={[styles.notice, { color: colors.mutedForeground }]}>
New account registration is currently disabled on this server.
</Text>
) : null}
{authentikEnabled ? (
<View style={styles.ssoSection}>
<Button
title="Sign in with Authentik"
variant="secondary"
loading={loading}
disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()}
/>
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}>
or
</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
</View>
) : null}
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title="Sign In"
loading={loading}
disabled={!canSignIn}
onPress={handleSignIn}
/>
</View>
{!signupsDisabled ? (
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one
</Link>
</Text>
) : null}
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
flex: {
flex: 1,
},
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
},
card: {
gap: spacing.lg,
},
header: {
alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
},
notice: {
fontSize: 13,
fontFamily: fonts.body,
textAlign: "center",
lineHeight: 18,
},
ssoSection: {
gap: spacing.md,
},
dividerRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
dividerLine: {
flex: 1,
height: StyleSheet.hairlineWidth,
},
dividerLabel: {
fontSize: 12,
fontFamily: fonts.bodyMedium,
textTransform: "uppercase",
letterSpacing: 0.6,
},
form: {
gap: spacing.md,
},
forgot: {
alignSelf: "flex-end",
fontFamily: fonts.bodyMedium,
fontSize: 12,
},
error: {
fontSize: 14,
fontFamily: fonts.body,
},
footer: {
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
},
link: {
fontFamily: fonts.bodySemiBold,
},
});
-70
View File
@@ -1,70 +0,0 @@
import { SymbolView } from 'expo-symbols';
import { Link, Tabs } from 'expo-router';
import { Platform, Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
),
headerRight: () => (
<Link href="/modal" asChild>
<Pressable style={{ marginRight: 15 }}>
{({ pressed }) => (
<SymbolView
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
size={25}
tintColor={Colors[colorScheme].text}
style={{ opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
),
}}
/>
</Tabs>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
-31
View File
@@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
+100 -31
View File
@@ -1,30 +1,74 @@
import { useFonts } from 'expo-font'; import { Stack } from "expo-router";
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; import {
import * as SplashScreen from 'expo-splash-screen'; Inter_400Regular,
import { useEffect } from 'react'; Inter_500Medium,
import 'react-native-reanimated'; Inter_600SemiBold,
Inter_700Bold,
} from "@expo-google-fonts/inter";
import {
PlayfairDisplay_600SemiBold,
PlayfairDisplay_700Bold,
} from "@expo-google-fonts/playfair-display";
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, type ReactNode } from "react";
import { View } from "react-native";
import { StatusBar } from "expo-status-bar";
import "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { useColorScheme } from '@/components/useColorScheme'; import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SessionSync } from "@/components/SessionSync";
import { ShortcutLinkCapture } from "@/components/ShortcutLinkCapture";
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
import { TRPCProvider } from "@/lib/trpc";
export { ErrorBoundary } from "expo-router";
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
function AppServices({ children }: { children: ReactNode }) {
const { apiUrl, authStoragePrefix, activeAccountId } = useAccounts();
const remountKey = `${activeAccountId ?? "guest"}:${apiUrl}`;
return (
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
<SessionSync />
<ShortcutLinkCapture />
{children}
</TRPCProvider>
</AuthProvider>
);
}
function ThemedChrome({ children }: { children: ReactNode }) {
const { isDark } = useAppTheme();
return (
<View style={{ flex: 1, backgroundColor: "transparent" }}>
<BrandBackground />
<View style={{ flex: 1, zIndex: 1 }}>
<StatusBar style={isDark ? "light" : "dark"} />
{children}
</View>
</View>
);
}
export default function RootLayout() { export default function RootLayout() {
const [loaded, error] = useFonts({ const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
PlayfairDisplay_600SemiBold,
PlayfairDisplay_700Bold,
}); });
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => { useEffect(() => {
if (error) throw error; if (error) throw error;
}, [error]); }, [error]);
@@ -39,18 +83,43 @@ export default function RootLayout() {
return null; return null;
} }
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <SafeAreaProvider>
<Stack> <ThemeProvider>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <ThemedChrome>
<Stack.Screen name="modal" options={{ presentation: 'modal' }} /> <AccountsProvider>
</Stack> <AppServices>
</ThemeProvider> <RootNavigator />
</AppServices>
</AccountsProvider>
</ThemedChrome>
</ThemeProvider>
</SafeAreaProvider>
);
}
function RootNavigator() {
const { data: session, isPending, error } = useSession();
if (isPending) {
return <LoadingScreen message="Checking session…" />;
}
const isAuthenticated = Boolean(session?.user) && !error;
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
>
<Stack.Protected guard={!isAuthenticated}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated}>
<Stack.Screen name="(app)" />
</Stack.Protected>
</Stack>
); );
} }
-35
View File
@@ -1,35 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 436 436" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(92, 0)">
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
<g transform="matrix(1,0,0,1,1343.05,673.674)">
<g transform="matrix(488.128,0,0,488.128,0,0)">
<path
d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z"
fill="currentColor"
/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+66
View File
@@ -0,0 +1,66 @@
{
"fill" : {
"automatic-gradient" : "gray:0.75000,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "extended-gray:0.00000,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"solid" : "extended-gray:1.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "extended-gray:0.50000,1.00000"
}
}
],
"glass" : true,
"image-name" : "beenvoice.svg",
"name" : "beenvoice",
"position" : {
"scale" : 1.85,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 2970 436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-15.2844,-243.314)">
<g transform="matrix(1,0,0,1,-42.8493,2.19437)">
<g transform="matrix(1.05907,0,0,1.05907,-1187.92,22.2126)">
<g transform="matrix(460.901,0,0,460.901,1157.01,576.339)">
<path d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,1515.12,576.339)">
<path d="M0.35,0.012C0.312,0.012 0.28,0.003 0.252,-0.014C0.225,-0.032 0.204,-0.055 0.189,-0.083L0.186,-0L0.074,-0L0.074,-0.71L0.192,-0.71L0.192,-0.459C0.206,-0.483 0.226,-0.504 0.254,-0.521C0.281,-0.538 0.313,-0.546 0.35,-0.546C0.394,-0.546 0.433,-0.535 0.466,-0.513C0.498,-0.49 0.523,-0.458 0.541,-0.417C0.559,-0.375 0.568,-0.325 0.568,-0.267C0.568,-0.209 0.559,-0.159 0.541,-0.117C0.523,-0.076 0.498,-0.044 0.466,-0.021C0.433,0.001 0.394,0.012 0.35,0.012ZM0.322,-0.094C0.362,-0.094 0.392,-0.11 0.413,-0.14C0.434,-0.17 0.445,-0.212 0.445,-0.267C0.445,-0.322 0.434,-0.364 0.413,-0.395C0.392,-0.425 0.362,-0.44 0.324,-0.44C0.297,-0.44 0.274,-0.433 0.254,-0.42C0.234,-0.406 0.219,-0.387 0.208,-0.361C0.198,-0.335 0.192,-0.304 0.192,-0.267C0.192,-0.231 0.198,-0.2 0.209,-0.174C0.219,-0.148 0.234,-0.129 0.254,-0.115C0.273,-0.101 0.296,-0.094 0.322,-0.094Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,1791.66,576.339)">
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2068.19,576.339)">
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2344.73,576.339)">
<path d="M0.076,-0L0.076,-0.534L0.184,-0.534L0.188,-0.391L0.176,-0.398C0.182,-0.432 0.194,-0.46 0.21,-0.482C0.227,-0.504 0.248,-0.52 0.272,-0.53C0.296,-0.541 0.323,-0.546 0.351,-0.546C0.391,-0.546 0.423,-0.537 0.448,-0.52C0.473,-0.502 0.492,-0.478 0.505,-0.448C0.518,-0.418 0.524,-0.384 0.524,-0.345L0.524,-0L0.406,-0L0.406,-0.317C0.406,-0.36 0.398,-0.392 0.382,-0.413C0.367,-0.434 0.343,-0.445 0.311,-0.445C0.29,-0.445 0.27,-0.44 0.253,-0.43C0.235,-0.42 0.221,-0.405 0.21,-0.385C0.2,-0.366 0.194,-0.342 0.194,-0.313L0.194,-0L0.076,-0Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2621.27,576.339)">
<path d="M0.227,-0L0.04,-0.534L0.167,-0.534L0.3,-0.128L0.433,-0.534L0.56,-0.534L0.373,-0L0.227,-0Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2897.8,576.339)">
<path d="M0.3,0.012C0.25,0.012 0.206,0.001 0.169,-0.022C0.131,-0.045 0.102,-0.077 0.081,-0.119C0.06,-0.161 0.05,-0.21 0.05,-0.267C0.05,-0.324 0.06,-0.373 0.081,-0.415C0.102,-0.457 0.131,-0.489 0.169,-0.512C0.206,-0.535 0.25,-0.546 0.3,-0.546C0.35,-0.546 0.394,-0.535 0.431,-0.512C0.469,-0.489 0.498,-0.457 0.519,-0.415C0.54,-0.373 0.55,-0.324 0.55,-0.267C0.55,-0.21 0.54,-0.161 0.519,-0.119C0.498,-0.077 0.469,-0.045 0.431,-0.022C0.394,0.001 0.35,0.012 0.3,0.012ZM0.3,-0.094C0.34,-0.094 0.372,-0.11 0.394,-0.14C0.416,-0.17 0.427,-0.212 0.427,-0.267C0.427,-0.322 0.416,-0.364 0.394,-0.395C0.372,-0.425 0.34,-0.44 0.3,-0.44C0.26,-0.44 0.228,-0.425 0.206,-0.395C0.184,-0.364 0.173,-0.322 0.173,-0.267C0.173,-0.212 0.184,-0.17 0.206,-0.14C0.228,-0.11 0.26,-0.094 0.3,-0.094Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,3174.34,576.339)">
<path d="M0.276,-0L0.276,-0.534L0.394,-0.534L0.394,-0L0.276,-0ZM0.072,-0L0.072,-0.096L0.568,-0.096L0.568,-0L0.072,-0ZM0.082,-0.438L0.082,-0.534L0.377,-0.534L0.377,-0.438L0.082,-0.438ZM0.271,-0.605L0.271,-0.717L0.391,-0.717L0.391,-0.605L0.271,-0.605Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,3450.88,576.339)">
<path d="M0.311,0.012C0.26,0.012 0.216,0 0.178,-0.023C0.14,-0.046 0.11,-0.079 0.089,-0.121C0.068,-0.162 0.057,-0.211 0.057,-0.267C0.057,-0.323 0.068,-0.371 0.089,-0.413C0.11,-0.455 0.14,-0.487 0.178,-0.511C0.216,-0.534 0.26,-0.546 0.311,-0.546C0.352,-0.546 0.389,-0.538 0.422,-0.522C0.456,-0.506 0.483,-0.483 0.505,-0.454C0.526,-0.424 0.54,-0.389 0.546,-0.348L0.427,-0.341C0.42,-0.373 0.406,-0.397 0.386,-0.414C0.366,-0.431 0.341,-0.44 0.312,-0.44C0.271,-0.44 0.239,-0.424 0.215,-0.394C0.192,-0.363 0.18,-0.321 0.18,-0.267C0.18,-0.213 0.192,-0.171 0.215,-0.141C0.239,-0.11 0.271,-0.094 0.312,-0.094C0.341,-0.094 0.367,-0.103 0.388,-0.121C0.409,-0.139 0.423,-0.165 0.43,-0.2L0.549,-0.193C0.543,-0.152 0.528,-0.116 0.507,-0.085C0.485,-0.055 0.457,-0.031 0.423,-0.014C0.39,0.003 0.352,0.012 0.311,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,3727.41,576.339)">
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,4003.95,576.339)">
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 436 436" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(92, 0)">
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
<g transform="matrix(1,0,0,1,1343.05,673.674)">
<g transform="matrix(488.128,0,0,488.128,0,0)">
<path
d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z"
fill="currentColor"
/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1463
View File
File diff suppressed because it is too large Load Diff
+338
View File
@@ -0,0 +1,338 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
ActivityIndicator,
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 { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
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,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const [open, setOpen] = useState(false);
const [refreshing, setRefreshing] = 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 startAdditionalAccountSignIn(clearActiveAccount);
}
async function handleSwitch(accountId: string) {
if (accountId === activeAccountId) {
setOpen(false);
return;
}
setOpen(false);
await switchAccount(accountId);
}
async function handleRefresh() {
setRefreshing(true);
try {
await refreshAccounts();
} finally {
setRefreshing(false);
}
}
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
if (result.remainingCount === 0) {
setOpen(false);
}
await finishAccountRemoval({
result,
clearActiveAccount,
signOut: () => authClient.signOut(),
});
},
);
}
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>
<View style={styles.sheetActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Refresh accounts"
disabled={refreshing}
hitSlop={8}
onPress={() => void handleRefresh()}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
{refreshing ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Ionicons name="refresh" size={20} color={colors.primary} />
)}
</Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
</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>
<View style={styles.accountActions}>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemove(account.id, account.name || account.email)
}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</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,
},
sheetActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
iconButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 28,
minHeight: 28,
},
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,
},
accountActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
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,
},
});
+34
View File
@@ -0,0 +1,34 @@
import { StyleSheet, View, type ViewProps } from "react-native";
import { BrandBackground } from "@/components/BrandBackground";
/** Auth screens — brand grid/blob behind content. */
export function AuthBackground({ style, children, ...props }: ViewProps) {
return (
<View style={[styles.root, style]} {...props}>
<BrandBackground />
<View style={styles.content}>{children}</View>
</View>
);
}
/** App tab/stack screens — brand grid/blob behind content (native tabs block the root layer). */
export function AppBackground({ style, children, ...props }: ViewProps) {
return (
<View style={[styles.root, style]} {...props}>
<BrandBackground />
<View style={styles.content}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "transparent",
},
content: {
flex: 1,
backgroundColor: "transparent",
},
});
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import {
Modal,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppLock } from "@/contexts/AppLockContext";
import { useAppTheme } from "@/contexts/ThemeContext";
export function AppLockOverlay() {
const { colors } = useAppTheme();
const {
enabled,
isLocked,
biometricEnabled,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
} = useAppLock();
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const promptedRef = useRef(false);
useEffect(() => {
if (!isLocked) {
setPin("");
setError("");
promptedRef.current = false;
}
}, [isLocked]);
useEffect(() => {
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
return;
}
if (promptedRef.current) return;
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) {
return null;
}
async function submitPin() {
const success = await unlockWithPin(pin);
if (success) {
setPin("");
setError("");
return;
}
setError("Incorrect PIN");
setPin("");
}
async function tryBiometric() {
promptedRef.current = true;
const success = await unlockWithBiometric();
if (!success) {
setError(`Could not unlock with ${biometricLabel}`);
}
}
return (
<Modal visible animationType="fade" transparent={false}>
<View style={[styles.screen, { backgroundColor: colors.background }]}>
<View style={styles.content}>
<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>
<TextInput
value={pin}
onChangeText={(value) => {
setError("");
setPin(value.replace(/\D/g, "").slice(0, 6));
}}
keyboardType="number-pad"
secureTextEntry
maxLength={6}
style={[
styles.pinInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.card,
},
]}
placeholder="PIN"
placeholderTextColor={colors.mutedForeground}
onSubmitEditing={() => void submitPin()}
/>
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
<View style={styles.actions}>
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
{biometricAvailable ? (
<Button
title={`Unlock with ${biometricLabel}`}
variant="secondary"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
/>
) : null}
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
},
content: {
alignItems: "center",
gap: spacing.md,
width: "100%",
maxWidth: 320,
alignSelf: "center",
},
title: {
fontSize: 22,
fontFamily: fonts.heading,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
lineHeight: 20,
},
pinInput: {
width: "100%",
borderWidth: 1,
borderRadius: 12,
minHeight: 52,
paddingHorizontal: spacing.md,
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
},
error: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
textAlign: "center",
},
actions: {
width: "100%",
gap: spacing.sm,
},
biometricButton: {
width: "100%",
},
});
+232
View File
@@ -0,0 +1,232 @@
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;
/** When true, picker sits inside the auth card with no outer margin. */
embedded?: boolean;
};
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
if (mode === "official") return "Official";
const host = formatServerHost(selfHostedUrl);
return host || "Self-hosted";
}
export function AuthServerPicker({ onReadyChange, embedded = false }: 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, embedded && styles.wrapperEmbedded]}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={() => setExpanded((open) => !open)}
hitSlop={8}
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
>
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
Server ·{" "}
<Text style={[styles.summary, { color: colors.foreground }]}>
{modeSummary(mode, selfHostedUrl)}
</Text>
</Text>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{expanded ? (
<View
style={[
styles.panel,
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
]}
>
{SERVER_MODE_OPTIONS.map((option) => {
const selected = option.value === mode;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={() => void applyMode(option.value)}
style={({ pressed }) => [
styles.option,
{
borderColor: colors.border,
backgroundColor: selected ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
{option.label}
</Text>
{selected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
{mode === "self-hosted" ? (
<>
<Input
label="Server URL"
value={selfHostedUrl}
onChangeText={(value) => {
setSelfHostedUrl(value);
setUrlError(null);
}}
onBlur={() => void commitSelfHostedUrl()}
onSubmitEditing={() => void commitSelfHostedUrl()}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
required
error={urlError ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;s LAN IP on a physical device.
</Text>
</>
) : null}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
marginBottom: spacing.md,
},
wrapperEmbedded: {
marginBottom: 0,
},
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,
},
});
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useMemo } from "react";
import { StyleSheet, useWindowDimensions, View, type ViewProps } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import Svg, { Circle, Defs, Line, RadialGradient, Stop } from "react-native-svg";
import { useAppTheme } from "@/contexts/ThemeContext";
import { blobAnimation, blobDiameter } from "@/lib/beenvoice-theme";
import { getBackgroundTokens } from "@/lib/theme-palette";
export function BrandBackground({ style, ...props }: ViewProps) {
const { colorScheme } = useAppTheme();
const tokens = useMemo(() => getBackgroundTokens(colorScheme), [colorScheme]);
const { width, height } = useWindowDimensions();
const cx = width / 2;
const cy = height / 2;
const gridLines = useMemo(() => {
const vertical: Array<{ key: string; x: number }> = [];
const horizontal: Array<{ key: string; y: number }> = [];
for (let x = 0; x <= width; x += tokens.gridSize) {
vertical.push({ key: `v-${x}`, x });
}
for (let y = 0; y <= height; y += tokens.gridSize) {
horizontal.push({ key: `h-${y}`, y });
}
return { vertical, horizontal };
}, [width, height, tokens.gridSize]);
return (
<View
style={[styles.root, { backgroundColor: tokens.background }, style]}
pointerEvents="none"
{...props}
>
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
{gridLines.vertical.map((line) => (
<Line
key={line.key}
x1={line.x}
y1={0}
x2={line.x}
y2={height}
stroke={tokens.gridLine}
strokeWidth={1}
/>
))}
{gridLines.horizontal.map((line) => (
<Line
key={line.key}
x1={0}
y1={line.y}
x2={width}
y2={line.y}
stroke={tokens.gridLine}
strokeWidth={1}
/>
))}
</Svg>
<AmbientBlob cx={cx} cy={cy} blobCore={tokens.blobCore} />
</View>
);
}
function AmbientBlob({ cx, cy, blobCore }: { cx: number; cy: number; blobCore: string }) {
const progress = useSharedValue(0);
const r = blobDiameter / 2;
useEffect(() => {
progress.value = withRepeat(
withTiming(1, {
duration: blobAnimation.durationMs,
easing: Easing.inOut(Easing.ease),
}),
-1,
false,
);
}, [progress]);
const animatedStyle = useAnimatedStyle(() => {
const k = blobAnimation.keyframes;
const t = progress.value;
const seg = t < 0.33 ? 0 : t < 0.66 ? 1 : 2;
const local = seg === 0 ? t / 0.33 : seg === 1 ? (t - 0.33) / 0.33 : (t - 0.66) / 0.34;
const from = k[seg]!;
const to = k[seg + 1] ?? k[0]!;
const lerp = (a: number, b: number) => a + (b - a) * local;
return {
transform: [
{ translateX: lerp(from.translateX, to.translateX) },
{ translateY: lerp(from.translateY, to.translateY) },
{ scale: lerp(from.scale, to.scale) },
],
};
});
return (
<Animated.View style={[styles.blobLayer, animatedStyle]} pointerEvents="none">
<Svg width={blobDiameter * 1.6} height={blobDiameter * 1.6}>
<Defs>
<RadialGradient id="blob-a" cx="50%" cy="50%" r="50%">
<Stop offset="0%" stopColor={blobCore} stopOpacity={0.9} />
<Stop offset="38%" stopColor={blobCore} stopOpacity={0.35} />
<Stop offset="62%" stopColor={blobCore} stopOpacity={0.1} />
<Stop offset="100%" stopColor={blobCore} stopOpacity={0} />
</RadialGradient>
</Defs>
<Circle cx={blobDiameter * 0.8} cy={blobDiameter * 0.8} r={r} fill="url(#blob-a)" />
</Svg>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: {
...StyleSheet.absoluteFill,
},
blobLayer: {
position: "absolute",
left: "50%",
top: "50%",
width: blobDiameter * 1.6,
height: blobDiameter * 1.6,
marginLeft: -(blobDiameter * 0.8),
marginTop: -(blobDiameter * 0.8),
},
});
+64
View File
@@ -0,0 +1,64 @@
import { router } from "expo-router";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { fonts } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
/** Green dot + elapsed time when a timer is running; tappable to open the clock. */
export function ClockedInIndicator() {
const { colors } = useAppTheme();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
if (!running) return null;
const label = formatElapsedHoursMinutes(elapsed);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Clocked in, ${label}`}
hitSlop={8}
onPress={() => router.push("/(app)/timer")}
style={styles.hit}
>
<View style={[styles.row, { backgroundColor: colors.successBg }]}>
<View style={[styles.dot, { backgroundColor: colors.success }]} />
<Text style={[styles.time, { color: colors.foreground }]}>{label}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
hit: {
flexShrink: 0,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
paddingHorizontal: 10,
minHeight: 28,
borderRadius: 999,
},
dot: {
width: 7,
height: 7,
borderRadius: 4,
},
time: {
fontFamily: fonts.mono,
fontSize: 14,
lineHeight: 18,
fontVariant: ["tabular-nums"],
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
},
});
+150
View File
@@ -0,0 +1,150 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
type CollapsibleServerFieldProps = {
defaultExpanded?: boolean;
};
function formatServerLabel(url: string) {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "");
}
}
export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleServerFieldProps) {
const { colors } = useAppTheme();
const insets = useSafeAreaInsets();
const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(defaultExpanded);
const [value, setValue] = useState(apiUrl);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
hasConfiguredInstanceUrl().then((configured) => {
if (!configured) setExpanded(true);
});
}, []);
useEffect(() => {
setValue(apiUrl);
}, [apiUrl]);
async function commit() {
const trimmed = value.trim();
if (!trimmed || trimmed === apiUrl) {
setError(null);
setExpanded(false);
return;
}
try {
const saved = await setInstanceUrl(trimmed);
setValue(saved);
setError(null);
setExpanded(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View
style={[
styles.wrapper,
{ paddingBottom: Math.max(insets.bottom, spacing.sm) },
]}
>
<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.host, { color: colors.foreground }]}>
{formatServerLabel(apiUrl)}
</Text>
</Text>
<Ionicons
name={expanded ? "chevron-down" : "chevron-up"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{expanded ? (
<View
style={[
styles.panel,
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
]}
>
<Input
label="Server instance"
value={value}
onChangeText={setValue}
onBlur={commit}
onSubmitEditing={commit}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;s LAN IP on a physical device.
</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
flexDirection: "column-reverse",
gap: spacing.sm,
paddingHorizontal: spacing.md,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
pressed: {
opacity: 0.7,
},
triggerText: {
fontSize: 13,
fontFamily: fonts.body,
},
host: {
fontFamily: fonts.mono,
fontSize: 13,
},
panel: {
borderWidth: 1,
borderRadius: 14,
padding: spacing.md,
gap: spacing.sm,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { Link } from 'expo-router'; import { Link, type Href } from 'expo-router';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
@@ -8,7 +8,7 @@ export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> &
<Link <Link
target="_blank" target="_blank"
{...props} {...props}
href={props.href} href={props.href as Href}
onPress={(e) => { onPress={(e) => {
if (Platform.OS !== 'web') { if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native. // Prevent the default behavior of linking to the default browser on native.
+57
View File
@@ -0,0 +1,57 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import { fonts, radii } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type FilterChipProps = {
label: string;
active?: boolean;
onPress: () => void;
};
export function FilterChip({ label, active, onPress }: FilterChipProps) {
const { colors } = useAppTheme();
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={[
styles.chip,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
active && { backgroundColor: colors.primary, borderColor: colors.primary },
]}
>
<View style={styles.chipInner}>
<Text
style={[
styles.label,
{ color: colors.mutedForeground },
active && { color: colors.primaryForeground },
]}
>
{label}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
chip: {
height: 32,
borderWidth: 1,
borderRadius: radii.pill,
overflow: "hidden",
},
chipInner: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 14,
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
});
+63
View File
@@ -0,0 +1,63 @@
import { Pressable, StyleSheet, Text } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii } from "@/constants/theme";
import { useFloatingActionBottom } from "@/lib/tab-bar-insets";
type FloatingActionButtonProps = {
onPress: () => void;
accessibilityLabel?: string;
};
export function FloatingActionButton({
onPress,
accessibilityLabel = "Create",
}: FloatingActionButtonProps) {
const { colors } = useAppTheme();
const bottom = useFloatingActionBottom();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
style={({ pressed }) => [
styles.fab,
{
bottom,
backgroundColor: colors.primary,
shadowColor: colors.foreground,
},
pressed && styles.pressed,
]}
>
<Text style={[styles.icon, { color: colors.primaryForeground }]}>+</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
fab: {
position: "absolute",
right: 20,
width: 56,
height: 56,
borderRadius: radii.pill,
alignItems: "center",
justifyContent: "center",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 6,
},
pressed: {
opacity: 0.9,
transform: [{ scale: 0.96 }],
},
icon: {
fontSize: 32,
lineHeight: 34,
fontFamily: fonts.body,
marginTop: -2,
},
});
+112
View File
@@ -0,0 +1,112 @@
import { BlurView } from "expo-blur";
import type { ReactNode } from "react";
import { Platform, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { blurIntensity, radius, shadowMd, shadowSm } from "@/lib/beenvoice-theme";
type GlassSurfaceProps = {
children: ReactNode;
style?: StyleProp<ViewStyle>;
radius?: number;
variant?: "card" | "stat";
};
export function GlassSurface({
children,
style,
radius: cornerRadius = radius.lg,
variant = "card",
}: GlassSurfaceProps) {
const { colors, isDark } = useAppTheme();
const flat = StyleSheet.flatten(style);
const isStat = variant === "stat";
return (
<View
style={[
styles.shell,
isStat ? styles.statShell : null,
{ borderRadius: cornerRadius, borderColor: colors.borderGlass },
isStat ? shadowMd : shadowSm,
flat,
Platform.OS === "android" ? { backgroundColor: colors.cardGlass } : null,
]}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={blurIntensity.card}
tint={isDark ? "dark" : "light"}
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
/>
) : null}
<View
pointerEvents="none"
style={[
styles.fill,
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
]}
/>
<View style={styles.content}>{children}</View>
</View>
);
}
export function GlassChrome({
children,
style,
radius: cornerRadius = 0,
}: {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
radius?: number;
}) {
const { colors, isDark } = useAppTheme();
return (
<View
style={[
styles.chromeShell,
{ borderRadius: cornerRadius, backgroundColor: colors.cardGlass },
StyleSheet.flatten(style),
]}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={blurIntensity.chrome}
tint={isDark ? "dark" : "light"}
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
/>
) : null}
<View
pointerEvents="none"
style={[
styles.fill,
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
]}
/>
{children ? <View style={styles.content}>{children}</View> : null}
</View>
);
}
const styles = StyleSheet.create({
shell: {
overflow: "hidden",
borderWidth: StyleSheet.hairlineWidth * 2,
backgroundColor: "transparent",
},
statShell: {
borderWidth: 0,
},
chromeShell: {
overflow: "hidden",
},
fill: {
...StyleSheet.absoluteFill,
},
content: {
position: "relative",
zIndex: 2,
},
});
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { 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 { normalizeInstanceUrl } from "@/lib/instance-url";
type InstanceUrlFieldProps = {
onSaved?: (url: string) => void;
};
export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts();
const [value, setValue] = useState(apiUrl);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setValue(apiUrl);
}, [apiUrl]);
async function commit() {
const trimmed = value.trim();
if (!trimmed || trimmed === apiUrl) {
setError(null);
return;
}
const normalized = normalizeInstanceUrl(trimmed);
if (!normalized) {
setError("Enter a valid URL like beenvoice.app or localhost:3000");
return;
}
try {
const saved = await setInstanceUrl(trimmed);
setValue(saved);
setError(null);
onSaved?.(saved);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View style={styles.wrapper}>
<Input
label="Server instance"
value={value}
onChangeText={setValue}
onBlur={commit}
onSubmitEditing={commit}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Point the app at your beenvoice server. Use your Mac&apos;s LAN IP on a physical device.
</Text>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.xs,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
+65
View File
@@ -0,0 +1,65 @@
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return;
router.push(`/(app)/invoices/${invoiceId}`);
}
/** Schedules local iOS/Android notifications for draft invoice send reminders. */
export function InvoiceReminderSync() {
const utils = api.useUtils();
const invoicesQuery = api.invoices.getAll.useQuery(
{ status: "draft" },
{ staleTime: 60_000 },
);
const wasBackgrounded = useRef(false);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data);
}, [invoicesQuery.data]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
});
return () => subscription.remove();
}, [utils.invoices.getAll]);
useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
},
);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return;
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
});
return () => responseSubscription.remove();
}, []);
return null;
}
+45
View File
@@ -0,0 +1,45 @@
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BrandBackground } from "@/components/BrandBackground";
import { LogoMark } from "@/components/Logo";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts } from "@/constants/theme";
export function LoadingScreen({ message = "Loading…" }: { message?: string }) {
const insets = useSafeAreaInsets();
const { colors } = useAppTheme();
return (
<View style={styles.root}>
<BrandBackground />
<View
style={[
styles.container,
{ paddingTop: insets.top, paddingBottom: insets.bottom },
]}
>
<LogoMark />
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 12,
backgroundColor: "transparent",
},
message: {
fontSize: 15,
fontFamily: fonts.body,
},
});
+110
View File
@@ -0,0 +1,110 @@
import { Image } from "expo-image";
import { StyleSheet, Text, View, type ViewStyle } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts } from "@/constants/theme";
const markSource = require("@/assets/images/icon.png");
type LogoSize = "xs" | "sm" | "md" | "lg";
const widths: Record<LogoSize, number> = {
xs: 104,
sm: 140,
md: 180,
lg: 220,
};
type LogoProps = {
size?: LogoSize;
style?: ViewStyle;
/** Force the light wordmark for dark backgrounds (e.g. status bar chrome). */
onDark?: boolean;
};
/** Full beenvoice wordmark from web `public/beenvoice-logo.png` */
export function Logo({ size = "md", style, onDark }: LogoProps) {
const { isDark } = useAppTheme();
const width = widths[size];
const height = width * (436 / 2970);
const useDarkAsset = onDark ?? isDark;
return (
<View style={[styles.row, styles.noShrink, style]}>
<Image
source={
useDarkAsset
? require("@/assets/images/beenvoice-logo-dark.png")
: require("@/assets/images/beenvoice-logo.png")
}
style={{ width, height }}
contentFit="contain"
/>
</View>
);
}
/** Square dollar mark from Icon Composer export (1024×1024 PNG). */
export function LogoMark({
size = 32,
style,
}: {
size?: number;
style?: ViewStyle;
}) {
const fromStyle =
typeof style?.width === "number"
? style.width
: typeof style?.height === "number"
? style.height
: undefined;
const dimension = fromStyle ?? size;
return (
<View
style={[
styles.markBox,
{ width: dimension, height: dimension, aspectRatio: 1 },
style,
]}
>
<Image
source={markSource}
style={{ width: dimension, height: dimension }}
contentFit="contain"
/>
</View>
);
}
export function HeadingText({
children,
style,
}: {
children: React.ReactNode;
style?: object;
}) {
const { colors } = useAppTheme();
return (
<Text style={[styles.heading, { color: colors.foreground }, style]}>{children}</Text>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
},
noShrink: {
flexShrink: 0,
},
markBox: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
heading: {
fontFamily: fonts.heading,
},
});
+35
View File
@@ -0,0 +1,35 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts } from "@/constants/theme";
import { tabLayout } from "@/lib/tab-layout";
import { useAppTheme } from "@/contexts/ThemeContext";
type PageHeaderProps = {
title: string;
subtitle: string;
};
/** Title block — scrolls with tab screen content. */
export function PageHeader({ title, subtitle }: PageHeaderProps) {
const { colors } = useAppTheme();
return (
<View style={tabLayout.pageHeader}>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
</View>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 28,
lineHeight: 32,
fontFamily: fonts.heading,
},
subtitle: {
fontSize: 14,
lineHeight: 18,
fontFamily: fonts.body,
},
});
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { isValidPin } from "@/lib/app-lock";
type PinPromptProps = {
visible: boolean;
title: string;
message: string;
confirmLabel?: string;
requireConfirmation?: boolean;
onCancel: () => void;
onSubmit: (pin: string) => void;
};
export function PinPrompt({
visible,
title,
message,
confirmLabel = "Continue",
requireConfirmation = false,
onCancel,
onSubmit,
}: PinPromptProps) {
const { colors } = useAppTheme();
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!visible) {
setPin("");
setConfirmPin("");
setError("");
}
}, [visible]);
function handleSubmit() {
if (!isValidPin(pin)) {
setError("PIN must be 46 digits");
return;
}
if (requireConfirmation && pin !== confirmPin) {
setError("PINs do not match");
return;
}
onSubmit(pin);
}
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Pressable style={styles.backdrop} onPress={onCancel}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card, borderColor: colors.border }]}
onPress={(event) => event.stopPropagation()}
>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
<TextInput
value={pin}
onChangeText={(value) => {
setError("");
setPin(value.replace(/\D/g, "").slice(0, 6));
}}
keyboardType="number-pad"
secureTextEntry
maxLength={6}
placeholder="PIN"
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
]}
/>
{requireConfirmation ? (
<TextInput
value={confirmPin}
onChangeText={(value) => {
setError("");
setConfirmPin(value.replace(/\D/g, "").slice(0, 6));
}}
keyboardType="number-pad"
secureTextEntry
maxLength={6}
placeholder="Confirm PIN"
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
]}
/>
) : null}
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
<View style={styles.actions}>
<Button title="Cancel" variant="secondary" onPress={onCancel} />
<Button title={confirmLabel} onPress={handleSubmit} />
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
backgroundColor: "rgba(0,0,0,0.35)",
},
sheet: {
borderWidth: 1,
borderRadius: 16,
padding: spacing.lg,
gap: spacing.md,
},
title: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
},
message: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
input: {
borderWidth: 1,
borderRadius: 12,
minHeight: 48,
paddingHorizontal: spacing.md,
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
},
error: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
});
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { StyleSheet, type ViewStyle } from "react-native";
import { SafeAreaView, type Edge } from "react-native-safe-area-context";
type ScreenProps = {
children: ReactNode;
style?: ViewStyle;
/**
* Safe area edges to pad. Default: top + sides (Dynamic Island / notch).
* Tab screens usually omit bottom — the tab bar handles home-indicator spacing.
*/
edges?: Edge[];
};
/** Full-screen wrapper that respects Dynamic Island, notch, and side insets. */
export function Screen({ children, style, edges = ["top", "left", "right"] }: ScreenProps) {
return (
<SafeAreaView style={[styles.screen, style]} edges={edges}>
{children}
</SafeAreaView>
);
}
/** Auth / modal screens that aren't inside a tab bar. */
export function FullScreen({ children, style }: Omit<ScreenProps, "edges">) {
return (
<SafeAreaView style={[styles.screen, style]} edges={["top", "bottom", "left", "right"]}>
{children}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: "transparent",
},
});
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useSession } from "@/contexts/AuthContext";
/** Refetch auth session when the app returns to the foreground. */
export function SessionSync() {
const { refetch } = useSession();
const wasBackgrounded = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void refetch();
});
return () => subscription.remove();
}, [refetch]);
return null;
}
+154
View File
@@ -0,0 +1,154 @@
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Alert, Platform } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import {
clearPendingShortcut,
peekPendingShortcut,
subscribeShortcutQueue,
} from "@/lib/shortcut-queue";
import {
DEFAULT_CLOCK_DESCRIPTION,
resolveClockDescription,
resolveEffectiveHourlyRate,
} from "@/lib/time-clock";
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
import type { ParsedShortcut } from "@/lib/shortcuts";
import { api } from "@/lib/trpc";
/**
* Executes queued shortcut actions once the user is signed in, unlocked, and data is ready.
*/
export function ShortcutHandler() {
const { activeAccountId } = useAccounts();
const { isLocked } = useAppLock();
const utils = api.useUtils();
const clientsQuery = api.clients.getAll.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery();
const [pending, setPending] = useState<ParsedShortcut | null>(null);
const processingRef = useRef(false);
const clockIn = api.timeEntries.clockIn.useMutation();
const clockOut = api.timeEntries.clockOut.useMutation();
useEffect(() => {
let cancelled = false;
async function refresh() {
const next = await peekPendingShortcut();
if (!cancelled) setPending(next);
}
void refresh();
return subscribeShortcutQueue(() => {
void refresh();
});
}, []);
useEffect(() => {
if (!pending || !activeAccountId || isLocked) return;
if (clientsQuery.isLoading || runningQuery.isLoading) return;
if (processingRef.current) return;
processingRef.current = true;
void (async () => {
try {
if (pending.action === "open-timer") {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
return;
}
if (pending.action === "clock-out") {
if (!runningQuery.data) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
if (Platform.OS === "ios") {
Alert.alert("No timer running", "There is nothing to clock out.");
}
return;
}
await clockOut.mutateAsync({});
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
return;
}
if (pending.action === "clock-in") {
if (runningQuery.data) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
if (Platform.OS === "ios") {
Alert.alert("Timer already running", "Stop the current timer before clocking in again.");
}
return;
}
const clientId =
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
if (!clientId) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
Alert.alert(
"Choose a client",
"Open the time clock and pick a client once — shortcuts will use it next time.",
);
return;
}
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
await clockIn.mutateAsync({
clientId,
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
rate: rate ?? undefined,
});
await utils.timeEntries.getRunning.invalidate();
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
}
} catch (err) {
await clearPendingShortcut();
setPending(null);
Alert.alert(
pending.action === "clock-out" ? "Clock out failed" : "Clock in failed",
err instanceof Error ? err.message : "Something went wrong.",
);
router.push("/(app)/timer");
} finally {
processingRef.current = false;
}
})();
}, [
activeAccountId,
clockIn,
clockOut,
clientsQuery.data,
clientsQuery.isLoading,
isLocked,
pending,
runningQuery.data,
runningQuery.isLoading,
utils,
]);
return null;
}
+30
View File
@@ -0,0 +1,30 @@
import * as Linking from "expo-linking";
import { useEffect } from "react";
import { enqueueShortcut } from "@/lib/shortcut-queue";
import { parseShortcutUrl } from "@/lib/shortcuts";
/**
* Captures shortcut deep links as early as possible (before auth / tabs mount).
* Mounted at the app root inside AppServices.
*/
export function ShortcutLinkCapture() {
useEffect(() => {
function capture(url: string | null | undefined) {
const parsed = parseShortcutUrl(url);
if (parsed) {
void enqueueShortcut(parsed);
}
}
void Linking.getInitialURL().then(capture);
const subscription = Linking.addEventListener("url", ({ url }) => {
capture(url);
});
return () => subscription.remove();
}, []);
return null;
}
+45
View File
@@ -0,0 +1,45 @@
import { StyleSheet, Text } from "react-native";
import { Card } from "@/components/ui/Card";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, spacing } from "@/constants/theme";
type StatCardProps = {
label: string;
value: string;
hint?: string;
};
/** Web `StatsCard` — glass card, border-0, shadow-md, p-6 */
export function StatCard({ label, value, hint }: StatCardProps) {
const { colors } = useAppTheme();
return (
<Card variant="stat" style={styles.card}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
{hint ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
) : null}
</Card>
);
}
const styles = StyleSheet.create({
card: {
width: "100%",
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
value: {
fontSize: 22,
fontFamily: fonts.heading,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
marginTop: 2,
},
});
+33
View File
@@ -0,0 +1,33 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { getStatusColor, statusLabels, type InvoiceStatus } from "@/lib/invoice-status";
export function StatusBadge({ status }: { status: InvoiceStatus }) {
const { isDark } = useAppTheme();
const color = getStatusColor(status, isDark);
return (
<View style={[styles.badge, { backgroundColor: `${color}22` }]}>
<Text style={[styles.text, { color }]}>{statusLabels[status]}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
height: 22,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
borderRadius: radii.pill,
},
text: {
fontSize: 10,
fontFamily: fonts.bodyBold,
textTransform: "uppercase",
letterSpacing: 0.4,
includeFontPadding: false,
},
});
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
import { StatusBar } from "expo-status-bar";
import { TopChromeBar } from "@/components/TopChromeBar";
import { useAppTheme } from "@/contexts/ThemeContext";
type TabPageProps = {
children: ReactNode;
};
/** Tab root — pinned top chrome, scrollable body below. */
export function TabPage({ children }: TabPageProps) {
const { isDark } = useAppTheme();
return (
<View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} />
<TopChromeBar />
<View style={styles.content}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "transparent",
},
content: {
flex: 1,
minHeight: 0,
backgroundColor: "transparent",
},
});
+55
View File
@@ -0,0 +1,55 @@
import { useScrollToTop } from "expo-router";
import { useRef, type ReactNode } from "react";
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
import { tabLayout } from "@/lib/tab-layout";
import { useTabScreenScrollPadding } from "@/lib/tab-bar-insets";
type TabScrollViewProps = ScrollViewProps & {
/** Rendered at the top of scroll content (scrolls with the page). */
header?: ReactNode;
children: ReactNode;
};
/**
* Tab screen scroll view. Top chrome (logo / account) is pinned in TabPage;
* the page header and body scroll together here.
*/
export function TabScrollView({
header,
children,
contentContainerStyle,
style,
...props
}: TabScrollViewProps) {
const scrollRef = useRef<ScrollView>(null);
const bottomPadding = useTabScreenScrollPadding();
useScrollToTop(scrollRef);
return (
<ScrollView
ref={scrollRef}
style={[styles.scroll, style]}
contentContainerStyle={[
tabLayout.scrollContent,
{ paddingBottom: bottomPadding },
contentContainerStyle,
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props}
>
{header}
<View style={tabLayout.scrollBody}>{children}</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
minHeight: 0,
backgroundColor: "transparent",
},
});
+29
View File
@@ -0,0 +1,29 @@
import { StyleSheet, View } from "react-native";
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, account switcher right — sits on TopChromeBar blur. */
export function TopChrome() {
const { isDark } = useAppTheme();
return (
<View style={styles.row}>
<Logo size="xs" onDark={isDark} />
<AccountSwitcher />
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: TOP_CHROME_ROW_HEIGHT,
paddingHorizontal: spacing.md,
},
});
+51
View File
@@ -0,0 +1,51 @@
import { BlurView } from "expo-blur";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TopChrome } from "@/components/TopChrome";
import { useAppTheme } from "@/contexts/ThemeContext";
import { blurIntensity } from "@/lib/beenvoice-theme";
import {
TOP_CHROME_PADDING_BOTTOM,
TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets";
/** Blurred status-bar chrome with logo + account switcher. */
export function TopChromeBar() {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)";
return (
<View
style={[
styles.host,
{
paddingTop: insets.top,
paddingBottom: TOP_CHROME_PADDING_BOTTOM,
paddingLeft: insets.left,
paddingRight: insets.right,
height: insets.top + TOP_CHROME_ROW_HEIGHT + TOP_CHROME_PADDING_BOTTOM,
},
]}
>
<BlurView
intensity={blurIntensity.chrome}
tint={isDark ? "dark" : "light"}
style={StyleSheet.absoluteFill}
/>
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tint }]}
/>
<TopChrome />
</View>
);
}
const styles = StyleSheet.create({
host: {
flexShrink: 0,
overflow: "hidden",
},
});
+342
View File
@@ -0,0 +1,342 @@
import { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
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 = {
name: string;
nickname: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
website: string;
taxId: string;
isDefault: boolean;
};
const emptyValues: BusinessFormValues = {
name: "",
nickname: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
website: "",
taxId: "",
isDefault: false,
};
type BusinessFormProps = {
mode: "create" | "edit";
businessId?: string;
scrollPadding: number;
onSaved: () => void;
onDeleted?: () => void;
};
export function BusinessForm({
mode,
businessId,
scrollPadding,
onSaved,
onDeleted,
}: BusinessFormProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createBusinessFormStyles);
const utils = api.useUtils();
const businessQuery = api.businesses.getById.useQuery(
{ id: businessId ?? "" },
{ enabled: mode === "edit" && Boolean(businessId) },
);
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null);
const switchProps = {
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
ios_backgroundColor: colors.switchIosBackground,
};
useEffect(() => {
const business = businessQuery.data;
if (!business) return;
setValues({
name: business.name,
nickname: business.nickname ?? "",
email: business.email ?? "",
phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "",
addressLine2: business.addressLine2 ?? "",
city: business.city ?? "",
state: business.state ?? "",
postalCode: business.postalCode ?? "",
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
isDefault: business.isDefault ?? false,
});
}, [businessQuery.data]);
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const deleteBusiness = api.businesses.delete.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
onDeleted?.();
},
onError: (err) => Alert.alert("Could not delete business", err.message),
});
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) {
setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null);
}
function buildPayload() {
return {
name: values.name.trim(),
nickname: values.nickname.trim(),
email: values.email.trim(),
phone: values.phone.trim(),
addressLine1: values.addressLine1.trim(),
addressLine2: values.addressLine2.trim(),
city: values.city.trim(),
state: values.state.trim(),
postalCode: values.postalCode.trim(),
country: values.country.trim() || "United States",
website: values.website.trim(),
taxId: values.taxId.trim(),
isDefault: values.isDefault,
};
}
function handleSave() {
if (!canSave) return;
const payload = buildPayload();
if (mode === "create") {
createBusiness.mutate(payload);
return;
}
if (!businessId) return;
updateBusiness.mutate({ id: businessId, ...payload });
}
function confirmDelete() {
if (!businessId) return;
Alert.alert(
"Delete business",
"This cannot be undone. Businesses with invoices cannot be deleted.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteBusiness.mutate({ id: businessId }),
},
],
);
}
const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required";
const canSave = isRequiredString(values.name);
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Profile">
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Nickname"
value={values.nickname}
onChangeText={(v) => patch("nickname", v)}
placeholder="Optional short name"
/>
<Input
label="Email"
value={values.email}
onChangeText={(v) => patch("email", v)}
keyboardType="email-address"
autoCapitalize="none"
/>
<Input
label="Phone"
value={values.phone}
onChangeText={(v) => patch("phone", v)}
keyboardType="phone-pad"
/>
<Input
label="Website"
value={values.website}
onChangeText={(v) => patch("website", v)}
autoCapitalize="none"
keyboardType="url"
placeholder="https://"
/>
<Input
label="Tax ID"
value={values.taxId}
onChangeText={(v) => patch("taxId", v)}
placeholder="Optional"
/>
<View style={styles.switchRow}>
<View style={styles.switchCopy}>
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
Default business
</Text>
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
Used for new invoices when none is selected
</Text>
</View>
<Switch
value={values.isDefault}
onValueChange={(v) => patch("isDefault", v)}
{...switchProps}
/>
</View>
</Card>
<Card title="Address">
<Input
label="Address line 1"
value={values.addressLine1}
onChangeText={(v) => patch("addressLine1", v)}
/>
<Input
label="Address line 2"
value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)}
/>
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
<Input
label="Postal code"
value={values.postalCode}
onChangeText={(v) => patch("postalCode", v)}
/>
<Input
label="Country"
value={values.country}
onChangeText={(v) => patch("country", v)}
/>
</Card>
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
<View style={styles.actions}>
<Button
title={mode === "create" ? "Create business" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
<Button
title="Delete business"
variant="danger"
loading={deleteBusiness.isPending}
onPress={confirmDelete}
/>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
switchRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingTop: spacing.xs,
},
switchCopy: {
flex: 1,
gap: 2,
},
switchLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
switchHint: {
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
},
actions: {
gap: spacing.sm,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
+301
View File
@@ -0,0 +1,301 @@
import { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
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 = {
name: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
defaultHourlyRate: string;
currency: string;
};
const emptyValues: ClientFormValues = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
defaultHourlyRate: "",
currency: "USD",
};
type ClientFormProps = {
mode: "create" | "edit";
clientId?: string;
scrollPadding: number;
onSaved: () => void;
onDeleted?: () => void;
};
export function ClientForm({
mode,
clientId,
scrollPadding,
onSaved,
onDeleted,
}: ClientFormProps) {
const styles = useThemedStyles(createClientFormStyles);
const utils = api.useUtils();
const clientQuery = api.clients.getById.useQuery(
{ id: clientId ?? "" },
{ enabled: mode === "edit" && Boolean(clientId) },
);
const [values, setValues] = useState<ClientFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null);
useEffect(() => {
const client = clientQuery.data;
if (!client) return;
setValues({
name: client.name,
email: client.email ?? "",
phone: client.phone ?? "",
addressLine1: client.addressLine1 ?? "",
addressLine2: client.addressLine2 ?? "",
city: client.city ?? "",
state: client.state ?? "",
postalCode: client.postalCode ?? "",
country: client.country ?? "United States",
defaultHourlyRate:
client.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
currency: client.currency ?? "USD",
});
}, [clientQuery.data]);
const createClient = api.clients.create.useMutation({
onSuccess: () => {
void utils.clients.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const updateClient = api.clients.update.useMutation({
onSuccess: () => {
void utils.clients.getAll.invalidate();
if (clientId) void utils.clients.getById.invalidate({ id: clientId });
void utils.dashboard.getStats.invalidate();
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const deleteClient = api.clients.delete.useMutation({
onSuccess: () => {
void utils.clients.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
onDeleted?.();
},
onError: (err) => Alert.alert("Could not delete client", err.message),
});
function patch(field: keyof ClientFormValues, value: string) {
setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null);
}
function handleSave() {
if (!canSave) return;
const rate = values.defaultHourlyRate.trim()
? Number(values.defaultHourlyRate)
: undefined;
if (rate !== undefined && (Number.isNaN(rate) || rate < 0)) {
setFieldError("Hourly rate must be a valid number");
return;
}
const payload = {
name: values.name.trim(),
email: values.email.trim(),
phone: values.phone.trim(),
addressLine1: values.addressLine1.trim(),
addressLine2: values.addressLine2.trim(),
city: values.city.trim(),
state: values.state.trim(),
postalCode: values.postalCode.trim(),
country: values.country.trim() || "United States",
defaultHourlyRate: rate,
currency: values.currency.trim() || "USD",
};
if (mode === "create") {
createClient.mutate(payload);
return;
}
if (!clientId) return;
updateClient.mutate({ id: clientId, ...payload });
}
function confirmDelete() {
if (!clientId) return;
Alert.alert(
"Delete client",
"This cannot be undone. Clients with invoices cannot be deleted.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteClient.mutate({ id: clientId }),
},
],
);
}
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}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Contact">
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Email"
value={values.email}
onChangeText={(v) => patch("email", v)}
keyboardType="email-address"
autoCapitalize="none"
/>
<Input
label="Phone"
value={values.phone}
onChangeText={(v) => patch("phone", v)}
keyboardType="phone-pad"
/>
</Card>
<Card title="Address">
<Input
label="Address line 1"
value={values.addressLine1}
onChangeText={(v) => patch("addressLine1", v)}
/>
<Input
label="Address line 2"
value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)}
/>
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
<Input
label="Postal code"
value={values.postalCode}
onChangeText={(v) => patch("postalCode", v)}
/>
<Input
label="Country"
value={values.country}
onChangeText={(v) => patch("country", v)}
/>
</Card>
<Card title="Billing">
<Input
label="Default hourly rate"
value={values.defaultHourlyRate}
onChangeText={(v) => patch("defaultHourlyRate", v)}
keyboardType="decimal-pad"
placeholder="Optional"
error={rateError}
/>
<Input
label="Currency"
value={values.currency}
onChangeText={(v) => patch("currency", v.toUpperCase())}
autoCapitalize="characters"
maxLength={3}
/>
</Card>
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
<View style={styles.actions}>
<Button
title={mode === "create" ? "Create client" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
<Button
title="Delete client"
variant="danger"
loading={deleteClient.isPending}
onPress={confirmDelete}
/>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const createClientFormStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
actions: {
gap: spacing.sm,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
@@ -0,0 +1,49 @@
import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme";
export type InvoiceEditorSection = "edit" | "preview";
type InvoiceEditorSectionTabsProps = {
value: InvoiceEditorSection;
onChange: (value: InvoiceEditorSection) => void;
editLabel?: string;
previewLabel?: string;
};
export function InvoiceEditorSectionTabs({
value,
onChange,
editLabel = "Edit",
previewLabel = "PDF preview",
}: InvoiceEditorSectionTabsProps) {
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
<FilterChip
label={editLabel}
active={value === "edit"}
onPress={() => onChange("edit")}
/>
<FilterChip
label={previewLabel}
active={value === "preview"}
onPress={() => onChange("preview")}
/>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
});
+180
View File
@@ -0,0 +1,180 @@
import { useMemo } from "react";
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView } from "react-native-webview";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import {
canPreviewPdfInput,
type InvoicePdfPreviewInput,
} from "@/lib/invoice-pdf-input";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type InvoicePdfPreviewProps = {
input: InvoicePdfPreviewInput | null;
height?: number;
style?: StyleProp<ViewStyle>;
};
function buildPdfHtml(contentType: string, base64: string) {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0" />
<style>
html, body { margin: 0; height: 100%; background: #525659; }
embed { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<embed src="data:${contentType};base64,${base64}" type="application/pdf" />
</body>
</html>`;
}
export function InvoicePdfPreview({
input,
height = 560,
style,
}: InvoicePdfPreviewProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createPreviewStyles);
const enabled = canPreviewPdfInput(input);
const { data, isLoading, isFetching, error, refetch } =
api.invoices.previewPdf.useQuery(input!, {
enabled,
refetchOnWindowFocus: false,
staleTime: 5_000,
});
const html = useMemo(() => {
if (!data?.base64) return null;
return buildPdfHtml(data.contentType, data.base64);
}, [data]);
if (!enabled) {
return (
<View style={[styles.frame, { height }, style]}>
<Text style={styles.placeholder}>
Select a client and add a description to every line item to preview the
PDF.
</Text>
</View>
);
}
if (isLoading && !html) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>Generating preview</Text>
</View>
);
}
if (error) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<Text style={styles.errorText}>{error.message}</Text>
<Pressable accessibilityRole="button" onPress={() => void refetch()}>
<Text style={[styles.retry, { color: colors.primary }]}>Try again</Text>
</Pressable>
</View>
);
}
if (!html) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<Text style={styles.placeholder}>PDF preview will appear here.</Text>
</View>
);
}
return (
<View style={[styles.wrapper, style]}>
{isFetching ? (
<View style={styles.refreshing}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null}
<View style={[styles.frame, { height }]}>
<WebView
originWhitelist={["*"]}
source={{ html }}
style={styles.webview}
scrollEnabled
showsVerticalScrollIndicator
showsHorizontalScrollIndicator={false}
/>
</View>
</View>
);
}
const createPreviewStyles = (colors: ThemeColors) =>
StyleSheet.create({
wrapper: {
gap: spacing.xs,
},
frame: {
overflow: "hidden",
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
webview: {
flex: 1,
backgroundColor: "transparent",
},
centered: {
alignItems: "center",
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
placeholder: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
textAlign: "center",
padding: spacing.lg,
},
loadingText: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
},
errorText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.destructive,
textAlign: "center",
},
retry: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
refreshing: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
zIndex: 2,
borderRadius: radii.pill,
backgroundColor: colors.card,
padding: spacing.xs,
},
});
+90
View File
@@ -0,0 +1,90 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type InvoiceTotalsProps = {
subtotal: string;
taxLabel?: string;
taxAmount?: string;
total: string;
};
export function InvoiceTotals({
subtotal,
taxLabel,
taxAmount,
total,
}: InvoiceTotalsProps) {
const { colors } = useAppTheme();
return (
<View style={[styles.totals, { borderTopColor: colors.border }]}>
<TotalRow label="Subtotal" value={subtotal} />
{taxLabel && taxAmount ? <TotalRow label={taxLabel} value={taxAmount} /> : null}
<TotalRow label="Total" value={total} bold />
</View>
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={styles.row}>
<Text
style={[
styles.label,
{ color: colors.mutedForeground },
bold && styles.bold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
styles.value,
{ color: colors.foreground },
bold && styles.bold,
]}
>
{value}
</Text>
</View>
);
}
const styles = StyleSheet.create({
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
gap: 6,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
bold: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
+274
View File
@@ -0,0 +1,274 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { CompactDateField } from "@/components/ui/CompactDateField";
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatShortDate } from "@/lib/format";
export type EditableLineItem = {
id?: string;
date: Date;
description: string;
hours: string;
rate: string;
};
type LineItemEditorProps = {
item: EditableLineItem;
index: number;
currency: string;
onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void;
readOnly?: boolean;
isLast?: boolean;
};
export function LineItemsTableHeader() {
const { colors } = useAppTheme();
return (
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}>
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
Description
</Text>
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
Date
</Text>
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
Hrs
</Text>
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
Rate
</Text>
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
Amt
</Text>
<View style={headerStyles.spacer} />
</View>
);
}
export function LineItemEditor({
item,
index,
currency,
onChange,
onRemove,
readOnly = false,
isLast = false,
}: LineItemEditorProps) {
const { colors } = useAppTheme();
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
const amount = hours * rate;
if (readOnly) {
return (
<View
style={[
styles.row,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
]}
>
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
<View style={styles.descCol}>
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}>
{item.description.trim() || "Untitled line"}
</Text>
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</Text>
</View>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
</View>
);
}
return (
<View
style={[
styles.editBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
]}
>
<View style={styles.editTop}>
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
<TextInput
value={item.description}
onChangeText={(description) => onChange({ description })}
placeholder="What was done?"
placeholderTextColor={colors.mutedForeground}
style={[
styles.descriptionInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
/>
</View>
<View style={styles.metricsRow}>
<CompactDateField
value={item.date}
onChange={(date) => onChange({ date })}
style={styles.dateField}
/>
<CompactStepperInput
value={item.hours}
onChangeText={(hours) => onChange({ hours })}
step={0.25}
style={styles.hoursField}
/>
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}>
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
<TextInput
value={item.rate}
onChangeText={(rate) => onChange({ rate })}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.rateInput, { color: colors.foreground }]}
/>
</View>
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Remove line item"
onPress={onRemove}
hitSlop={8}
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}
>
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
</Pressable>
</View>
</View>
);
}
const headerStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingBottom: spacing.xs,
marginBottom: spacing.xs,
borderBottomWidth: 1,
},
cell: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.4,
},
desc: { flex: 1, paddingLeft: 22 },
date: { width: 72 },
hours: { width: 88, textAlign: "center" },
rate: { width: 72, textAlign: "center" },
amt: { width: 64, textAlign: "right" },
spacer: { width: 32 },
});
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
},
editBlock: {
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editTop: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
index: {
width: 18,
fontFamily: fonts.bodySemiBold,
fontSize: 12,
textAlign: "center",
},
descCol: {
flex: 1,
gap: 2,
},
readTitle: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
lineHeight: 18,
},
readSub: {
fontFamily: fonts.body,
fontSize: 11,
},
descriptionInput: {
flex: 1,
minHeight: 36,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.sm,
fontFamily: fonts.body,
fontSize: 14,
paddingVertical: 6,
},
metricsRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingLeft: 22,
},
dateField: {
width: 72,
},
hoursField: {
width: 88,
},
rateField: {
width: 72,
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: radii.md,
minHeight: 36,
paddingHorizontal: spacing.xs,
},
ratePrefix: {
fontFamily: fonts.body,
fontSize: 13,
},
rateInput: {
flex: 1,
fontFamily: fonts.body,
fontSize: 13,
paddingVertical: 4,
textAlign: "right",
},
amount: {
width: 64,
fontFamily: fonts.bodySemiBold,
fontSize: 13,
textAlign: "right",
},
amountEdit: {
fontSize: 12,
},
remove: {
width: 32,
height: 36,
alignItems: "center",
justifyContent: "center",
},
removePressed: {
opacity: 0.65,
},
});
+941
View File
@@ -0,0 +1,941 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import {
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
compact?: boolean;
header?: ReactNode;
};
type ClientRow = {
id: string;
name: string;
defaultHourlyRate: number | null;
currency?: string;
};
type StartMode = "now" | "at" | "ago";
const AGO_PRESETS = [
{ label: "15m", minutes: 15 },
{ label: "30m", minutes: 30 },
{ label: "1h", minutes: 60 },
{ label: "2h", minutes: 120 },
{ label: "4h", minutes: 240 },
] as const;
function clientRateText(client: ClientRow | undefined): string {
return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
}
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
compact = false,
header,
}: TimeClockPanelProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles);
const { activeAccountId } = useAccounts();
const utils = api.useUtils();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const clientsQuery = api.clients.getAll.useQuery();
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
const [agoMinutes, setAgoMinutes] = useState(60);
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
const clients = clientsQuery.data ?? [];
const activeClientId = running?.clientId ?? clientId;
const billableQuery = api.invoices.getBillable.useQuery(
activeClientId ? { clientId: activeClientId } : undefined,
);
const billableInvoices = billableQuery.data ?? [];
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const entriesQuery = api.timeEntries.getAll.useQuery();
const recentClientIds = useMemo(() => {
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of entriesQuery.data ?? []) {
if (entry.clientId && !seen.has(entry.clientId)) {
seen.add(entry.clientId);
ids.push(entry.clientId);
if (ids.length >= 2) break;
}
}
return ids;
}, [entriesQuery.data]);
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => {
await utils.timeEntries.getRunning.invalidate();
},
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
onError: (err) => {
Alert.alert("Could not update timer", err.message);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
if (running?.clientId && activeAccountId) {
await setLastTimeClockClientId(activeAccountId, running.clientId);
}
const message = describeClockOutOutcome({
outcome: data.outcome,
hours: data.hours,
rate: data.rate,
invoice: data.invoice,
});
Alert.alert(
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
message,
);
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
},
});
useEffect(() => {
if (!activeAccountId) {
setPrefsLoaded(true);
return;
}
setPrefsLoaded(false);
void getLastTimeClockClientId(activeAccountId).then((id) => {
setStoredLastClientId(id);
setPrefsLoaded(true);
});
}, [activeAccountId]);
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description?.trim() ?? "");
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
useEffect(() => {
if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId);
if (!client?.defaultHourlyRate) return;
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
useEffect(() => {
if (running || defaultClientId || initialClientResolved) return;
if (!prefsLoaded || clients.length === 0) return;
const preferredId =
storedLastClientId && clients.some((client) => client.id === storedLastClientId)
? storedLastClientId
: recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
if (preferredId) {
const client = clients.find((c) => c.id === preferredId);
setClientId(preferredId);
setRateText(clientRateText(client));
}
setInitialClientResolved(true);
}, [
clients,
defaultClientId,
initialClientResolved,
prefsLoaded,
recentClientIds,
running,
storedLastClientId,
]);
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
const ids: string[] = [];
const add = (id: string | null | undefined) => {
if (!id || ids.includes(id)) return;
if (!clients.some((client) => client.id === id)) return;
ids.push(id);
};
add(storedLastClientId);
for (const id of recentClientIds) add(id);
setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity({ ...running, description }, seconds);
};
sync();
const interval = setInterval(sync, 15_000);
return () => clearInterval(interval);
}, [running, description]);
const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
rateText,
selectedClient?.defaultHourlyRate,
);
const displayRate = running
? (running.rate ?? effectiveRate ?? 0)
: (effectiveRate ?? 0);
const featuredClients = useMemo(
() =>
featuredClientIds
.map((id) => clients.find((client) => client.id === id))
.filter((client) => client != null),
[clients, featuredClientIds],
);
const moreClients = useMemo(() => {
const featuredIds = new Set(featuredClientIds);
return clients
.filter((client) => !featuredIds.has(client.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [clients, featuredClientIds]);
const resolvedStartAt = useMemo(() => {
if (startMode === "now") return new Date();
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
return startedAt;
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string; start?: string } = {};
if (!clientId) next.clientId = "Choose a client to start";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
if (startMode === "ago" && agoMinutes <= 0) {
next.start = "Enter how long ago you started";
}
return next;
}, [agoMinutes, clientId, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
const optionsSummary = useMemo(() => {
const rate =
effectiveRate ??
(selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
return `${rateLabel} · ${startLabel}`;
}, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
const todayEntries = useMemo(
() =>
(entriesQuery.data ?? []).filter(
(entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
),
[entriesQuery.data, todayStart],
);
async function persistClientChoice(nextClientId: string, syncState = false) {
if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId);
if (syncState) setStoredLastClientId(nextClientId);
}
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (!featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
void persistClientChoice(nextClientId);
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
if (mode === "now") {
setStartedAt(new Date());
return;
}
if (mode === "ago") {
setStartedAt(startedAtFromMinutesAgo(agoMinutes));
return;
}
setStartedAt((current) =>
Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
);
}
function selectAgoPreset(minutes: number) {
setStartMode("ago");
setAgoMinutes(minutes);
setAgoMinutesText(String(minutes));
setStartedAt(startedAtFromMinutesAgo(minutes));
}
function handleAgoMinutesChange(text: string) {
setAgoMinutesText(text);
const parsed = Number(text);
if (!Number.isNaN(parsed) && parsed > 0) {
setAgoMinutes(parsed);
setStartedAt(startedAtFromMinutesAgo(parsed));
}
}
async function handleClockIn() {
if (!canClockIn) {
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
return;
}
try {
const backdated =
startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
await persistClientChoice(clientId);
setStartMode("now");
setStartedAt(new Date());
setAgoMinutes(60);
setAgoMinutesText("60");
} catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleRunningClientChange(nextClientId: string) {
if (!running) return;
setClientId(nextClientId);
setInvoiceId("");
try {
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
const client = clients.find((c) => c.id === nextClientId);
setRateText(clientRateText(client));
await persistClientChoice(nextClientId);
} catch {
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
}
}
async function handleRunningInvoiceChange(nextInvoiceId: string) {
if (!running) return;
const previous = invoiceId;
setInvoiceId(nextInvoiceId);
try {
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
} catch {
setInvoiceId(previous);
}
}
if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />;
}
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null,
displayRate ? `$${displayRate}/hr` : null,
]
.filter(Boolean)
.join(" · ");
const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningClientChange(client.id);
else selectClient(client.id);
}}
/>
);
}
return (
<TabScrollView
style={styles.scroll}
header={header}
refreshControl={
<RefreshControl
refreshing={runningQuery.isRefetching}
onRefresh={() => {
void runningQuery.refetch();
void clientsQuery.refetch();
void billableQuery.refetch();
void entriesQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabel}>Running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""}
</Text>
</>
) : (
<Text style={styles.idleHint}>
Choose a client and clock in. A draft invoice is created automatically if needed.
</Text>
)}
</View>
</GlassSurface>
) : null}
<GlassSurface style={styles.setupCard}>
<Input
label="Title"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
returnKeyType="done"
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
Add a client first to start tracking time.
</Text>
) : (
<>
<View style={styles.chipWrap}>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => {
if (controlsDisabled) return;
setClientsExpanded((open) => !open);
}}
/>
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
{clockInErrors.clientId && !running ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange("");
else setInvoiceId("");
}}
/>
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
<FilterChip
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange(invoice.id);
else setInvoiceId(invoice.id);
}}
/>
);
})}
</View>
</View>
) : null}
{!running && clientId ? (
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
{!optionsExpanded ? (
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
) : null}
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? (
<View style={styles.optionsBody}>
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder={
selectedClient?.defaultHourlyRate != null
? String(selectedClient.defaultHourlyRate)
: "0"
}
error={clockInErrors.rate}
/>
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
<Text style={styles.rateHint}>
Defaults to{" "}
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
</Text>
) : null}
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Now"
active={startMode === "now"}
onPress={() => selectStartMode("now")}
/>
<FilterChip
label="Pick time"
active={startMode === "at"}
onPress={() => selectStartMode("at")}
/>
<FilterChip
label="Time ago"
active={startMode === "ago"}
onPress={() => selectStartMode("ago")}
/>
</View>
{startMode === "at" ? (
<DateTimeField
label="Started at"
value={startedAt}
maximumDate={new Date()}
onChange={(date) => {
setStartedAt(date);
setStartMode("at");
}}
/>
) : null}
{startMode === "ago" ? (
<View style={styles.agoBlock}>
<View style={styles.chipWrap}>
{AGO_PRESETS.map((preset) => (
<FilterChip
key={preset.label}
label={preset.label}
active={agoMinutes === preset.minutes}
onPress={() => selectAgoPreset(preset.minutes)}
/>
))}
</View>
<View style={styles.agoCustomRow}>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
Started
</Text>
<TextInput
value={agoMinutesText}
onChangeText={handleAgoMinutesChange}
keyboardType="number-pad"
style={[
styles.agoInput,
{ color: colors.foreground, borderColor: colors.border },
]}
/>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
min ago
</Text>
</View>
</View>
) : null}
{clockInErrors.start ? (
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
) : null}
</View>
) : null}
</View>
) : null}
</GlassSurface>
{running ? (
<Button
title="Clock out"
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
) : (
<Button
title="Clock in"
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
onPress={handleClockIn}
/>
)}
{todayEntries.length > 0 ? (
<Card title="Today">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
</Text>
</View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</>
);
if (!entry.invoice) {
return (
<View key={entry.id} style={styles.entryRow}>
{row}
</View>
);
}
return (
<Pressable
key={entry.id}
accessibilityRole="button"
accessibilityLabel={`View invoice ${invoiceLabel}`}
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
>
{row}
</Pressable>
);
})}
</Card>
) : null}
</TabScrollView>
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
},
hero: {
padding: spacing.md,
gap: spacing.sm,
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
},
heroLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.4,
},
timerValue: {
fontSize: 52,
lineHeight: 56,
fontFamily: fonts.mono,
color: colors.foreground,
fontVariant: ["tabular-nums"],
},
runningMeta: {
fontSize: 13,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
setupCard: {
padding: spacing.md,
gap: spacing.lg,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
titleInput: {
minHeight: 44,
textAlignVertical: "center",
},
titleInputPlaceholder: {
textAlign: "center",
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabelInset: {
marginTop: spacing.sm,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
moreClientsWrap: {
paddingTop: spacing.xs,
},
emptyClients: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
fieldError: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.destructive,
},
optionsToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
optionsTogglePressed: {
opacity: 0.7,
},
optionsToggleText: {
flex: 1,
gap: 2,
},
optionsToggleLabel: {
fontSize: 14,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
optionsToggleSummary: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
optionsChevron: {
fontSize: 20,
lineHeight: 22,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
optionsBody: {
gap: spacing.md,
paddingBottom: spacing.xs,
},
rateHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
agoBlock: {
gap: spacing.sm,
},
agoCustomRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
agoCustomLabel: {
fontSize: 14,
fontFamily: fonts.body,
},
agoInput: {
minWidth: 56,
fontSize: 16,
fontFamily: fonts.bodySemiBold,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 4,
textAlign: "center",
},
entryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
entryRowPressed: {
opacity: 0.65,
},
entryMeta: {
flex: 1,
gap: 2,
},
entryTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
entrySub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
entryHours: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
});
+95
View File
@@ -0,0 +1,95 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
type PressableProps,
type ViewStyle,
} from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
type ButtonProps = PressableProps & {
title: string;
loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle;
};
export function Button({
title,
loading,
variant = "primary",
disabled,
style,
...props
}: ButtonProps) {
const { colors } = useAppTheme();
const isDisabled = disabled || loading;
const variantStyles = {
primary: { backgroundColor: colors.primary },
secondary: {
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
},
danger: {
backgroundColor: colors.destructiveBg,
borderWidth: 1,
borderColor: colors.destructive,
},
ghost: { backgroundColor: "transparent" },
} as const;
const labelStyles = {
primary: { color: colors.primaryForeground },
secondary: { color: colors.foreground },
danger: { color: colors.destructive },
ghost: { color: colors.foreground },
} as const;
return (
<Pressable
accessibilityRole="button"
disabled={isDisabled}
style={({ pressed }) => [
styles.base,
variantStyles[variant],
pressed && !isDisabled && styles.pressed,
isDisabled && styles.disabled,
style,
]}
{...props}
>
{loading ? (
<ActivityIndicator
color={variant === "primary" ? colors.primaryForeground : colors.primary}
/>
) : (
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
minHeight: 40,
borderRadius: radii.lg,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.md,
},
pressed: {
opacity: 0.92,
},
disabled: {
opacity: 0.55,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
});
+38
View File
@@ -0,0 +1,38 @@
import { StyleSheet, Text, View, type StyleProp, type ViewProps, type ViewStyle } from "react-native";
import { GlassSurface } from "@/components/GlassSurface";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, spacing } from "@/constants/theme";
import { radius } from "@/lib/beenvoice-theme";
type CardProps = ViewProps & {
title?: string;
style?: StyleProp<ViewStyle>;
variant?: "card" | "stat";
};
export function Card({ title, style, children, variant = "card", ...props }: CardProps) {
const { colors } = useAppTheme();
return (
<GlassSurface style={StyleSheet.flatten(style)} radius={radius.lg} variant={variant}>
<View style={styles.inner} {...props}>
{title ? <Text style={[styles.title, { color: colors.foreground }]}>{title}</Text> : null}
{children}
</View>
</GlassSurface>
);
}
const styles = StyleSheet.create({
inner: {
paddingHorizontal: 20,
paddingVertical: spacing.md,
gap: spacing.sm,
alignItems: "stretch",
},
title: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
});
+174
View File
@@ -0,0 +1,174 @@
import DateTimePicker, {
type DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { fonts, radii } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatShortDate } from "@/lib/format";
type CompactDateFieldProps = {
value: Date;
onChange: (date: Date) => void;
style?: StyleProp<ViewStyle>;
maximumDate?: Date;
minimumDate?: Date;
};
export function CompactDateField({
value,
onChange,
style,
maximumDate = new Date(2100, 0, 1),
minimumDate,
}: CompactDateFieldProps) {
const { colors, isDark } = useAppTheme();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
function applyDate(next: Date) {
const clamped =
next.getTime() > maximumDate.getTime()
? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime()
? minimumDate
: next;
onChange(clamped);
}
function handleChange(event: DateTimePickerEvent, selected?: Date) {
if (Platform.OS === "android") {
setOpen(false);
if (event.type === "set" && selected) applyDate(selected);
return;
}
if (selected) setDraft(selected);
}
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Change date"
onPress={() => {
setDraft(value);
setOpen(true);
}}
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
pressed && styles.pressed,
style,
]}
>
<Text style={[styles.value, { color: colors.foreground }]} numberOfLines={1}>
{formatShortDate(value)}
</Text>
<Ionicons name="chevron-down" size={12} color={colors.mutedForeground} />
</Pressable>
{Platform.OS === "ios" ? (
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Pressable onPress={() => setOpen(false)}>
<Text style={[styles.action, { color: colors.mutedForeground }]}>Cancel</Text>
</Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Date</Text>
<Pressable
onPress={() => {
applyDate(draft);
setOpen(false);
}}
>
<Text style={[styles.action, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={draft}
mode="date"
display="spinner"
maximumDate={maximumDate}
minimumDate={minimumDate}
themeVariant={isDark ? "dark" : "light"}
onChange={handleChange}
/>
</Pressable>
</Pressable>
</Modal>
) : open ? (
<DateTimePicker
value={draft}
mode="date"
maximumDate={maximumDate}
minimumDate={minimumDate}
onChange={handleChange}
/>
) : null}
</>
);
}
const styles = StyleSheet.create({
trigger: {
minHeight: 36,
borderWidth: 1,
borderRadius: radii.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 8,
gap: 2,
},
pressed: {
opacity: 0.9,
},
value: {
flex: 1,
fontFamily: fonts.body,
fontSize: 12,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.lg,
borderTopRightRadius: radii.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
sheetTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
action: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
});
+92
View File
@@ -0,0 +1,92 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, TextInput, View, type StyleProp, type ViewStyle } from "react-native";
import { fonts, radii } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type CompactStepperInputProps = {
value: string;
onChangeText: (value: string) => void;
step?: number;
min?: number;
style?: StyleProp<ViewStyle>;
};
export function CompactStepperInput({
value,
onChangeText,
step = 0.25,
min = 0,
style,
}: CompactStepperInputProps) {
const { colors } = useAppTheme();
function adjust(delta: number) {
const current = Number.parseFloat(value) || 0;
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
onChangeText(Number.isInteger(next) ? String(next) : String(next));
}
return (
<View
style={[
styles.field,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
style,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Decrease hours"
hitSlop={4}
onPress={() => adjust(-step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
>
<Ionicons name="remove" size={14} color={colors.foreground} />
</Pressable>
<TextInput
value={value}
onChangeText={onChangeText}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.input, { color: colors.foreground }]}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel="Increase hours"
hitSlop={4}
onPress={() => adjust(step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
>
<Ionicons name="add" size={14} color={colors.foreground} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
field: {
minHeight: 36,
borderWidth: 1,
borderRadius: radii.md,
flexDirection: "row",
alignItems: "center",
},
stepButton: {
width: 28,
height: 36,
alignItems: "center",
justifyContent: "center",
},
pressed: {
opacity: 0.65,
},
input: {
flex: 1,
textAlign: "center",
fontSize: 13,
fontFamily: fonts.bodyMedium,
paddingVertical: 4,
},
});
+184
View File
@@ -0,0 +1,184 @@
import { Ionicons } from "@expo/vector-icons";
import DateTimePicker, {
type DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { useState } from "react";
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDate, formatDateTime } from "@/lib/format";
type DateTimeFieldProps = {
label: string;
value: Date;
mode?: "date" | "datetime";
maximumDate?: Date;
minimumDate?: Date;
onChange: (date: Date) => void;
};
export function DateTimeField({
label,
value,
mode = "datetime",
maximumDate = new Date(),
minimumDate,
onChange,
}: DateTimeFieldProps) {
const { colors, isDark } = useAppTheme();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
function openPicker() {
setDraft(value);
setOpen(true);
}
function applyDate(next: Date) {
const clamped =
next.getTime() > maximumDate.getTime()
? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime()
? minimumDate
: next;
onChange(clamped);
}
function handleChange(event: DateTimePickerEvent, selected?: Date) {
if (Platform.OS === "android") {
setOpen(false);
if (event.type === "set" && selected) {
applyDate(selected);
}
return;
}
if (selected) {
setDraft(selected);
}
}
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Pressable
accessibilityRole="button"
onPress={openPicker}
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
pressed && styles.triggerPressed,
]}
>
<Text style={[styles.value, { color: colors.foreground }]}>
{mode === "date" ? formatDate(value) : formatDateTime(value)}
</Text>
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
</Pressable>
{Platform.OS === "ios" ? (
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Pressable onPress={() => setOpen(false)}>
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
</Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Pressable
onPress={() => {
applyDate(draft);
setOpen(false);
}}
>
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={draft}
mode={mode}
display="spinner"
maximumDate={maximumDate}
minimumDate={minimumDate}
themeVariant={isDark ? "dark" : "light"}
onChange={handleChange}
/>
</Pressable>
</Pressable>
</Modal>
) : open ? (
<DateTimePicker
value={draft}
mode={mode}
maximumDate={maximumDate}
minimumDate={minimumDate}
onChange={handleChange}
/>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.xs,
alignSelf: "stretch",
width: "100%",
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
alignSelf: "stretch",
width: "100%",
borderWidth: 1,
borderRadius: radii.lg,
paddingHorizontal: spacing.md,
minHeight: 48,
paddingVertical: spacing.sm,
},
triggerPressed: {
opacity: 0.92,
},
value: {
fontSize: 15,
fontFamily: fonts.body,
flex: 1,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.lg,
borderTopRightRadius: radii.lg,
paddingBottom: spacing.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
},
sheetTitle: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
sheetAction: {
fontSize: 15,
fontFamily: fonts.bodyMedium,
},
});
+66
View File
@@ -0,0 +1,66 @@
import {
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
type InputProps = TextInputProps & {
label: string;
error?: string;
required?: boolean;
};
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}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{
borderColor: colors.border,
color: colors.foreground,
backgroundColor: colors.cardGlass,
},
error && { borderColor: colors.destructive },
style,
]}
{...props}
/>
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
input: {
minHeight: 40,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: fonts.body,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
});
+218
View File
@@ -0,0 +1,218 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export type SelectOption = {
label: string;
value: string;
};
type SelectFieldProps = {
label: string;
placeholder: string;
value: string;
options: SelectOption[];
disabled?: boolean;
required?: boolean;
error?: string;
onValueChange: (value: string) => void;
};
export function SelectField({
label,
placeholder,
value,
options,
disabled,
required,
error,
onValueChange,
}: SelectFieldProps) {
const { colors } = useAppTheme();
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={() => setOpen(true)}
style={({ pressed }) => [
styles.trigger,
{
borderColor: error ? colors.destructive : colors.borderGlass,
backgroundColor: colors.cardGlass,
},
disabled && styles.triggerDisabled,
pressed && !disabled && styles.triggerPressed,
]}
>
<Text
style={[
styles.triggerText,
{ color: colors.foreground },
!selected && { color: colors.mutedForeground },
]}
numberOfLines={1}
>
{selected?.label ?? placeholder}
</Text>
<Ionicons name="chevron-down" size={18} color={colors.mutedForeground} />
</Pressable>
<Modal
animationType="slide"
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 }]}>{label}</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{options.map((option) => {
const isSelected = option.value === value;
return (
<Pressable
key={option.value || "__empty__"}
accessibilityRole="button"
onPress={() => {
onValueChange(option.value);
setOpen(false);
}}
style={({ pressed }) => [
styles.option,
isSelected && { backgroundColor: colors.muted },
pressed && styles.optionPressed,
]}
>
<Text
style={[
styles.optionText,
{ color: colors.foreground },
isSelected && styles.optionTextSelected,
]}
numberOfLines={2}
>
{option.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
trigger: {
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
triggerDisabled: {
opacity: 0.55,
},
triggerPressed: {
opacity: 0.92,
},
triggerText: {
flex: 1,
fontSize: 14,
fontFamily: fonts.body,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.45)",
},
sheet: {
maxHeight: "70%",
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
paddingBottom: spacing.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sheetTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
},
done: {
fontSize: 15,
fontFamily: fonts.bodyMedium,
},
option: {
minHeight: 48,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
optionPressed: {
opacity: 0.9,
},
optionText: {
flex: 1,
fontSize: 15,
fontFamily: fonts.body,
},
optionTextSelected: {
fontFamily: fonts.bodySemiBold,
},
});
+106
View File
@@ -0,0 +1,106 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type StepperInputProps = Omit<TextInputProps, "value" | "onChangeText"> & {
label: string;
value: string;
onChangeText: (value: string) => void;
step?: number;
min?: number;
};
export function StepperInput({
label,
value,
onChangeText,
step = 0.25,
min = 0,
...props
}: StepperInputProps) {
const { colors } = useAppTheme();
function adjust(delta: number) {
const current = Number.parseFloat(value) || 0;
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
onChangeText(Number.isInteger(next) ? String(next) : String(next));
}
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<View
style={[
styles.field,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Decrease ${label}`}
hitSlop={6}
onPress={() => adjust(-step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
>
<Ionicons name="remove" size={18} color={colors.foreground} />
</Pressable>
<TextInput
value={value}
onChangeText={onChangeText}
keyboardType="decimal-pad"
placeholderTextColor={colors.mutedForeground}
style={[styles.input, { color: colors.foreground }]}
{...props}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increase ${label}`}
hitSlop={6}
onPress={() => adjust(step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
>
<Ionicons name="add" size={18} color={colors.foreground} />
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
field: {
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.xs,
},
stepButton: {
width: 36,
height: 36,
alignItems: "center",
justifyContent: "center",
borderRadius: radii.sm,
},
stepPressed: {
opacity: 0.65,
},
input: {
flex: 1,
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
paddingVertical: spacing.sm,
},
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Mobile palette + spacing — re-exports canonical tokens from lib/beenvoice-theme.ts
*/
import {
background,
border,
border50,
foreground,
muted,
mutedForeground,
primary,
primaryForeground,
radius,
shadowMd,
shadowSm,
surface80,
} from "@/lib/beenvoice-theme";
export const colors = {
background,
backgroundMuted: muted,
foreground,
card: background,
cardGlass: surface80,
primary,
primaryForeground,
muted,
mutedForeground,
border,
borderGlass: border50,
secondary: border,
secondaryForeground: primary,
accent: muted,
destructive: "#EF4444",
destructiveForeground: primaryForeground,
destructiveBg: "#FEF2F2",
success: "#16A34A",
successBg: "#F0FDF4",
warning: "#D97706",
warningBg: "#FFFBEB",
brand: primary,
brandDark: foreground,
text: foreground,
textMuted: mutedForeground,
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
};
export const radii = {
sm: radius.sm,
md: radius.md,
lg: radius.lg,
xl: radius.xl,
card: radius.lg,
pill: radius.pill,
};
export const fonts = {
heading: "PlayfairDisplay_700Bold",
headingSemi: "PlayfairDisplay_600SemiBold",
body: "Inter_400Regular",
bodyMedium: "Inter_500Medium",
bodySemiBold: "Inter_600SemiBold",
bodyBold: "Inter_700Bold",
mono: "SpaceMono",
} as const;
export const layout = {
/** Bottom inset when tab bar is `position: absolute` */
tabBarInset: 96,
};
+248
View File
@@ -0,0 +1,248 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { LoadingScreen } from "@/components/LoadingScreen";
import {
authStoragePrefix,
buildAccountId,
loadAccounts,
loadActiveAccountId,
loadDraftInstanceUrl,
saveAccounts,
saveActiveAccountId,
saveDraftInstanceUrl,
type SavedAccount,
} from "@/lib/accounts";
import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
export type RemoveAccountResult = {
wasActive: boolean;
remainingCount: number;
};
type AccountsContextValue = {
accounts: SavedAccount[];
activeAccount: SavedAccount | null;
activeAccountId: string | null;
apiUrl: string;
authStoragePrefix: string;
setInstanceUrl: (url: string) => Promise<string>;
switchAccount: (accountId: string) => Promise<void>;
registerAccount: (input: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<SavedAccount>;
removeAccount: (accountId: string) => Promise<RemoveAccountResult>;
refreshAccounts: () => Promise<void>;
clearActiveAccount: () => Promise<void>;
};
const AccountsContext = createContext<AccountsContextValue | null>(null);
export function AccountsProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [accounts, setAccounts] = useState<SavedAccount[]>([]);
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
const [apiUrl, setApiUrl] = useState(getApiUrl);
useEffect(() => {
Promise.all([loadAccounts(), loadActiveAccountId(), loadDraftInstanceUrl()])
.then(([storedAccounts, activeId, draftUrl]) => {
setAccounts(storedAccounts);
const active = storedAccounts.find((account) => account.id === activeId) ?? null;
if (active) {
setActiveAccountId(active.id);
setRuntimeApiUrl(active.instanceUrl);
} else if (draftUrl) {
setRuntimeApiUrl(draftUrl);
} else {
setRuntimeApiUrl(DEFAULT_API_URL);
}
setApiUrl(getApiUrl());
})
.finally(() => setReady(true));
}, []);
const activeAccount = useMemo(
() => accounts.find((account) => account.id === activeAccountId) ?? null,
[accounts, activeAccountId],
);
const setInstanceUrl = useCallback(
async (url: string) => {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
}
if (activeAccount) {
const nextAccounts = accounts.map((account) =>
account.id === activeAccount.id ? { ...account, instanceUrl: normalized } : account,
);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
} else {
await saveDraftInstanceUrl(normalized);
}
await saveStoredInstanceUrl(normalized);
setRuntimeApiUrl(normalized);
setApiUrl(normalized);
return normalized;
},
[activeAccount, accounts],
);
const switchAccount = useCallback(
async (accountId: string) => {
const account = accounts.find((entry) => entry.id === accountId);
if (!account) return;
const nextAccounts = accounts.map((entry) =>
entry.id === accountId ? { ...entry, lastUsedAt: Date.now() } : entry,
);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
await saveActiveAccountId(accountId);
setActiveAccountId(accountId);
setRuntimeApiUrl(account.instanceUrl);
setApiUrl(account.instanceUrl);
},
[accounts],
);
const registerAccount = useCallback(
async (input: { instanceUrl: string; userId: string; email: string; name: string }) => {
const id = buildAccountId(input.instanceUrl, input.userId);
const existing = accounts.find((account) => account.id === id);
const account: SavedAccount = {
id,
instanceUrl: input.instanceUrl,
userId: input.userId,
email: input.email,
name: input.name,
lastUsedAt: Date.now(),
};
const nextAccounts = existing
? accounts.map((entry) => (entry.id === id ? account : entry))
: [account, ...accounts.filter((entry) => entry.id !== id)];
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
await saveActiveAccountId(id);
await saveDraftInstanceUrl(null);
setActiveAccountId(id);
setRuntimeApiUrl(input.instanceUrl);
setApiUrl(input.instanceUrl);
await saveStoredInstanceUrl(input.instanceUrl);
return account;
},
[accounts],
);
const removeAccount = useCallback(
async (accountId: string): Promise<RemoveAccountResult> => {
const wasActive = activeAccountId === accountId;
await clearAuthStorage(authStoragePrefix(accountId));
await clearTimeClockPrefsForAccount(accountId);
const nextAccounts = accounts.filter((account) => account.id !== accountId);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
if (wasActive) {
const fallback = nextAccounts[0] ?? null;
await saveActiveAccountId(fallback?.id ?? null);
setActiveAccountId(fallback?.id ?? null);
if (fallback) {
setRuntimeApiUrl(fallback.instanceUrl);
setApiUrl(fallback.instanceUrl);
}
}
return { wasActive, remainingCount: nextAccounts.length };
},
[accounts, activeAccountId],
);
const refreshAccounts = useCallback(async () => {
const stored = await loadAccounts();
const refreshed = await Promise.all(
stored.map(async (account) => {
const user = await readStoredSessionUser(authStoragePrefix(account.id));
if (!user?.name && !user?.email) return account;
return {
...account,
name: user.name?.trim() || account.name,
email: user.email?.trim() || account.email,
};
}),
);
setAccounts(refreshed);
await saveAccounts(refreshed);
}, []);
const clearActiveAccount = useCallback(async () => {
await saveActiveAccountId(null);
setActiveAccountId(null);
}, []);
const value = useMemo(
() => ({
accounts,
activeAccount,
activeAccountId,
apiUrl,
authStoragePrefix: activeAccount
? authStoragePrefix(activeAccount.id)
: "beenvoice:guest",
setInstanceUrl,
switchAccount,
registerAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
}),
[
accounts,
activeAccount,
activeAccountId,
apiUrl,
setInstanceUrl,
switchAccount,
registerAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
],
);
if (!ready) {
return <LoadingScreen message="Starting…" />;
}
return <AccountsContext.Provider value={value}>{children}</AccountsContext.Provider>;
}
export function useAccounts() {
const ctx = useContext(AccountsContext);
if (!ctx) throw new Error("useAccounts must be used within AccountsProvider");
return ctx;
}
+301
View File
@@ -0,0 +1,301 @@
import * as LocalAuthentication from "expo-local-authentication";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import {
clearStoredPin,
getAppLockEnabled,
getBiometricEnabled,
getStoredPin,
isValidPin,
setAppLockEnabled,
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
import { hasPendingShortcut } from "@/lib/shortcut-queue";
type AppLockContextValue = {
enabled: boolean;
biometricEnabled: boolean;
hasPin: boolean;
isLocked: boolean;
biometricAvailable: boolean;
biometricLabel: string;
unlockWithPin: (pin: string) => Promise<boolean>;
unlockWithBiometric: () => Promise<boolean>;
enableLock: (pin: string) => Promise<void>;
disableLock: (pin: string) => Promise<boolean>;
changePin: (currentPin: string, nextPin: string) => Promise<boolean>;
setUseBiometric: (enabled: boolean) => Promise<void>;
lock: () => void;
};
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);
const [isLocked, setIsLocked] = useState(false);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
const wasBackgrounded = useRef(false);
const biometricUnlockInProgress = useRef(false);
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, shortcutPending] =
await Promise.all([
getAppLockEnabled(accountId),
getStoredPin(accountId),
getBiometricEnabled(accountId),
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
hasPendingShortcut(),
]);
if (cancelled) return;
const bioAvailable = hasHardware && isEnrolled;
setEnabled(lockEnabled);
setHasPin(Boolean(pin));
setBiometricEnabledState(bioEnabled && bioAvailable);
setBiometricAvailable(bioAvailable);
setBiometricLabel(
authTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)
? "Face ID"
: authTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)
? "Touch ID"
: "Biometrics",
);
setIsLocked(lockEnabled && !shortcutPending);
hydrated.current = true;
}
void hydrate();
return () => {
cancelled = true;
};
}, [activeAccountId]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled || !activeAccountId) return;
// Only true backgrounding should re-lock — `inactive` fires during Face ID,
// Control Center, and other system sheets and must not trigger another lock.
if (nextState === "background") {
wasBackgrounded.current = true;
}
if (
nextState === "active" &&
wasBackgrounded.current &&
!biometricUnlockInProgress.current
) {
wasBackgrounded.current = false;
setIsLocked(true);
}
});
return () => subscription.remove();
}, [enabled, activeAccountId]);
const unlockWithPin = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
},
[activeAccountId],
);
const unlockWithBiometric = useCallback(async () => {
if (!biometricAvailable || !activeAccountId) {
return false;
}
biometricUnlockInProgress.current = true;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: false,
biometricsSecurityLevel: "weak",
});
if (!result.success) {
return false;
}
if (!biometricEnabled) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
} finally {
biometricUnlockInProgress.current = false;
}
}, [biometricAvailable, biometricEnabled, activeAccountId]);
const enableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) {
throw new Error("No active account");
}
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
const [hasHardware, isEnrolled] = await Promise.all([
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
]);
const bioAvailable = hasHardware && isEnrolled;
await setStoredPin(activeAccountId, pin);
await setAppLockEnabled(activeAccountId, true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
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) {
setIsLocked(true);
}
}, [enabled]);
const value = useMemo(
() => ({
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
}),
[
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
],
);
return <AppLockContext.Provider value={value}>{children}</AppLockContext.Provider>;
}
export function useAppLock() {
const context = useContext(AppLockContext);
if (!context) {
throw new Error("useAppLock must be used within AppLockProvider");
}
return context;
}
+57
View File
@@ -0,0 +1,57 @@
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import * as SecureStore from "expo-secure-store";
import {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
type AuthClient = ReturnType<typeof createAuthClient>;
function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient {
return createAuthClient({
baseURL: apiUrl,
plugins: [
expoClient({
scheme: "beenvoice",
storagePrefix,
storage: SecureStore,
// Avoid showing a cached session when cookies have already expired.
disableCache: true,
}),
genericOAuthClient(),
],
});
}
const AuthContext = createContext<AuthClient | null>(null);
export function AuthProvider({
apiUrl,
storagePrefix,
children,
}: {
apiUrl: string;
storagePrefix: string;
children: ReactNode;
}) {
const client = useMemo(
() => createAppAuthClient(apiUrl, storagePrefix),
[apiUrl, storagePrefix],
);
return <AuthContext.Provider value={client}>{children}</AuthContext.Provider>;
}
export function useAuthClient() {
const client = useContext(AuthContext);
if (!client) throw new Error("useAuthClient must be used within AuthProvider");
return client;
}
export function useSession() {
return useAuthClient().useSession();
}
+88
View File
@@ -0,0 +1,88 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useColorScheme as useSystemColorScheme, type ColorSchemeName } from "react-native";
import { getThemeColors, type ThemeColors } from "@/lib/theme-palette";
export type ColorMode = "system" | "light" | "dark";
const STORAGE_KEY = "beenvoice:color-mode";
type ThemeContextValue = {
colorMode: ColorMode;
setColorMode: (mode: ColorMode) => Promise<void>;
colorScheme: NonNullable<ColorSchemeName>;
colors: ThemeColors;
isDark: boolean;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemScheme = useSystemColorScheme();
const [colorMode, setColorModeState] = useState<ColorMode>("system");
const [ready, setReady] = useState(false);
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY)
.then((stored) => {
if (stored === "light" || stored === "dark" || stored === "system") {
setColorModeState(stored);
}
})
.finally(() => setReady(true));
}, []);
const colorScheme: NonNullable<ColorSchemeName> =
colorMode === "system" ? (systemScheme ?? "light") : colorMode;
const colors = useMemo(() => getThemeColors(colorScheme), [colorScheme]);
const setColorMode = useCallback(async (mode: ColorMode) => {
setColorModeState(mode);
await AsyncStorage.setItem(STORAGE_KEY, mode);
}, []);
const value = useMemo(
() => ({
colorMode,
setColorMode,
colorScheme,
colors,
isDark: colorScheme === "dark",
}),
[colorMode, setColorMode, colorScheme, colors],
);
if (!ready) {
return (
<ThemeContext.Provider
value={{
colorMode: "system",
setColorMode: async () => {},
colorScheme: systemScheme ?? "light",
colors: getThemeColors(systemScheme ?? "light"),
isDark: systemScheme === "dark",
}}
>
{children}
</ThemeContext.Provider>
);
}
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useAppTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useAppTheme must be used within ThemeProvider");
return ctx;
}
+284
View File
@@ -0,0 +1,284 @@
# App Store Connect — beenvoice iOS
Copy-paste reference for submitting **beenvoice** (`com.beenvoice.app`, v1.0.0). Update URLs if your production web host differs from `beenvoice.com`.
---
## App Information
| Field | Value |
|-------|--------|
| **Name** | beenvoice |
| **Subtitle** (30 chars max) | Invoices & time tracking |
| **Bundle ID** | `com.beenvoice.app` |
| **SKU** | `beenvoice-ios` (your choice; immutable) |
| **Primary language** | English (U.S.) |
| **Primary category** | Business |
| **Secondary category** | Productivity |
| **Content rights** | Does not contain third-party content |
| **Age rating** | 4+ (no restricted content; business/finance utility) |
### Copyright
```
© 2026 beenvoice
```
---
## URLs
Deploy the Next.js legal pages before submission. Privacy Policy and Terms must load without login.
| Field | URL |
|-------|-----|
| **Privacy Policy URL** | `https://beenvoice.com/privacy` |
| **Terms of Use (EULA)** | Use Apple Standard EULA *or* link `https://beenvoice.com/terms` |
| **Support URL** | `https://beenvoice.com` (or a dedicated `/support` page when available) |
| **Marketing URL** (optional) | `https://beenvoice.com` |
If production web is still on `beenvoice.soconnor.dev`, use `https://beenvoice.soconnor.dev/privacy` and `/terms` until `beenvoice.com` is live.
---
## Promotional Text (170 chars max)
Optional; can be changed without a new build.
```
Track billable hours, manage clients, and send invoices from your phone. Syncs with your beenvoice account. Lock the app with Face ID.
```
---
## Description (4000 chars max)
```
beenvoice is the mobile companion for freelancers and small teams who invoice clients and track billable time.
DASHBOARD AT A GLANCE
See revenue, pending and overdue invoices, and your running timer without opening multiple tools.
TIME CLOCK
Clock in and out with an optional description, client, invoice, and hourly rate. On iPhone, a Live Activity on the Lock Screen and Dynamic Island shows elapsed time while you work.
INVOICES
Browse, filter, create, and edit invoices. Update status and keep billing moving from anywhere.
CLIENTS & BUSINESSES
Maintain client records and business profiles so invoices stay consistent across web and mobile.
MULTI-ACCOUNT
Switch between beenvoice accounts (e.g. work and personal) with separate sessions, similar to a password manager.
SECURITY
Optional per-account app lock with PIN and Face ID / Touch ID when returning to the app.
OFFICIAL OR SELF-HOSTED
Sign in to the official beenvoice cloud or point the app at your own beenvoice server URL.
REQUIREMENTS
A beenvoice account and network access to your beenvoice server. The mobile app is not a standalone product—it connects to the same API as the beenvoice web app.
Questions or feedback: support via your beenvoice administrator or the contact on beenvoice.com.
```
---
## Keywords (100 chars max, comma-separated, no spaces after commas)
```
invoice,time tracking,freelance,billing,clients,timer,accounting,small business,hours,beenvoice
```
---
## Whats New (Version 1.0.0)
```
Initial App Store release.
• Dashboard with revenue and invoice summaries
• Time clock with optional client, invoice, and rate
• iOS Live Activity for running timers
• Invoice list, create, and edit
• Clients and businesses management
• Multi-account support with secure sign-in
• Per-account app lock (PIN and Face ID)
• Light and dark appearance
```
---
## App Review Information
### Sign-in required
**Yes** — the app requires a beenvoice account.
### Demo account (production server)
Ensure migration `0014_seed_demo_account` has run on the server reviewers will hit.
| Field | Value |
|-------|--------|
| **Username** | `demo@example.com` |
| **Password** | `demo123` |
### Notes for Review
```
beenvoice is a client for the beenvoice invoicing and time-tracking platform (web + API).
SIGN IN
1. Open the app.
2. Leave "Official" server selected (https://beenvoice.soconnor.dev) unless we specify otherwise in this note.
3. Sign in with the demo account above.
WHAT TO TEST
• Dashboard — sample invoices and stats are pre-seeded.
• Timer tab — clock in, optionally pick client/description; on a physical device, Lock Screen Live Activity appears while a timer runs.
• Invoices — list includes draft, sent, and paid examples.
• Settings — profile, theme, optional app lock (PIN / Face ID).
APP LOCK
Optional. Enable in Settings → App Lock. Face ID uses on-device biometrics only; no biometric data is sent to our servers.
LIVE ACTIVITY
Requires a physical iPhone (not available in Simulator). Start a timer, lock the device, and check the Lock Screen / Dynamic Island.
SELF-HOSTED SERVERS
Users may enter a custom server URL on sign-in. Review uses the official server only.
No in-app purchases. No ads.
```
Update the official server URL in the note if you change `DEFAULT_API_URL` in `lib/config.ts`.
---
## App Privacy (Privacy Nutrition Labels)
Answer in App Store Connect → App Privacy. Adjust if you add analytics later.
### Data linked to the user
| Data type | Purpose | Collected | Linked | Tracking |
|-----------|---------|-----------|--------|----------|
| **Email address** | App functionality, account | Yes | Yes | No |
| **Name** | App functionality, account | Yes | Yes | No |
| **Other user content** (clients, invoices, time entries, business details) | App functionality | Yes | Yes | No |
| **User ID** | App functionality | Yes | Yes | No |
### Data not collected for tracking
The app does **not** use data for tracking across apps/websites. No third-party analytics SDKs in the current build.
### Data collected but not linked (typically none)
If you only use on-device Face ID via `expo-local-authentication`, Apple treats biometrics as **not** collected by the developer—do **not** declare Face ID templates as collected data.
### Practice to select
- **Data Used to Track You:** None
- **Data Linked to You:** Contact info, identifiers, user content (as above)
- **Data Not Linked to You:** None (unless you add crash logs without account linkage)
---
## Age Rating Questionnaire (typical answers)
| Topic | Answer |
|-------|--------|
| Cartoon / fantasy violence | None |
| Realistic violence | None |
| Sexual content | None |
| Profanity | None |
| Drugs, alcohol, tobacco | None |
| Gambling | None |
| Horror | None |
| Mature / suggestive themes | None |
| Unrestricted web access | No (in-app browser not used for open web) |
| User-generated content broadly distributed | No (invoice data is private to the account) |
Expected result: **4+**.
---
## Export Compliance
In App Store Connect encryption questions:
- **Uses encryption:** Yes (HTTPS/TLS for API)
- **Exempt:** Yes — standard HTTPS only, qualify for exemption under mass-market encryption rules (same as most apps using TLS)
Confirm annually in Connect; no separate ERN needed for standard TLS-only apps in most cases.
---
## Screenshots (required sizes)
Capture from **iPhone 6.7"** (e.g. iPhone 15 Pro Max) and **6.5"** if you support older requirements. Xcode Simulator → Save Screenshot, or physical device.
Suggested screens (portrait):
1. **Sign-in** — brand, clean auth (optional; some teams skip)
2. **Dashboard** — stats + recent invoices (demo account)
3. **Timer** — running or ready to clock in
4. **Invoices** — list with statuses
5. **Invoice detail / edit** — line items
6. **Settings** — theme + app lock (shows polish)
Minimum: **3 screenshots** per required device size.
Optional: iPad 12.9" if `supportsTablet: true` — use iPad simulator or “Run on iPad” with scaled iPhone UI.
---
## Build & submit
### Option A — Local Xcode (no EAS)
See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide.
```bash
cd beenvoice-app
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
bun run ios:release:upload # archive + upload to TestFlight
```
Requires Xcode on macOS, Apple Developer membership, and an App Store Connect API key.
### Option B — EAS (Expo cloud build)
```bash
cd beenvoice-app
# Production iOS build (auto-increments build number)
eas build --platform ios --profile production
# Submit latest build to App Store Connect
eas submit --platform ios --profile production
```
Prerequisites:
- Apple Developer Program membership
- App record created in App Store Connect with bundle ID `com.beenvoice.app`
- EAS credentials configured (`eas credentials`) — Option B only
- Privacy Policy URL live and reachable
---
## Pre-submission checklist
- [ ] Legal pages live at Privacy Policy URL (HTTP 200, no auth wall)
- [ ] Demo account works on production API (`demo@example.com` / `demo123`)
- [ ] `eas build --profile production` succeeds
- [ ] TestFlight smoke test on device (login, timer, invoices, app lock)
- [ ] Live Activity tested on physical iPhone
- [ ] App Privacy answers match actual data flows
- [ ] Screenshots uploaded for required device sizes
- [ ] Review notes include demo credentials and server URL
- [ ] Export compliance answered
- [ ] Version `1.0.0` matches `app.json` / Connect version field
+266
View File
@@ -0,0 +1,266 @@
# beenvoice-app architecture
Dense reference for the Expo 56 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
## Stack
| Layer | Technology |
|-------|------------|
| Framework | Expo 56, expo-router 56 (file-based routes) |
| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
| API | tRPC 11 + TanStack Query, SuperJSON |
| Auth | better-auth + `@better-auth/expo``expo-secure-store` |
| Types | `AppRouter` imported from `../beenvoice/src/server/api/root` |
## Boot sequence
```
app/_layout.tsx
SafeAreaProvider
ThemeProvider ← AsyncStorage color mode
BrandBackground + StatusBar
AccountsProvider ← load accounts, active id, apiUrl
AppServices ← key={activeAccountId:apiUrl}
AuthProvider ← better-auth client (storagePrefix)
TRPCProvider ← cookie header on /api/trpc
RootNavigator
session? → (app) | (auth)
```
`AccountsProvider` blocks on `LoadingScreen` until AsyncStorage hydrates. `AuthProvider` + `TRPCProvider` **remount** when `activeAccountId` or `apiUrl` changes so each account uses isolated SecureStore session keys.
## Routing
### Auth group `app/(auth)/`
| Screen | File | Notes |
|--------|------|-------|
| redirect | `index.tsx` | → sign-in |
| Sign in | `sign-in.tsx` | Server picker, deferred validation |
| Register | `register.tsx` | REST register + sign-in + finalize account |
| Forgot password | `forgot-password.tsx` | REST |
| Reset password | `reset-password.tsx` | Deep link `beenvoice://reset-password?token=` |
### App group `app/(app)/`
`NativeTabs` (5 tabs) in `_layout.tsx`. Wrapped in `AppLockProvider` + `AppLockOverlay`.
| Tab | Screen | Features |
|-----|--------|----------|
| Dashboard | `index.tsx` | Stats, running timer chip, recent invoices |
| Timer | `timer.tsx` | `TimeClockPanel` |
| Entities | `entities/*` | Clients + businesses CRUD stacks |
| Invoices | `invoices/*` | List, create, edit, status |
| Settings | `settings.tsx` | Profile, accounts, theme, app lock, sign out |
Nested stacks: `entities/_layout.tsx`, `invoices/_layout.tsx`.
## Multi-account model
**Storage**`lib/accounts.ts` (AsyncStorage):
| Key | Content |
|-----|---------|
| `beenvoice:accounts` | `SavedAccount[]` |
| `beenvoice:active-account-id` | current account id or absent |
| `beenvoice:draft-instance-url` | server URL before first login |
**Account id**: `{hostname}::{userId}` — host from instance URL without protocol/trailing slash.
**Auth storage prefix** (SecureStore, better-auth):
| Mode | Prefix |
|------|--------|
| Guest (signed out / adding account) | `beenvoice:guest` |
| Per account | `beenvoice:auth:{accountId}` |
Keys written by `@better-auth/expo`: `{prefix}_cookie`, `{prefix}_session_data`, `{prefix}_last_login_method` (colons normalized to `_` in SecureStore). Large cookies are chunked (`\x01ba-chunks:N` + `.{i}` keys).
### Sign-in flow (critical)
1. User signs in while `AuthProvider` uses **guest** (or current) prefix.
2. Session lands in that prefix's SecureStore.
3. `finalizeAuthenticatedAccount()` (`lib/auth-storage.ts`):
- `migrateAuthStorage(sourcePrefix → targetPrefix)` — copy session keys
- `registerAccount()` — set active account, persist metadata
4. `AuthProvider` remounts with account prefix; session already migrated → `RootNavigator` shows `(app)`.
Without migration, remounting loses the session and forces a second login.
### Account switcher
`components/AccountSwitcher.tsx` (header):
- **Switch**: `switchAccount(id)` → remount auth/tRPC with that account's URL + prefix (session must already exist in target prefix).
- **Add account**: `signOut()` + `clearActiveAccount()` → auth stack on guest prefix.
## Server picker
`components/AuthServerPicker.tsx` + `lib/server-mode.ts`:
- **Official** — `DEFAULT_API_URL` (`https://beenvoice.soconnor.dev` in `lib/config.ts`)
- **Self-hosted** — user URL, normalized via `lib/instance-url.ts` (adds `http://` for localhost/LAN)
`setInstanceUrl()` updates runtime API (`lib/config.ts` `setRuntimeApiUrl`) and draft or active account URL.
## API URL resolution
`lib/config.ts` priority:
1. Runtime override (`setRuntimeApiUrl` from AccountsContext)
2. `EXPO_PUBLIC_API_URL` from `.env`
3. Dev: Metro host IP + `:3000`
4. `DEFAULT_API_URL`
## tRPC client
`lib/trpc.tsx`:
```ts
httpBatchLink({
url: `${apiUrl}/api/trpc`,
transformer: SuperJSON,
headers: () => ({ cookie: authClient.getCookie() }),
})
```
Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`.
## App lock (per account)
`lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`:
- `beenvoice:app-lock:{id}:enabled|pin|biometric`
- One-time migration from legacy global keys `beenvoice_app_lock_*`
`contexts/AppLockContext.tsx`:
- Hydrates on account change
- Locks when returning from background (if enabled)
- PIN 46 digits; Face ID / Touch ID via `expo-local-authentication`
- Only active inside `(app)/_layout.tsx` — auth screens never locked
UI: `AppLockOverlay.tsx`, `PinPrompt.tsx`, settings toggles.
## Time clock
`components/time-clock/TimeClockPanel.tsx`:
- Client required; description optional (defaults to **"Clock In"** via `lib/time-clock.ts`)
- Optional invoice, hourly rate, backdated start
- `clockOut` sends optional description update
- Syncs iOS Live Activity every 30s while running
### Live Activity
| File | Role |
|------|------|
| `widgets/TimeClockActivity.tsx` | SwiftUI widget (`expo-widgets`); must keep all UI **inside** the `"widget"` function (babel preset serializes only that) |
| `lib/time-clock-live-activity.ts` | `syncTimeClockLiveActivity`, `endTimeClockLiveActivity` |
| `app.json` | Plugin `expo-widgets`, app group `group.com.beenvoice.app` |
Does not work in Expo Go — use `bun run ios` dev build.
## Theming
| File | Role |
|------|------|
| `lib/beenvoice-theme.ts` | Light tokens mirrored from web `globals.css` |
| `lib/theme-palette.ts` | Light + dark `ThemeColors` |
| `constants/theme.ts` | spacing, radii, fonts (Playfair / Inter / SpaceMono) |
| `contexts/ThemeContext.tsx` | system / light / dark → AsyncStorage `beenvoice:color-mode` |
| `components/BrandBackground.tsx` | Grid + animated blob |
| `lib/use-themed-styles.ts` | Memoized StyleSheet factory |
## Contexts summary
| Context | File |
|---------|------|
| Auth | `contexts/AuthContext.tsx` |
| Accounts | `contexts/AccountsContext.tsx` |
| App lock | `contexts/AppLockContext.tsx` |
| Theme | `contexts/ThemeContext.tsx` |
## `lib/` module index
| Module | Purpose |
|--------|---------|
| `accounts.ts` | Account registry types + AsyncStorage |
| `auth-storage.ts` | Session migration, `finalizeAuthenticatedAccount` |
| `auth-api.ts` | REST register / forgot / reset |
| `config.ts` | API URL |
| `instance-url.ts` | URL normalization + persistence |
| `server-mode.ts` | Official vs self-hosted |
| `trpc.tsx` | tRPC provider |
| `app-lock.ts` | Per-account PIN storage |
| `form-validation.ts` | Validators + `useFieldVisibility` |
| `format.ts` | Currency, dates |
| `invoice-status.ts` | Status colors |
| `invoice-number.ts` | Number generation |
| `time-clock.ts` | Timer formatting, clock-out copy |
| `time-clock-live-activity*.ts` | Live Activity bridge |
| `tab-layout.ts`, `tab-bar-insets.ts`, `top-chrome-insets.ts` | Layout metrics |
## Components layout
```
components/
├── ui/ # Button, Card, Input, SelectField, DateTimeField
├── time-clock/ # TimeClockPanel
├── clients/ # ClientForm
├── businesses/ # BusinessForm
├── invoices/ # LineItemEditor
├── AuthServerPicker, AccountSwitcher, TopChrome, AppLockOverlay, Logo, …
```
## Native config
**`app.json`**
- `scheme`: `beenvoice`
- `bundleIdentifier`: `com.beenvoice.app`
- Plugins: dev-client, router, secure-store, widgets, local-authentication
- iOS Face ID usage strings; custom `beenvoice.icon`
**`eas.json`**
- Profiles: development, preview, production
- `cli.appVersionSource`: remote
- User does not require EAS for local `expo run:ios`
**Metro**: port **8082** (`package.json` scripts) to avoid collisions.
## Deep links
| URL | Handler |
|-----|---------|
| `beenvoice://reset-password?token=…` | `reset-password.tsx` |
| `beenvoice://timer` | Live Activity tap → timer tab |
## Development commands
```bash
bun run ios # build + run simulator, Metro :8082
bun run start # Metro only (--dev-client --port 8082)
bunx expo prebuild --platform ios --clean # after native dep / icon changes
```
## Troubleshooting
| Symptom | Likely cause |
|---------|----------------|
| `PlatformConstants` / runtime not ready | Wrong Metro port, stale native build, or Expo Go instead of dev client |
| Login twice | Session not migrated — see `finalizeAuthenticatedAccount` |
| Live Activity blank | Widget UI outside `"widget"` function; rebuild native |
| API unreachable on device | Use LAN IP in `EXPO_PUBLIC_API_URL`, not `localhost` |
| Auth fails on device | `BETTER_AUTH_URL` on server must match reachable host |
## Server dependency
Requires beenvoice with:
- `@better-auth/expo` in `src/lib/auth.ts`
- `trustedOrigins` including `beenvoice://` and `exp://`
- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`)
See [beenvoice/docs/ARCHITECTURE.md](../../beenvoice/docs/ARCHITECTURE.md).
+88
View File
@@ -0,0 +1,88 @@
# Local iOS release (no EAS)
Archive and upload **beenvoice** to App Store Connect using Xcode on your Mac — no Expo Application Services (EAS) subscription required.
## Prerequisites
- macOS with **Xcode** (same major version you use for development)
- **Apple Developer Program** membership
- App record in [App Store Connect](https://appstoreconnect.apple.com) with bundle ID `com.beenvoice.app`
- **Distribution** signing set up in Xcode (automatic signing + team is enough for most cases)
- [App Store Connect API key](https://appstoreconnect.apple.com/access/integrations/api) (for upload only)
## One-time setup
```bash
cd beenvoice-app
cp .ios-release.env.example .ios-release.env
```
Edit `.ios-release.env`:
| Variable | Where to find it |
|----------|------------------|
| `APPLE_TEAM_ID` | [developer.apple.com/account](https://developer.apple.com/account) → Membership → Team ID |
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect → Users and Access → Integrations → Keys |
| `APP_STORE_CONNECT_API_ISSUER_ID` | Same page (Issuer ID at top) |
| `APP_STORE_CONNECT_API_KEY_PATH` | Path to downloaded `AuthKey_XXXXXX.p8` |
| `EXPO_PUBLIC_API_URL` | Production API URL baked into the release bundle |
Optional: store the `.p8` in `~/.appstoreconnect/private_keys/` (never commit it).
Open the iOS project once in Xcode and confirm **Signing & Capabilities** succeeds for targets **beenvoice** and **ExpoWidgetsTarget**.
## Commands
```bash
# Archive + export signed IPA to dist/ios-release/export/
bun run ios:release
# Archive + export + upload to App Store Connect (TestFlight)
bun run ios:release:upload
```
### Flags (pass through to the script)
```bash
bash scripts/ios-release.sh --archive-only # .xcarchive only
bash scripts/ios-release.sh --export-only --upload # re-upload existing archive
bash scripts/ios-release.sh --no-prebuild # skip expo prebuild
bash scripts/ios-release.sh --no-bump # don't increment build number
```
With `IOS_BUMP_BUILD=1` in `.ios-release.env`, each run bumps `CFBundleVersion` via `agvtool` (recommended for repeated TestFlight uploads).
## What the script does
1. `expo prebuild --platform ios` (unless `--no-prebuild`)
2. `pod install`
3. Optional build-number bump (`agvtool`)
4. `xcodebuild archive` (Release, generic iOS device)
5. `xcodebuild -exportArchive` → App Store IPA
6. `xcrun altool --upload-app` (with `--upload` only)
Artifacts land in `dist/ios-release/` (gitignored).
## After upload
1. App Store Connect → **TestFlight** — wait for “Processing” to finish
2. Smoke-test on device
3. Submit for App Store review when ready
See also [APP_STORE_CONNECT.md](./APP_STORE_CONNECT.md) for metadata, screenshots, and review notes.
## Notes
- **Dev client:** `expo-dev-client` is in the native project today. Store builds still work, but the binary includes the dev client shell. For a slimmer production binary, remove that plugin and re-run prebuild before release (or maintain a separate `app.config` variant).
- **Manual upload:** After `bun run ios:release`, drag the IPA into Apples [Transporter](https://apps.apple.com/app/transporter/id1450874784) app instead of using `--upload`.
- **CI:** Run the same script on a Mac runner (GitHub `macos-latest`, etc.) with secrets injected as env vars instead of `.ios-release.env`.
## Troubleshooting
| Issue | Fix |
|-------|-----|
| No signing certificate | Xcode → Settings → Accounts → Download Manual Profiles; or open project and enable automatic signing |
| `pod install` fails | `cd ios && pod repo update && pod install` |
| Upload auth error | Verify API key has **Developer** access; check Key ID, Issuer ID, and `.p8` path |
| Duplicate build number | Enable `IOS_BUMP_BUILD=1` or bump `CURRENT_PROJECT_VERSION` in Xcode |
| Widget extension signing | Both **beenvoice** and **ExpoWidgetsTarget** need the same team |
+12
View File
@@ -0,0 +1,12 @@
# beenvoice-app documentation
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Routing, contexts, auth/accounts, tRPC, app lock, Live Activity, theming |
| [../README.md](../README.md) | Setup, run, troubleshooting |
| [../AGENTS.md](../AGENTS.md) | Agent conventions |
## Related
- [beenvoice docs](../../beenvoice/docs/README.md) — server API and web app
- [Workspace README](../../README.md) — full-stack layout
+30
View File
@@ -0,0 +1,30 @@
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": false
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
}
},
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m-medium"
}
}
},
"submit": {
"production": {}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { router } from "expo-router";
import { Alert } from "react-native";
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
type FinishAccountRemovalInput = {
result: RemoveAccountResult;
clearActiveAccount: () => Promise<void>;
signOut: () => Promise<unknown>;
};
/** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({
result,
clearActiveAccount,
signOut,
}: FinishAccountRemovalInput): Promise<void> {
if (result.remainingCount > 0) return;
await signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}
export function confirmRemoveAccount(
label: string,
onRemove: () => Promise<RemoveAccountResult>,
onFinished: (result: RemoveAccountResult) => Promise<void>,
) {
Alert.alert("Remove account", `Remove ${label} from this device?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => {
void (async () => {
const result = await onRemove();
await onFinished(result);
})();
},
},
]);
}

Some files were not shown because too many files have changed in this diff Show More