Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e17c4c6854 | |||
| 355b14faef | |||
| 06bc91ac13 | |||
| 0b2d65a4e9 | |||
| 3daf123399 | |||
| 32ffe782ea | |||
| e6ea3d7c5d | |||
| d3b73464e4 | |||
| 6d2711e36e | |||
| 14c880123c |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 4–6 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthIndex() {
|
||||||
|
return <Redirect href="/(auth)/sign-in" />;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
|
<RootNavigator />
|
||||||
|
</AppServices>
|
||||||
|
</AccountsProvider>
|
||||||
|
</ThemedChrome>
|
||||||
</ThemeProvider>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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%",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 4–6 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 4–6 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What’s 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
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# beenvoice-app architecture
|
||||||
|
|
||||||
|
Dense reference for the Expo 56 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Framework | Expo 56, expo-router 56 (file-based routes) |
|
||||||
|
| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
|
||||||
|
| API | tRPC 11 + TanStack Query, SuperJSON |
|
||||||
|
| Auth | better-auth + `@better-auth/expo` → `expo-secure-store` |
|
||||||
|
| Types | `AppRouter` imported from `../beenvoice/src/server/api/root` |
|
||||||
|
|
||||||
|
## Boot sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
app/_layout.tsx
|
||||||
|
SafeAreaProvider
|
||||||
|
ThemeProvider ← AsyncStorage color mode
|
||||||
|
BrandBackground + StatusBar
|
||||||
|
AccountsProvider ← load accounts, active id, apiUrl
|
||||||
|
AppServices ← key={activeAccountId:apiUrl}
|
||||||
|
AuthProvider ← better-auth client (storagePrefix)
|
||||||
|
TRPCProvider ← cookie header on /api/trpc
|
||||||
|
RootNavigator
|
||||||
|
session? → (app) | (auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
`AccountsProvider` blocks on `LoadingScreen` until AsyncStorage hydrates. `AuthProvider` + `TRPCProvider` **remount** when `activeAccountId` or `apiUrl` changes so each account uses isolated SecureStore session keys.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
### Auth group `app/(auth)/`
|
||||||
|
|
||||||
|
| Screen | File | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| redirect | `index.tsx` | → sign-in |
|
||||||
|
| Sign in | `sign-in.tsx` | Server picker, deferred validation |
|
||||||
|
| Register | `register.tsx` | REST register + sign-in + finalize account |
|
||||||
|
| Forgot password | `forgot-password.tsx` | REST |
|
||||||
|
| Reset password | `reset-password.tsx` | Deep link `beenvoice://reset-password?token=` |
|
||||||
|
|
||||||
|
### App group `app/(app)/`
|
||||||
|
|
||||||
|
`NativeTabs` (5 tabs) in `_layout.tsx`. Wrapped in `AppLockProvider` + `AppLockOverlay`.
|
||||||
|
|
||||||
|
| Tab | Screen | Features |
|
||||||
|
|-----|--------|----------|
|
||||||
|
| Dashboard | `index.tsx` | Stats, running timer chip, recent invoices |
|
||||||
|
| Timer | `timer.tsx` | `TimeClockPanel` |
|
||||||
|
| Entities | `entities/*` | Clients + businesses CRUD stacks |
|
||||||
|
| Invoices | `invoices/*` | List, create, edit, status |
|
||||||
|
| Settings | `settings.tsx` | Profile, accounts, theme, app lock, sign out |
|
||||||
|
|
||||||
|
Nested stacks: `entities/_layout.tsx`, `invoices/_layout.tsx`.
|
||||||
|
|
||||||
|
## Multi-account model
|
||||||
|
|
||||||
|
**Storage** — `lib/accounts.ts` (AsyncStorage):
|
||||||
|
|
||||||
|
| Key | Content |
|
||||||
|
|-----|---------|
|
||||||
|
| `beenvoice:accounts` | `SavedAccount[]` |
|
||||||
|
| `beenvoice:active-account-id` | current account id or absent |
|
||||||
|
| `beenvoice:draft-instance-url` | server URL before first login |
|
||||||
|
|
||||||
|
**Account id**: `{hostname}::{userId}` — host from instance URL without protocol/trailing slash.
|
||||||
|
|
||||||
|
**Auth storage prefix** (SecureStore, better-auth):
|
||||||
|
|
||||||
|
| Mode | Prefix |
|
||||||
|
|------|--------|
|
||||||
|
| Guest (signed out / adding account) | `beenvoice:guest` |
|
||||||
|
| Per account | `beenvoice:auth:{accountId}` |
|
||||||
|
|
||||||
|
Keys written by `@better-auth/expo`: `{prefix}_cookie`, `{prefix}_session_data`, `{prefix}_last_login_method` (colons normalized to `_` in SecureStore). Large cookies are chunked (`\x01ba-chunks:N` + `.{i}` keys).
|
||||||
|
|
||||||
|
### Sign-in flow (critical)
|
||||||
|
|
||||||
|
1. User signs in while `AuthProvider` uses **guest** (or current) prefix.
|
||||||
|
2. Session lands in that prefix's SecureStore.
|
||||||
|
3. `finalizeAuthenticatedAccount()` (`lib/auth-storage.ts`):
|
||||||
|
- `migrateAuthStorage(sourcePrefix → targetPrefix)` — copy session keys
|
||||||
|
- `registerAccount()` — set active account, persist metadata
|
||||||
|
4. `AuthProvider` remounts with account prefix; session already migrated → `RootNavigator` shows `(app)`.
|
||||||
|
|
||||||
|
Without migration, remounting loses the session and forces a second login.
|
||||||
|
|
||||||
|
### Account switcher
|
||||||
|
|
||||||
|
`components/AccountSwitcher.tsx` (header):
|
||||||
|
|
||||||
|
- **Switch**: `switchAccount(id)` → remount auth/tRPC with that account's URL + prefix (session must already exist in target prefix).
|
||||||
|
- **Add account**: `signOut()` + `clearActiveAccount()` → auth stack on guest prefix.
|
||||||
|
|
||||||
|
## Server picker
|
||||||
|
|
||||||
|
`components/AuthServerPicker.tsx` + `lib/server-mode.ts`:
|
||||||
|
|
||||||
|
- **Official** — `DEFAULT_API_URL` (`https://beenvoice.soconnor.dev` in `lib/config.ts`)
|
||||||
|
- **Self-hosted** — user URL, normalized via `lib/instance-url.ts` (adds `http://` for localhost/LAN)
|
||||||
|
|
||||||
|
`setInstanceUrl()` updates runtime API (`lib/config.ts` `setRuntimeApiUrl`) and draft or active account URL.
|
||||||
|
|
||||||
|
## API URL resolution
|
||||||
|
|
||||||
|
`lib/config.ts` priority:
|
||||||
|
|
||||||
|
1. Runtime override (`setRuntimeApiUrl` from AccountsContext)
|
||||||
|
2. `EXPO_PUBLIC_API_URL` from `.env`
|
||||||
|
3. Dev: Metro host IP + `:3000`
|
||||||
|
4. `DEFAULT_API_URL`
|
||||||
|
|
||||||
|
## tRPC client
|
||||||
|
|
||||||
|
`lib/trpc.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${apiUrl}/api/trpc`,
|
||||||
|
transformer: SuperJSON,
|
||||||
|
headers: () => ({ cookie: authClient.getCookie() }),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`.
|
||||||
|
|
||||||
|
## App lock (per account)
|
||||||
|
|
||||||
|
`lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`:
|
||||||
|
|
||||||
|
- `beenvoice:app-lock:{id}:enabled|pin|biometric`
|
||||||
|
- One-time migration from legacy global keys `beenvoice_app_lock_*`
|
||||||
|
|
||||||
|
`contexts/AppLockContext.tsx`:
|
||||||
|
|
||||||
|
- Hydrates on account change
|
||||||
|
- Locks when returning from background (if enabled)
|
||||||
|
- PIN 4–6 digits; Face ID / Touch ID via `expo-local-authentication`
|
||||||
|
- Only active inside `(app)/_layout.tsx` — auth screens never locked
|
||||||
|
|
||||||
|
UI: `AppLockOverlay.tsx`, `PinPrompt.tsx`, settings toggles.
|
||||||
|
|
||||||
|
## Time clock
|
||||||
|
|
||||||
|
`components/time-clock/TimeClockPanel.tsx`:
|
||||||
|
|
||||||
|
- Client required; description optional (defaults to **"Clock In"** via `lib/time-clock.ts`)
|
||||||
|
- Optional invoice, hourly rate, backdated start
|
||||||
|
- `clockOut` sends optional description update
|
||||||
|
- Syncs iOS Live Activity every 30s while running
|
||||||
|
|
||||||
|
### Live Activity
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `widgets/TimeClockActivity.tsx` | SwiftUI widget (`expo-widgets`); must keep all UI **inside** the `"widget"` function (babel preset serializes only that) |
|
||||||
|
| `lib/time-clock-live-activity.ts` | `syncTimeClockLiveActivity`, `endTimeClockLiveActivity` |
|
||||||
|
| `app.json` | Plugin `expo-widgets`, app group `group.com.beenvoice.app` |
|
||||||
|
|
||||||
|
Does not work in Expo Go — use `bun run ios` dev build.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `lib/beenvoice-theme.ts` | Light tokens mirrored from web `globals.css` |
|
||||||
|
| `lib/theme-palette.ts` | Light + dark `ThemeColors` |
|
||||||
|
| `constants/theme.ts` | spacing, radii, fonts (Playfair / Inter / SpaceMono) |
|
||||||
|
| `contexts/ThemeContext.tsx` | system / light / dark → AsyncStorage `beenvoice:color-mode` |
|
||||||
|
| `components/BrandBackground.tsx` | Grid + animated blob |
|
||||||
|
| `lib/use-themed-styles.ts` | Memoized StyleSheet factory |
|
||||||
|
|
||||||
|
## Contexts summary
|
||||||
|
|
||||||
|
| Context | File |
|
||||||
|
|---------|------|
|
||||||
|
| Auth | `contexts/AuthContext.tsx` |
|
||||||
|
| Accounts | `contexts/AccountsContext.tsx` |
|
||||||
|
| App lock | `contexts/AppLockContext.tsx` |
|
||||||
|
| Theme | `contexts/ThemeContext.tsx` |
|
||||||
|
|
||||||
|
## `lib/` module index
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `accounts.ts` | Account registry types + AsyncStorage |
|
||||||
|
| `auth-storage.ts` | Session migration, `finalizeAuthenticatedAccount` |
|
||||||
|
| `auth-api.ts` | REST register / forgot / reset |
|
||||||
|
| `config.ts` | API URL |
|
||||||
|
| `instance-url.ts` | URL normalization + persistence |
|
||||||
|
| `server-mode.ts` | Official vs self-hosted |
|
||||||
|
| `trpc.tsx` | tRPC provider |
|
||||||
|
| `app-lock.ts` | Per-account PIN storage |
|
||||||
|
| `form-validation.ts` | Validators + `useFieldVisibility` |
|
||||||
|
| `format.ts` | Currency, dates |
|
||||||
|
| `invoice-status.ts` | Status colors |
|
||||||
|
| `invoice-number.ts` | Number generation |
|
||||||
|
| `time-clock.ts` | Timer formatting, clock-out copy |
|
||||||
|
| `time-clock-live-activity*.ts` | Live Activity bridge |
|
||||||
|
| `tab-layout.ts`, `tab-bar-insets.ts`, `top-chrome-insets.ts` | Layout metrics |
|
||||||
|
|
||||||
|
## Components layout
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── ui/ # Button, Card, Input, SelectField, DateTimeField
|
||||||
|
├── time-clock/ # TimeClockPanel
|
||||||
|
├── clients/ # ClientForm
|
||||||
|
├── businesses/ # BusinessForm
|
||||||
|
├── invoices/ # LineItemEditor
|
||||||
|
├── AuthServerPicker, AccountSwitcher, TopChrome, AppLockOverlay, Logo, …
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native config
|
||||||
|
|
||||||
|
**`app.json`**
|
||||||
|
|
||||||
|
- `scheme`: `beenvoice`
|
||||||
|
- `bundleIdentifier`: `com.beenvoice.app`
|
||||||
|
- Plugins: dev-client, router, secure-store, widgets, local-authentication
|
||||||
|
- iOS Face ID usage strings; custom `beenvoice.icon`
|
||||||
|
|
||||||
|
**`eas.json`**
|
||||||
|
|
||||||
|
- Profiles: development, preview, production
|
||||||
|
- `cli.appVersionSource`: remote
|
||||||
|
- User does not require EAS for local `expo run:ios`
|
||||||
|
|
||||||
|
**Metro**: port **8082** (`package.json` scripts) to avoid collisions.
|
||||||
|
|
||||||
|
## Deep links
|
||||||
|
|
||||||
|
| URL | Handler |
|
||||||
|
|-----|---------|
|
||||||
|
| `beenvoice://reset-password?token=…` | `reset-password.tsx` |
|
||||||
|
| `beenvoice://timer` | Live Activity tap → timer tab |
|
||||||
|
|
||||||
|
## Development commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run ios # build + run simulator, Metro :8082
|
||||||
|
bun run start # Metro only (--dev-client --port 8082)
|
||||||
|
bunx expo prebuild --platform ios --clean # after native dep / icon changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause |
|
||||||
|
|---------|----------------|
|
||||||
|
| `PlatformConstants` / runtime not ready | Wrong Metro port, stale native build, or Expo Go instead of dev client |
|
||||||
|
| Login twice | Session not migrated — see `finalizeAuthenticatedAccount` |
|
||||||
|
| Live Activity blank | Widget UI outside `"widget"` function; rebuild native |
|
||||||
|
| API unreachable on device | Use LAN IP in `EXPO_PUBLIC_API_URL`, not `localhost` |
|
||||||
|
| Auth fails on device | `BETTER_AUTH_URL` on server must match reachable host |
|
||||||
|
|
||||||
|
## Server dependency
|
||||||
|
|
||||||
|
Requires beenvoice with:
|
||||||
|
|
||||||
|
- `@better-auth/expo` in `src/lib/auth.ts`
|
||||||
|
- `trustedOrigins` including `beenvoice://` and `exp://`
|
||||||
|
- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`)
|
||||||
|
|
||||||
|
See [beenvoice/docs/ARCHITECTURE.md](../../beenvoice/docs/ARCHITECTURE.md).
|
||||||
@@ -0,0 +1,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 Apple’s [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 |
|
||||||
@@ -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
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||