diff --git a/.env.example b/.env.example
index 671d4e4..d84b729 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,4 @@
# beenvoice API base URL (no trailing slash)
-# Omit or leave unset in production builds — app defaults to https://beenvoice.soconnor.dev
+# Omit or leave unset in production builds — app defaults to https://beenvoice.app
# 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
diff --git a/.ios-release.env.example b/.ios-release.env.example
index aabdcf2..b0459ce 100644
--- a/.ios-release.env.example
+++ b/.ios-release.env.example
@@ -12,7 +12,7 @@ APPLE_TEAM_ID=
# 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
+EXPO_PUBLIC_API_URL=https://beenvoice.app
# 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.
diff --git a/AGENTS.md b/AGENTS.md
index 9e0c4ad..a3e4d56 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Expo SDK **56**. Read [Expo v56 docs](https://docs.expo.dev/versions/v56.0.0/) b
## Conventions
- **Package manager**: Bun only
-- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice/src/*`)
+- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice-web/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`)
@@ -30,4 +30,4 @@ Expo SDK **56**. Read [Expo v56 docs](https://docs.expo.dev/versions/v56.0.0/) b
## Server repo
-Sibling `../beenvoice` — run `bun run dev` on :3000 before mobile dev.
+Sibling `../beenvoice-web` — run `bun run dev` on :3000 before mobile dev.
diff --git a/README.md b/README.md
index d7d3168..19f6434 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# 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.
+Expo companion for [beenvoice-web](../beenvoice-web) — 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))
+- beenvoice API running ([setup](../beenvoice-web/README.md))
- Xcode + iOS Simulator (or device) for native dev build
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
@@ -29,7 +29,7 @@ EXPO_PUBLIC_API_URL=http://localhost:3000
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`.
+Omit `EXPO_PUBLIC_API_URL` in production builds to default to `https://beenvoice.app`.
Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `beenvoice://` in `trustedOrigins`.
@@ -37,7 +37,7 @@ Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `been
```bash
# Terminal 1 — API
-cd ../beenvoice && bun run dev
+cd ../beenvoice-web && bun run dev
# Terminal 2 — mobile (builds native app if needed)
cd beenvoice-app && bun run ios
@@ -80,7 +80,7 @@ bun run ios
- **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
+- **Server picker**: Official (`beenvoice.app`) or custom URL on auth screens
Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model)
@@ -94,16 +94,23 @@ Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#mul
| `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`):
+**iOS Shortcuts / Siri** (requires a **native dev client or TestFlight build** — not Expo Go; iOS **18+**):
- **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.
+Shortcuts are **not pre-installed** in your Shortcuts library. To add one:
+
+1. Install a fresh native build (`bunx expo prebuild --platform ios && bun run ios`, or a new EAS/TestFlight build).
+2. Open beenvoice once while signed in.
+3. Open **Shortcuts** → **+** → **Add Action** → search **beenvoice** (or “Clock In”).
+4. Choose **Clock In**, **Clock Out**, or **Open Time Clock**.
+5. Pick a client once on the Timer tab before the first clock-in shortcut.
+
+You can also say “Hey Siri, clock in with beenvoice”. Settings → Shortcuts & Siri in the app has a setup guide and test links.
+
+If beenvoice actions never appear when searching in Shortcuts, the installed build predates App Intents — rebuild and reinstall.
**Test deep links:**
@@ -138,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
## Related
-- [beenvoice README](../beenvoice/README.md)
-- [beenvoice ARCHITECTURE](../beenvoice/docs/ARCHITECTURE.md)
+- [beenvoice-web README](../beenvoice-web/README.md)
+- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md)
- [Workspace root README](../README.md)
diff --git a/app.json b/app.json
index 52ac725..33bada8 100644
--- a/app.json
+++ b/app.json
@@ -10,7 +10,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
- "buildNumber": "7",
+ "buildNumber": "11",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx
index b9d1182..2aed1e9 100644
--- a/app/(app)/_layout.tsx
+++ b/app/(app)/_layout.tsx
@@ -4,6 +4,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { ShortcutHandler } from "@/components/ShortcutHandler";
+import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext";
@@ -74,6 +75,7 @@ export default function AppLayout() {
+
diff --git a/app/(app)/invoices/[id].tsx b/app/(app)/invoices/[id].tsx
index 162fb2d..decd464 100644
--- a/app/(app)/invoices/[id].tsx
+++ b/app/(app)/invoices/[id].tsx
@@ -1,14 +1,12 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
-import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
-import {
- InvoiceEditorSectionTabs,
- type InvoiceEditorSection,
-} from "@/components/invoices/InvoiceEditorSectionTabs";
+import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
+import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
@@ -24,12 +22,11 @@ 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("edit");
+ const [section, setSection] = useState("details");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -45,16 +42,6 @@ export default function InvoiceDetailScreen() {
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.");
@@ -63,6 +50,12 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send reminder", err.message),
});
+ const invoice = invoiceQuery.data;
+ const previewInput = useMemo(
+ () => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
+ [invoice],
+ );
+
if (!id) {
return ;
}
@@ -85,17 +78,12 @@ export default function InvoiceDetailScreen() {
);
}
- 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() {
+ function openSendScreen() {
if (!clientEmail) {
Alert.alert(
"No client email",
@@ -103,18 +91,14 @@ export default function InvoiceDetailScreen() {
);
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 }),
- },
- ],
- );
+ if (invoice.items.length === 0) {
+ Alert.alert(
+ "No line items",
+ "Add line items or clock time to this invoice before sending.",
+ );
+ return;
+ }
+ router.push(`/(app)/invoices/send/${invoice.id}`);
}
function promptPaymentReminder() {
@@ -159,25 +143,7 @@ export default function InvoiceDetailScreen() {
return (
-
- status !== "paid" ? (
- pressed && styles.headerPressed}
- >
-
- {status === "draft" ? "Send" : "Resend"}
-
-
- ) : null,
- }}
- />
+
- router.push(`/(app)/invoices/edit/${invoice.id}`)}
+ onSend={openSendScreen}
/>
{section === "preview" ? (
@@ -215,6 +182,8 @@ export default function InvoiceDetailScreen() {
) : (
<>
+
+
@@ -234,20 +203,27 @@ export default function InvoiceDetailScreen() {
- {invoice.items.map((item) => (
-
-
- {item.description}
-
- {formatDate(item.date)} · {item.hours}h ×{" "}
- {formatCurrency(item.rate, invoice.currency)}
+ {invoice.items.length === 0 ? (
+
+ No line items yet. Clock time to this invoice from the Timer tab, or edit to
+ add lines manually.
+
+ ) : (
+ invoice.items.map((item) => (
+
+
+ {item.description}
+
+ {formatDate(item.date)} · {item.hours}h ×{" "}
+ {formatCurrency(item.rate, invoice.currency)}
+
+
+
+ {formatCurrency(item.amount, invoice.currency)}
-
- {formatCurrency(item.amount, invoice.currency)}
-
-
- ))}
+ ))
+ )}
0 ? `Tax (${invoice.taxRate}%)` : undefined}
@@ -264,43 +240,19 @@ export default function InvoiceDetailScreen() {
) : null}
-
- {status !== "paid" ? (
-
- ) : null}
- {status === "sent" || status === "overdue" ? (
-
- ) : null}
-
+ promptStatusChange(status)}
+ updateStatusLoading={updateStatus.isPending}
+ onTrackTime={() =>
+ router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
+ }
+ />
>
)}
@@ -398,22 +350,18 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
color: colors.foreground,
fontSize: 14,
},
+ emptyLines: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ lineHeight: 20,
+ color: colors.mutedForeground,
+ },
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",
diff --git a/app/(app)/invoices/_layout.tsx b/app/(app)/invoices/_layout.tsx
index 41ebb71..db7c8b6 100644
--- a/app/(app)/invoices/_layout.tsx
+++ b/app/(app)/invoices/_layout.tsx
@@ -43,6 +43,13 @@ export default function InvoicesLayout() {
headerBackTitle: "Invoices",
}}
/>
+
new Date());
+ const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState(null);
const [items, setItems] = useState([]);
- const [section, setSection] = useState("edit");
+ const [section, setSection] = useState("setup");
const [error, setError] = useState(null);
useEffect(() => {
const invoice = invoiceQuery.data;
if (!invoice) return;
+ setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
+ setClientId(invoice.clientId);
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
+ setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems(
invoice.items.map((item) => ({
@@ -72,6 +80,11 @@ export default function InvoiceEditScreen() {
);
}, [invoiceQuery.data]);
+ useEffect(() => {
+ if (businessId || !businessesQuery.data?.length) return;
+ setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
+ }, [businessId, businessesQuery.data]);
+
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
@@ -85,19 +98,31 @@ export default function InvoiceEditScreen() {
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 businessOptions = useMemo(
+ () =>
+ (businessesQuery.data ?? []).map((business) => ({
+ label: business.name,
+ value: business.id,
+ })),
+ [businessesQuery.data],
+ );
+
+ 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 ?? invoice?.currency ?? "USD";
+ const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
+
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
@@ -108,35 +133,39 @@ export default function InvoiceEditScreen() {
[items],
);
- const taxRate = invoice?.taxRate ?? 0;
- const taxAmount = subtotal * (taxRate / 100);
+ const parsedTaxRate = Number(taxRate) || 0;
+ const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
- const currency = invoice?.currency ?? "USD";
const lineItemsError = isDraft ? validateLineItems(items) : null;
- const canSave = isDraft ? !lineItemsError : true;
+ const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
+ const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
+ const clientError = isDraft && !clientId ? "Select a client" : undefined;
+ const canSave = isDraft
+ ? !lineItemsError && !taxError && !businessError && !clientError
+ : true;
const previewInput = useMemo(() => {
if (!invoice) return null;
return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix,
- businessId: invoice.businessId,
- clientId: invoice.clientId,
+ businessId: resolvedBusinessId,
+ clientId,
issueDate: new Date(invoice.issueDate),
dueDate,
status: invoice.status as "draft" | "sent" | "paid",
notes,
- taxRate,
+ taxRate: parsedTaxRate,
currency,
items,
});
- }, [invoice, dueDate, notes, taxRate, currency, items]);
+ }, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
if (!id) {
return ;
}
- if (invoiceQuery.isLoading) {
+ if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
return ;
}
@@ -147,28 +176,6 @@ export default function InvoiceEditScreen() {
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) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
@@ -186,10 +193,6 @@ export default function InvoiceEditScreen() {
}
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));
}
@@ -230,6 +233,10 @@ export default function InvoiceEditScreen() {
sendReminderAt,
...(isDraft
? {
+ businessId: resolvedBusinessId,
+ clientId,
+ taxRate: parsedTaxRate,
+ currency,
items: parsedItems,
}
: {}),
@@ -254,7 +261,9 @@ export default function InvoiceEditScreen() {
{invoice.invoicePrefix}
{invoice.invoiceNumber}
- {invoice.client?.name ?? "Client"}
+
+ {selectedClient?.name ?? invoice.client?.name ?? "Client"}
+
@@ -263,96 +272,120 @@ export default function InvoiceEditScreen() {
+ ) : section === "setup" ? (
+
+
+ {taxError ? {taxError} : null}
+
) : (
<>
-
-
- {isDraft ? (
- <>
-
- {sendReminderAt ? (
- setSendReminderAt(null)}>
-
- Clear send reminder
-
+
+ {!isDraft ? (
+
+ Line items are locked after an invoice is sent. Mark as draft on the invoice
+ screen to edit entries.
+
+ ) : items.length === 0 ? (
+
+ No line items yet. Add lines here or clock time to this invoice from the
+ Timer tab.
+
+ ) : null}
+ {items.map((item, index) => (
+ updateItem(index, patch)}
+ onRemove={() => removeItem(index)}
+ readOnly={!isDraft}
+ />
+ ))}
+
+ {isDraft ? (
+
+ + Add another line
) : null}
- >
- ) : null}
-
-
-
- {!isDraft ? (
-
- Line items are locked after an invoice is sent. Mark as draft on the invoice
- screen to edit entries.
-
- ) : (
-
- )}
- {items.map((item, index) => (
- updateItem(index, patch)}
- onRemove={() => removeItem(index)}
- readOnly={!isDraft}
- />
- ))}
+ 0 ? `Tax (${parsedTaxRate}%)` : undefined}
+ taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
+ total={formatCurrency(total, currency)}
+ />
+
- {isDraft ? (
-
- + Add line
-
- ) : null}
-
- 0 ? `Tax (${taxRate}%)` : undefined}
- taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
- total={formatCurrency(total, currency)}
- />
-
-
- {lineItemsError ? {lineItemsError} : null}
- {error ? {error} : null}
-
-
-
- {status !== "paid" ? (
-
- ) : null}
-
+ {lineItemsError ? {lineItemsError} : null}
>
)}
+
+ {error ? {error} : null}
+
+ {
+ if (!clientEmail) {
+ Alert.alert(
+ "No client email",
+ "Add an email address to this client on the web app before sending invoices.",
+ );
+ return;
+ }
+ if (items.length === 0) {
+ Alert.alert(
+ "No line items",
+ "Add line items or clock time to this invoice before sending.",
+ );
+ return;
+ }
+ router.push(`/(app)/invoices/send/${invoice.id}`);
+ },
+ disabled: !clientEmail || items.length === 0,
+ }
+ : undefined
+ }
+ />
@@ -380,23 +413,21 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
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,
+ emptyLines: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ lineHeight: 20,
+ color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: {
- paddingTop: spacing.sm,
+ paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {
@@ -409,7 +440,4 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
fontFamily: fonts.body,
fontSize: 14,
},
- actions: {
- gap: spacing.sm,
- },
});
diff --git a/app/(app)/invoices/index.tsx b/app/(app)/invoices/index.tsx
index acd9716..5120663 100644
--- a/app/(app)/invoices/index.tsx
+++ b/app/(app)/invoices/index.tsx
@@ -169,7 +169,19 @@ export default function InvoicesScreen() {
router.push("/(app)/invoices/new")}
+ onPress={() => {
+ Alert.alert("Create invoice", "Choose how to start", [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "With line items",
+ onPress: () => router.push("/(app)/invoices/new"),
+ },
+ {
+ text: "Blank (for timer)",
+ onPress: () => router.push("/(app)/invoices/new?blank=1"),
+ },
+ ]);
+ }}
/>
diff --git a/app/(app)/invoices/new.tsx b/app/(app)/invoices/new.tsx
index 0ab41f0..59d7115 100644
--- a/app/(app)/invoices/new.tsx
+++ b/app/(app)/invoices/new.tsx
@@ -1,4 +1,4 @@
-import { router, Stack } from "expo-router";
+import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
@@ -16,55 +16,75 @@ import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
+import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
+import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
-import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
+import { LineItemEditor, 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 { resolveInvoiceBusinessId } from "@/lib/invoice-business";
+import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
+import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
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 { blank } = useLocalSearchParams<{ blank?: string }>();
+ const isBlank = blank === "1" || blank === "true";
+ const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
+ const [businessId, setBusinessId] = useState("");
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([
- {
- date: new Date(),
- description: "",
- hours: "1",
- rate: "0",
- },
- ]);
- const [section, setSection] = useState("edit");
+ const [items, setItems] = useState(() =>
+ isBlank
+ ? []
+ : [
+ {
+ date: new Date(),
+ description: "",
+ hours: "1",
+ rate: "0",
+ },
+ ],
+ );
+ const [section, setSection] = useState("setup");
const [error, setError] = useState(null);
+ useEffect(() => {
+ if (businessId || !businessesQuery.data?.length) return;
+ setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
+ }, [businessId, businessesQuery.data]);
+
+ const businessOptions = useMemo(
+ () =>
+ (businessesQuery.data ?? []).map((business) => ({
+ label: business.name,
+ value: business.id,
+ })),
+ [businessesQuery.data],
+ );
+
const clientOptions = useMemo(
() =>
(clientsQuery.data ?? []).map((client) => ({
@@ -76,6 +96,7 @@ export default function NewInvoiceScreen() {
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? "USD";
+ const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
@@ -120,6 +141,7 @@ export default function NewInvoiceScreen() {
() =>
buildPreviewPdfInput({
invoiceNumber,
+ businessId: resolvedBusinessId,
clientId,
issueDate,
dueDate,
@@ -128,9 +150,20 @@ export default function NewInvoiceScreen() {
notes,
items,
}),
- [invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
+ [
+ invoiceNumber,
+ resolvedBusinessId,
+ clientId,
+ issueDate,
+ dueDate,
+ parsedTaxRate,
+ currency,
+ notes,
+ items,
+ ],
);
+ const businessError = resolvedBusinessId ? undefined : "Select a business";
const clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
@@ -138,13 +171,15 @@ export default function NewInvoiceScreen() {
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items);
const canCreate =
+ businessOptions.length > 0 &&
clientOptions.length > 0 &&
+ !businessError &&
!clientError &&
!invoiceNumberError &&
!taxError &&
!lineItemsError;
- if (clientsQuery.isLoading) {
+ if (businessesQuery.isLoading || clientsQuery.isLoading) {
return ;
}
@@ -165,10 +200,6 @@ export default function NewInvoiceScreen() {
}
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));
}
@@ -193,6 +224,7 @@ export default function NewInvoiceScreen() {
}
createInvoice.mutate({
+ businessId: resolvedBusinessId,
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
@@ -207,7 +239,12 @@ export default function NewInvoiceScreen() {
return (
-
+
+ ) : section === "setup" ? (
+
+ {clientOptions.length === 0 || businessOptions.length === 0 ? (
+
+
+ {businessOptions.length === 0
+ ? "Add a business before creating an invoice."
+ : "Add a client before creating an invoice."}
+
+
+ router.push(
+ businessOptions.length === 0
+ ? "/(app)/entities/businesses/new"
+ : "/(app)/entities/clients/new",
+ )
+ }
+ />
+
+ ) : (
+
+ )}
+ {taxError ? {taxError} : null}
+ {invoiceNumberError ? (
+ {invoiceNumberError}
+ ) : null}
+
) : (
<>
-
- {clientOptions.length === 0 ? (
-
-
- Add a client before creating an invoice.
-
- router.push("/(app)/entities/clients/new")}
+
+ {isBlank && items.length === 0 ? (
+
+ No line items yet. Save this draft and clock time to it from the Timer tab,
+ or add lines here.
+
+ ) : null}
+ {items.map((item, index) => (
+ updateItem(index, patch)}
+ onRemove={() => removeItem(index)}
+ />
+ ))}
+
+
+ + Add another line
+
+
+ 0 ? `Tax (${parsedTaxRate}%)` : undefined}
+ taxAmount={
+ parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
+ }
+ total={formatCurrency(total, currency)}
/>
-
- ) : (
-
- )}
-
- {
- setIssueDate(date);
- setDueDate((current) => (current < date ? defaultDueDate(date) : current));
- }}
- />
-
-
-
-
+
-
-
- {items.map((item, index) => (
- updateItem(index, patch)}
- onRemove={() => removeItem(index)}
- />
- ))}
-
-
- + Add line
-
-
- 0 ? `Tax (${parsedTaxRate}%)` : undefined}
- taxAmount={
- parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
- }
- total={formatCurrency(total, currency)}
- />
-
-
- {lineItemsError ? {lineItemsError} : null}
- {error ? {error} : null}
-
-
+ {lineItemsError ? {lineItemsError} : null}
>
)}
+
+ {error ? {error} : null}
+
+
@@ -336,21 +369,24 @@ const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
padding: spacing.md,
gap: spacing.md,
},
- notesInput: {
- minHeight: 72,
- textAlignVertical: "top",
- },
- noClients: {
+ noEntities: {
gap: spacing.sm,
},
- noClientsText: {
+ noEntitiesText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
lineHeight: 20,
},
+ emptyLines: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ lineHeight: 20,
+ color: colors.mutedForeground,
+ marginBottom: spacing.sm,
+ },
addLine: {
- paddingTop: spacing.sm,
+ paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {
diff --git a/app/(app)/invoices/send/[id].tsx b/app/(app)/invoices/send/[id].tsx
new file mode 100644
index 0000000..282699f
--- /dev/null
+++ b/app/(app)/invoices/send/[id].tsx
@@ -0,0 +1,237 @@
+import { router, Stack, useLocalSearchParams } from "expo-router";
+import { useMemo, useState } from "react";
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+
+import { AppBackground } from "@/components/AppBackground";
+import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
+import { LoadingScreen } from "@/components/LoadingScreen";
+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 { formatCurrency, formatDate } from "@/lib/format";
+import { getInvoiceStatus } from "@/lib/invoice-status";
+import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
+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 InvoiceSendScreen() {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(createSendStyles);
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const utils = api.useUtils();
+ const scrollPadding = useTabBarScrollPadding();
+ const [customMessage, setCustomMessage] = useState("");
+
+ const invoiceQuery = api.invoices.getById.useQuery(
+ { id: id ?? "" },
+ { enabled: Boolean(id) },
+ );
+
+ const sendInvoice = api.email.sendInvoice.useMutation({
+ onSuccess: (data) => {
+ void utils.invoices.getById.invalidate({ id: id ?? "" });
+ void utils.invoices.getAll.invalidate();
+ void utils.dashboard.getStats.invalidate();
+ Alert.alert("Invoice sent", data.message, [
+ { text: "OK", onPress: () => router.replace(`/(app)/invoices/${id}`) },
+ ]);
+ },
+ onError: (err) => Alert.alert("Could not send invoice", err.message),
+ });
+
+ const invoice = invoiceQuery.data;
+ const previewInput = useMemo(
+ () => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
+ [invoice],
+ );
+
+ if (!id) {
+ return ;
+ }
+
+ if (invoiceQuery.isLoading) {
+ return ;
+ }
+
+ if (!invoice) {
+ return ;
+ }
+
+ const status = getInvoiceStatus(invoice);
+ const clientEmail = invoice.client?.email?.trim() ?? "";
+ const businessName = invoice.business?.name ?? "Your business";
+ const sendLabel = status === "draft" ? "Send invoice" : "Resend invoice";
+
+ function handleSend() {
+ if (!clientEmail) {
+ Alert.alert(
+ "No client email",
+ "Add an email address to this client on the web app before sending invoices.",
+ );
+ return;
+ }
+ if (invoice.items.length === 0) {
+ Alert.alert(
+ "No line items",
+ "Add line items or clock time to this invoice before sending.",
+ );
+ return;
+ }
+
+ sendInvoice.mutate({
+ invoiceId: invoice!.id,
+ customMessage: customMessage.trim() || undefined,
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Optional note included in the email body.
+
+
+
+
+
+ {!clientEmail ? (
+
+ Add a client email address before sending.
+
+ ) : invoice.items.length === 0 ? (
+
+ Add line items before sending this invoice.
+
+ ) : null}
+
+
+
+ );
+}
+
+function SummaryRow({
+ label,
+ value,
+ bold,
+}: {
+ label: string;
+ value: string;
+ bold?: boolean;
+}) {
+ const { colors } = useAppTheme();
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+const summaryStyles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ gap: spacing.md,
+ paddingVertical: 4,
+ },
+ label: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ },
+ value: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 14,
+ flex: 1,
+ textAlign: "right",
+ },
+ bold: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ },
+});
+
+const createSendStyles = (colors: ThemeColors) =>
+ StyleSheet.create({
+ flex: { flex: 1 },
+ container: {
+ padding: spacing.md,
+ gap: spacing.md,
+ },
+ messageHint: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ lineHeight: 18,
+ marginBottom: spacing.xs,
+ },
+ messageInput: {
+ minHeight: 96,
+ textAlignVertical: "top",
+ },
+ warning: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ textAlign: "center",
+ },
+ });
diff --git a/app/(app)/settings.tsx b/app/(app)/settings.tsx
index 3ae910e..81bf957 100644
--- a/app/(app)/settings.tsx
+++ b/app/(app)/settings.tsx
@@ -11,6 +11,7 @@ import { InstanceUrlField } from "@/components/InstanceUrlField";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PinPrompt } from "@/components/PinPrompt";
+import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
@@ -294,6 +295,12 @@ export default function SettingsScreen() {
+ {Platform.OS === "ios" ? (
+
+
+
+ ) : null}
+
diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx
index 6a74f48..bab2fd8 100644
--- a/app/(auth)/register.tsx
+++ b/app/(auth)/register.tsx
@@ -1,19 +1,13 @@
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 { StyleSheet, Text, View } from "react-native";
+
+import { AuthCard } from "@/components/auth/AuthCard";
+import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
+import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
import { AuthServerPicker } from "@/components/AuthServerPicker";
-import { HeadingText, Logo } from "@/components/Logo";
+import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
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";
@@ -21,7 +15,12 @@ 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";
+import {
+ isRequiredString,
+ isValidEmail,
+ isValidPassword,
+ useFieldVisibility,
+} from "@/lib/form-validation";
export default function RegisterScreen() {
const authClient = useAuthClient();
@@ -96,141 +95,109 @@ export default function RegisterScreen() {
}
return (
-
-
-
-
-
-
-
-
- Create your account
-
- Get started today
-
-
+
+
+
-
+
-
-
-
- touch("firstName")}
- autoComplete="given-name"
- placeholder="Jane"
- required
- error={visible("firstName") ? firstNameError : undefined}
- />
-
-
- touch("lastName")}
- autoComplete="family-name"
- placeholder="Doe"
- required
- error={visible("lastName") ? lastNameError : undefined}
- />
-
-
-
- touch("email")}
- placeholder="you@example.com"
- required
- error={visible("email") ? emailValidationError : undefined}
- />
- touch("password")}
- placeholder="At least 8 characters"
- required
- error={visible("password") ? passwordValidationError : undefined}
- />
-
- {error ? (
- {error}
- ) : null}
-
-
-
-
-
- Already have an account?{" "}
-
- Sign in
-
-
-
+
+
+
+ touch("firstName")}
+ autoComplete="given-name"
+ placeholder="John"
+ required
+ error={visible("firstName") ? firstNameError : undefined}
+ />
-
-
-
-
+
+ touch("lastName")}
+ autoComplete="family-name"
+ placeholder="Doe"
+ required
+ error={visible("lastName") ? lastNameError : undefined}
+ />
+
+
+
+ touch("email")}
+ placeholder="you@example.com"
+ required
+ error={visible("email") ? emailValidationError : undefined}
+ />
+ touch("password")}
+ placeholder="••••••••"
+ hint="At least 8 characters"
+ required
+ error={visible("password") ? passwordValidationError : undefined}
+ />
+
+ {error ? (
+ {error}
+ ) : null}
+
+
+
+
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+
+
+
+
);
}
const styles = StyleSheet.create({
- safe: { flex: 1 },
- flex: { flex: 1 },
- container: {
- flexGrow: 1,
- justifyContent: "center",
- alignItems: "center",
- padding: spacing.lg,
- paddingVertical: spacing.xl,
+ form: {
+ gap: spacing.md,
},
- content: {
- width: "100%",
- maxWidth: 420,
+ row: {
+ flexDirection: "row",
+ gap: spacing.md,
},
- card: {
- gap: spacing.lg,
+ half: {
+ flex: 1,
},
- 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,
diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx
index d77873b..ba62994 100644
--- a/app/(auth)/sign-in.tsx
+++ b/app/(auth)/sign-in.tsx
@@ -1,21 +1,16 @@
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 { Pressable, StyleSheet, Text, View } from "react-native";
+
+import { AuthCard } from "@/components/auth/AuthCard";
+import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
+import { AuthDivider } from "@/components/auth/AuthDivider";
+import { AuthNotice } from "@/components/auth/AuthNotice";
+import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
import { AuthServerPicker } from "@/components/AuthServerPicker";
-import { HeadingText, Logo } from "@/components/Logo";
-import { FullScreen } from "@/components/Screen";
+import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
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";
@@ -124,177 +119,99 @@ export default function SignInScreen() {
}
return (
-
-
-
-
-
-
-
-
- Welcome back
-
- Sign in to manage invoices on the go
-
-
+
+
+
-
+
- {signupsDisabled ? (
-
- New account registration is currently disabled on this server.
-
- ) : null}
+ {signupsDisabled ? (
+ New account registration is currently disabled.
+ ) : null}
- {authentikEnabled ? (
-
- void handleAuthentikSignIn()}
- />
-
-
-
- or
-
-
-
-
- ) : null}
+ {authentikEnabled ? (
+
+ void handleAuthentikSignIn()}
+ />
+
+
+ ) : null}
-
- touch("email")}
- placeholder="you@example.com"
- required
- error={visible("email") ? emailValidationError : undefined}
- />
- touch("password")}
- placeholder="••••••••"
- required
- error={visible("password") ? passwordValidationError : undefined}
- />
+
+ touch("email")}
+ placeholder="you@example.com"
+ required
+ error={visible("email") ? emailValidationError : undefined}
+ />
+ touch("password")}
+ placeholder="••••••••"
+ required
+ error={visible("password") ? passwordValidationError : undefined}
+ labelAccessory={
+ router.push("/(auth)/forgot-password")} hitSlop={8}>
+
+ Forgot password?
+
+
+ }
+ />
- router.push("/(auth)/forgot-password")}>
-
- Forgot password?
-
-
+ {error ? (
+ {error}
+ ) : null}
- {error ? (
- {error}
- ) : null}
+
+
-
-
+ {!signupsDisabled ? (
+
+ Don't have an account?{" "}
+
+ Create account
+
+
+ ) : null}
- {!signupsDisabled ? (
-
- Don't have an account?{" "}
-
- Create one
-
-
- ) : null}
-
-
-
-
-
-
+
+
+
);
}
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,
},
diff --git a/components/AuthServerPicker.tsx b/components/AuthServerPicker.tsx
index 3285aa5..3b6a785 100644
--- a/components/AuthServerPicker.tsx
+++ b/components/AuthServerPicker.tsx
@@ -6,7 +6,7 @@ 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 { DEFAULT_API_URL, OFFICIAL_SERVER_PLACEHOLDER, invalidServerUrlMessage } from "@/lib/config";
import {
formatServerHost,
isServerConfigValid,
@@ -80,7 +80,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
async function commitSelfHostedUrl() {
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) {
- setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
+ setUrlError(invalidServerUrlMessage());
return;
}
@@ -164,7 +164,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
- placeholder="beenvoice.app or localhost:3000"
+ placeholder={OFFICIAL_SERVER_PLACEHOLDER}
required
error={urlError ?? undefined}
/>
diff --git a/components/CollapsibleServerField.tsx b/components/CollapsibleServerField.tsx
index ccc75ec..32191e7 100644
--- a/components/CollapsibleServerField.tsx
+++ b/components/CollapsibleServerField.tsx
@@ -8,6 +8,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
+import { OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
type CollapsibleServerFieldProps = {
defaultExpanded?: boolean;
@@ -100,7 +101,7 @@ export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleS
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
- placeholder="beenvoice.app or localhost:3000"
+ placeholder={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined}
/>
diff --git a/components/InstanceUrlField.tsx b/components/InstanceUrlField.tsx
index 1ebba2b..26b4862 100644
--- a/components/InstanceUrlField.tsx
+++ b/components/InstanceUrlField.tsx
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
+import { invalidServerUrlMessage, OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
import { normalizeInstanceUrl } from "@/lib/instance-url";
type InstanceUrlFieldProps = {
@@ -30,7 +31,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
const normalized = normalizeInstanceUrl(trimmed);
if (!normalized) {
- setError("Enter a valid URL like beenvoice.app or localhost:3000");
+ setError(invalidServerUrlMessage());
return;
}
@@ -55,7 +56,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
- placeholder="beenvoice.app or localhost:3000"
+ placeholder={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined}
/>
diff --git a/components/ShortcutHandler.tsx b/components/ShortcutHandler.tsx
index 47af4e7..3e8fcce 100644
--- a/components/ShortcutHandler.tsx
+++ b/components/ShortcutHandler.tsx
@@ -15,6 +15,10 @@ import {
resolveEffectiveHourlyRate,
} from "@/lib/time-clock";
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
+import {
+ endTimeClockLiveActivity,
+ syncTimeClockLiveActivity,
+} from "@/lib/time-clock-live-activity";
import type { ParsedShortcut } from "@/lib/shortcuts";
import { api } from "@/lib/trpc";
@@ -75,6 +79,7 @@ export function ShortcutHandler() {
}
await clockOut.mutateAsync({});
+ await endTimeClockLiveActivity();
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
@@ -121,6 +126,13 @@ export function ShortcutHandler() {
rate: rate ?? undefined,
});
await utils.timeEntries.getRunning.invalidate();
+ const running = await utils.timeEntries.getRunning.fetch();
+ if (running) {
+ const seconds = Math.floor(
+ (Date.now() - new Date(running.startedAt).getTime()) / 1000,
+ );
+ await syncTimeClockLiveActivity(running, seconds);
+ }
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
diff --git a/components/ShortcutsSetupCard.tsx b/components/ShortcutsSetupCard.tsx
new file mode 100644
index 0000000..84856fd
--- /dev/null
+++ b/components/ShortcutsSetupCard.tsx
@@ -0,0 +1,139 @@
+import { Ionicons } from "@expo/vector-icons";
+import * as Linking from "expo-linking";
+import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
+
+import { Button } from "@/components/ui/Button";
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { SHORTCUT_URLS } from "@/lib/shortcuts";
+
+const SHORTCUT_ACTIONS = [
+ { title: "Clock In", subtitle: "Start timer with your last client", url: SHORTCUT_URLS.clockIn },
+ { title: "Clock Out", subtitle: "Stop the running timer", url: SHORTCUT_URLS.clockOut },
+ { title: "Open Time Clock", subtitle: "Jump to the timer tab", url: SHORTCUT_URLS.openTimer },
+] as const;
+
+export function ShortcutsSetupCard() {
+ const { colors } = useAppTheme();
+
+ if (Platform.OS !== "ios") {
+ return null;
+ }
+
+ return (
+
+
+ beenvoice actions appear when you build a shortcut — they are not pre-installed in your
+ library. After installing a native build (not Expo Go), open the app once, then:
+
+
+
+
+ 1. Open the Shortcuts app → tap + → Add Action
+
+
+ 2. Search beenvoice (or Clock In / Clock Out)
+
+
+ 3. Pick Clock In, Clock Out, or Open Time Clock
+
+
+
+
+ You can also ask Siri: “Clock in with beenvoice”. Pick a client once on the Timer tab
+ before your first clock-in shortcut.
+
+
+
+ If nothing shows up, reinstall from a fresh native build (TestFlight or{" "}
+ bun run ios). Shortcuts require iOS 18+.
+
+
+
+ {SHORTCUT_ACTIONS.map((action) => (
+ void Linking.openURL(action.url)}
+ style={({ pressed }) => [
+ styles.actionRow,
+ {
+ borderColor: colors.border,
+ backgroundColor: pressed ? colors.muted : "transparent",
+ },
+ ]}
+ >
+
+ {action.title}
+
+ {action.subtitle}
+
+
+
+
+ ))}
+
+
+ void Linking.openURL("shortcuts://")}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ stack: {
+ gap: spacing.md,
+ },
+ lead: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ steps: {
+ borderWidth: 1,
+ borderRadius: 12,
+ gap: spacing.sm,
+ padding: spacing.md,
+ },
+ step: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ emphasis: {
+ fontFamily: fonts.bodyMedium,
+ },
+ meta: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ actions: {
+ gap: spacing.sm,
+ },
+ actionRow: {
+ alignItems: "center",
+ borderRadius: 12,
+ borderWidth: 1,
+ flexDirection: "row",
+ gap: spacing.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ },
+ actionCopy: {
+ flex: 1,
+ gap: 2,
+ },
+ actionTitle: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 15,
+ },
+ actionSubtitle: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+});
diff --git a/components/auth/AuthCard.tsx b/components/auth/AuthCard.tsx
new file mode 100644
index 0000000..56d0e5a
--- /dev/null
+++ b/components/auth/AuthCard.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from "react";
+import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
+
+import { GlassSurface } from "@/components/GlassSurface";
+import { spacing } from "@/constants/theme";
+
+const AUTH_CARD_RADIUS = 24;
+
+type AuthCardProps = {
+ children: ReactNode;
+ style?: StyleProp;
+};
+
+export function AuthCard({ children, style }: AuthCardProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ inner: {
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.lg,
+ gap: spacing.lg,
+ },
+});
diff --git a/components/auth/AuthCardHeader.tsx b/components/auth/AuthCardHeader.tsx
new file mode 100644
index 0000000..69c2aae
--- /dev/null
+++ b/components/auth/AuthCardHeader.tsx
@@ -0,0 +1,45 @@
+import { StyleSheet, Text, View } from "react-native";
+
+import { Logo } from "@/components/Logo";
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+
+type AuthCardHeaderProps = {
+ title: string;
+ description: string;
+};
+
+export function AuthCardHeader({ title, description }: AuthCardHeaderProps) {
+ const { colors } = useAppTheme();
+
+ return (
+
+
+
+ {title}
+
+ {description}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrapper: {
+ gap: spacing.md,
+ },
+ copy: {
+ gap: spacing.xs,
+ },
+ title: {
+ fontSize: 24,
+ fontFamily: fonts.headingSemi,
+ letterSpacing: -0.3,
+ },
+ description: {
+ fontSize: 14,
+ fontFamily: fonts.body,
+ lineHeight: 20,
+ },
+});
diff --git a/components/auth/AuthDivider.tsx b/components/auth/AuthDivider.tsx
new file mode 100644
index 0000000..8e32170
--- /dev/null
+++ b/components/auth/AuthDivider.tsx
@@ -0,0 +1,34 @@
+import { StyleSheet, Text, View } from "react-native";
+
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+
+export function AuthDivider() {
+ const { colors } = useAppTheme();
+
+ return (
+
+
+ or
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ },
+ line: {
+ flex: 1,
+ height: StyleSheet.hairlineWidth,
+ },
+ label: {
+ fontSize: 12,
+ fontFamily: fonts.bodyMedium,
+ textTransform: "uppercase",
+ letterSpacing: 0.6,
+ },
+});
diff --git a/components/auth/AuthNotice.tsx b/components/auth/AuthNotice.tsx
new file mode 100644
index 0000000..bf91e72
--- /dev/null
+++ b/components/auth/AuthNotice.tsx
@@ -0,0 +1,39 @@
+import { StyleSheet, Text } from "react-native";
+
+import { fonts, radii, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+
+type AuthNoticeProps = {
+ children: string;
+};
+
+export function AuthNotice({ children }: AuthNoticeProps) {
+ const { colors } = useAppTheme();
+
+ return (
+
+ {children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ notice: {
+ fontSize: 14,
+ fontFamily: fonts.body,
+ lineHeight: 20,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: radii.lg,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm + 2,
+ },
+});
diff --git a/components/auth/AuthScreenLayout.tsx b/components/auth/AuthScreenLayout.tsx
new file mode 100644
index 0000000..cd73085
--- /dev/null
+++ b/components/auth/AuthScreenLayout.tsx
@@ -0,0 +1,47 @@
+import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, type ViewProps } from "react-native";
+
+import { AuthBackground } from "@/components/AppBackground";
+import { FullScreen } from "@/components/Screen";
+import { spacing } from "@/constants/theme";
+
+export function AuthScreenLayout({ children, style, ...props }: ViewProps) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safe: {
+ flex: 1,
+ },
+ flex: {
+ flex: 1,
+ },
+ container: {
+ flexGrow: 1,
+ justifyContent: "center",
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.xl,
+ },
+ content: {
+ width: "100%",
+ maxWidth: 420,
+ alignSelf: "center",
+ },
+});
diff --git a/components/invoices/InvoiceDetailActions.tsx b/components/invoices/InvoiceDetailActions.tsx
new file mode 100644
index 0000000..2af51ab
--- /dev/null
+++ b/components/invoices/InvoiceDetailActions.tsx
@@ -0,0 +1,155 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+import { fonts, radii, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import type { InvoiceStatus } from "@/lib/invoice-status";
+
+type ActionItem = {
+ key: string;
+ title: string;
+ subtitle?: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ loading?: boolean;
+};
+
+type InvoiceDetailActionsProps = {
+ status: InvoiceStatus;
+ clientEmail: string;
+ onPaymentReminder?: () => void;
+ paymentReminderLoading?: boolean;
+ onUpdateStatus: () => void;
+ updateStatusLoading?: boolean;
+ onTrackTime: () => void;
+};
+
+export function InvoiceDetailActions({
+ status,
+ clientEmail,
+ onPaymentReminder,
+ paymentReminderLoading,
+ onUpdateStatus,
+ updateStatusLoading,
+ onTrackTime,
+}: InvoiceDetailActionsProps) {
+ const { colors } = useAppTheme();
+
+ const rows: ActionItem[] = [];
+
+ if ((status === "sent" || status === "overdue") && onPaymentReminder) {
+ rows.push({
+ key: "reminder",
+ title: "Send payment reminder",
+ subtitle: clientEmail ? `Nudge ${clientEmail}` : "Add a client email first",
+ icon: "notifications-outline",
+ onPress: onPaymentReminder,
+ loading: paymentReminderLoading,
+ });
+ }
+
+ rows.push(
+ {
+ key: "status",
+ title: "Update status",
+ subtitle: "Draft, sent, or paid",
+ icon: "swap-horizontal-outline",
+ onPress: onUpdateStatus,
+ loading: updateStatusLoading,
+ },
+ {
+ key: "timer",
+ title: "Track time",
+ subtitle: "Clock hours to this invoice",
+ icon: "timer-outline",
+ onPress: onTrackTime,
+ },
+ );
+
+ return (
+
+
+ {rows.map((row, index) => (
+
+ {index > 0 ? (
+
+ ) : null}
+ [styles.row, pressed && styles.rowPressed]}
+ >
+
+
+
+
+ {row.title}
+ {row.subtitle ? (
+
+ {row.subtitle}
+
+ ) : null}
+
+
+
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ borderWidth: 1,
+ borderRadius: radii.lg,
+ padding: spacing.md,
+ gap: spacing.sm,
+ },
+ list: {
+ gap: 0,
+ },
+ divider: {
+ height: StyleSheet.hairlineWidth,
+ marginVertical: spacing.xs,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ paddingVertical: spacing.sm,
+ },
+ rowPressed: {
+ opacity: 0.75,
+ },
+ iconWrap: {
+ width: 40,
+ height: 40,
+ borderRadius: radii.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ copy: {
+ flex: 1,
+ gap: 2,
+ minWidth: 0,
+ },
+ title: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ },
+ subtitle: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+});
diff --git a/components/invoices/InvoiceEditorFooter.tsx b/components/invoices/InvoiceEditorFooter.tsx
new file mode 100644
index 0000000..075403c
--- /dev/null
+++ b/components/invoices/InvoiceEditorFooter.tsx
@@ -0,0 +1,129 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+import { Button } from "@/components/ui/Button";
+import { fonts, radii, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+
+type SecondaryAction = {
+ title: string;
+ subtitle?: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ loading?: boolean;
+ disabled?: boolean;
+};
+
+type InvoiceEditorFooterProps = {
+ primaryTitle: string;
+ onPrimary: () => void;
+ primaryLoading?: boolean;
+ primaryDisabled?: boolean;
+ secondary?: SecondaryAction;
+};
+
+export function InvoiceEditorFooter({
+ primaryTitle,
+ onPrimary,
+ primaryLoading,
+ primaryDisabled,
+ secondary,
+}: InvoiceEditorFooterProps) {
+ const { colors } = useAppTheme();
+
+ return (
+
+
+
+ {secondary ? (
+ <>
+
+ [
+ styles.secondaryRow,
+ (pressed || secondary.loading) && styles.secondaryPressed,
+ (secondary.disabled || secondary.loading) && styles.secondaryDisabled,
+ ]}
+ >
+
+
+
+
+
+ {secondary.title}
+
+ {secondary.subtitle ? (
+
+ {secondary.subtitle}
+
+ ) : null}
+
+
+
+ >
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ borderWidth: 1,
+ borderRadius: radii.lg,
+ padding: spacing.md,
+ gap: spacing.sm,
+ },
+ divider: {
+ height: StyleSheet.hairlineWidth,
+ marginVertical: spacing.xs,
+ },
+ secondaryRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ paddingVertical: spacing.xs,
+ },
+ secondaryPressed: {
+ opacity: 0.75,
+ },
+ secondaryDisabled: {
+ opacity: 0.45,
+ },
+ iconWrap: {
+ width: 40,
+ height: 40,
+ borderRadius: radii.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ secondaryCopy: {
+ flex: 1,
+ gap: 2,
+ minWidth: 0,
+ },
+ secondaryTitle: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ },
+ secondarySubtitle: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+});
diff --git a/components/invoices/InvoiceEditorSectionTabs.tsx b/components/invoices/InvoiceEditorSectionTabs.tsx
index 3220a72..5016eb4 100644
--- a/components/invoices/InvoiceEditorSectionTabs.tsx
+++ b/components/invoices/InvoiceEditorSectionTabs.tsx
@@ -3,21 +3,34 @@ import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme";
-export type InvoiceEditorSection = "edit" | "preview";
+export type InvoiceEditorSection = "setup" | "lines" | "preview";
+export type InvoiceViewSection = "details" | "preview";
-type InvoiceEditorSectionTabsProps = {
- value: InvoiceEditorSection;
- onChange: (value: InvoiceEditorSection) => void;
- editLabel?: string;
- previewLabel?: string;
-};
+type InvoiceEditorSectionTabsProps =
+ | {
+ mode?: "edit";
+ value: InvoiceEditorSection;
+ onChange: (value: InvoiceEditorSection) => void;
+ }
+ | {
+ mode: "view";
+ value: InvoiceViewSection;
+ onChange: (value: InvoiceViewSection) => void;
+ };
+
+export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
+ const tabs =
+ props.mode === "view"
+ ? [
+ { id: "details" as const, label: "Details" },
+ { id: "preview" as const, label: "PDF" },
+ ]
+ : [
+ { id: "setup" as const, label: "Setup" },
+ { id: "lines" as const, label: "Line items" },
+ { id: "preview" as const, label: "PDF preview" },
+ ];
-export function InvoiceEditorSectionTabs({
- value,
- onChange,
- editLabel = "Edit",
- previewLabel = "PDF preview",
-}: InvoiceEditorSectionTabsProps) {
return (
- onChange("edit")}
- />
- onChange("preview")}
- />
+ {tabs.map((tab) => (
+ props.onChange(tab.id as never)}
+ />
+ ))}
);
diff --git a/components/invoices/InvoiceSetupForm.tsx b/components/invoices/InvoiceSetupForm.tsx
new file mode 100644
index 0000000..9add694
--- /dev/null
+++ b/components/invoices/InvoiceSetupForm.tsx
@@ -0,0 +1,229 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+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 { defaultDueDate } from "@/lib/invoice-number";
+
+type SelectOption = { label: string; value: string };
+
+type InvoiceSetupFormProps = {
+ businessId: string;
+ onBusinessIdChange: (value: string) => void;
+ businessOptions: SelectOption[];
+ businessError?: string;
+ businessReadOnly?: boolean;
+ clientId: string;
+ onClientIdChange: (value: string) => void;
+ clientOptions: SelectOption[];
+ clientError?: string;
+ clientReadOnly?: boolean;
+ invoiceNumber: string;
+ onInvoiceNumberChange?: (value: string) => void;
+ invoiceNumberReadOnly?: boolean;
+ issueDate: Date;
+ onIssueDateChange?: (date: Date) => void;
+ issueDateReadOnly?: boolean;
+ dueDate: Date;
+ onDueDateChange: (date: Date) => void;
+ taxRate: string;
+ onTaxRateChange?: (value: string) => void;
+ taxRateReadOnly?: boolean;
+ notes: string;
+ onNotesChange: (value: string) => void;
+ sendReminderAt?: Date | null;
+ onSendReminderAtChange?: (date: Date | null) => void;
+ showSendReminder?: boolean;
+};
+
+export function InvoiceSetupForm({
+ businessId,
+ onBusinessIdChange,
+ businessOptions,
+ businessError,
+ businessReadOnly = false,
+ clientId,
+ onClientIdChange,
+ clientOptions,
+ clientError,
+ clientReadOnly = false,
+ invoiceNumber,
+ onInvoiceNumberChange,
+ invoiceNumberReadOnly = false,
+ issueDate,
+ onIssueDateChange,
+ issueDateReadOnly = false,
+ dueDate,
+ onDueDateChange,
+ taxRate,
+ onTaxRateChange,
+ taxRateReadOnly = false,
+ notes,
+ onNotesChange,
+ sendReminderAt,
+ onSendReminderAtChange,
+ showSendReminder = false,
+}: InvoiceSetupFormProps) {
+ const { colors } = useAppTheme();
+
+ return (
+
+ {businessOptions.length === 0 ? (
+
+ Add a business in Entities before invoicing.
+
+ ) : (
+
+ )}
+
+ {clientOptions.length === 0 ? (
+
+ Add a client in Entities before invoicing.
+
+ ) : (
+
+ )}
+
+ {invoiceNumberReadOnly ? (
+
+
+ Invoice number
+
+
+ {invoiceNumber}
+
+
+ ) : (
+
+ )}
+
+ {issueDateReadOnly ? (
+
+
+ Issue date
+
+
+ {issueDate.toLocaleDateString()}
+
+
+ ) : (
+ {
+ onIssueDateChange?.(date);
+ if (dueDate < date) onDueDateChange(defaultDueDate(date));
+ }}
+ />
+ )}
+
+
+
+ {taxRateReadOnly ? (
+
+
+ Tax rate
+
+
+ {taxRate}%
+
+
+ ) : (
+
+ )}
+
+ {showSendReminder && onSendReminderAtChange ? (
+ <>
+
+ {sendReminderAt ? (
+ onSendReminderAtChange(null)}>
+
+ Clear send reminder
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ form: {
+ gap: spacing.sm,
+ },
+ hint: {
+ fontFamily: fonts.body,
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ readOnlyField: {
+ gap: 4,
+ paddingVertical: 4,
+ },
+ readOnlyLabel: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 13,
+ },
+ readOnlyValue: {
+ fontFamily: fonts.body,
+ fontSize: 15,
+ },
+ notesInput: {
+ minHeight: 72,
+ textAlignVertical: "top",
+ },
+ clearReminder: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 13,
+ marginBottom: spacing.xs,
+ },
+});
diff --git a/components/invoices/InvoiceTotals.tsx b/components/invoices/InvoiceTotals.tsx
index 85571e7..c739dde 100644
--- a/components/invoices/InvoiceTotals.tsx
+++ b/components/invoices/InvoiceTotals.tsx
@@ -65,10 +65,10 @@ function TotalRow({
const styles = StyleSheet.create({
totals: {
- marginTop: spacing.sm,
- paddingTop: spacing.sm,
- borderTopWidth: 1,
- gap: 6,
+ marginTop: spacing.md,
+ paddingTop: spacing.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ gap: spacing.xs,
},
row: {
flexDirection: "row",
diff --git a/components/invoices/InvoiceViewChips.tsx b/components/invoices/InvoiceViewChips.tsx
new file mode 100644
index 0000000..793b8e4
--- /dev/null
+++ b/components/invoices/InvoiceViewChips.tsx
@@ -0,0 +1,58 @@
+import { ScrollView, StyleSheet, View } from "react-native";
+
+import { FilterChip } from "@/components/FilterChip";
+import { spacing } from "@/constants/theme";
+import type { InvoiceStatus } from "@/lib/invoice-status";
+
+export type InvoiceViewSection = "details" | "preview";
+
+type InvoiceViewChipsProps = {
+ section: InvoiceViewSection;
+ onSectionChange: (section: InvoiceViewSection) => void;
+ status: InvoiceStatus;
+ onEdit: () => void;
+ onSend: () => void;
+};
+
+export function InvoiceViewChips({
+ section,
+ onSectionChange,
+ status,
+ onEdit,
+ onSend,
+}: InvoiceViewChipsProps) {
+ const sendLabel = status === "draft" ? "Send" : "Resend";
+
+ return (
+
+
+ onSectionChange("details")}
+ />
+
+ {status !== "paid" ? (
+
+ ) : null}
+ onSectionChange("preview")}
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ gap: spacing.sm,
+ paddingVertical: spacing.xs,
+ },
+});
diff --git a/components/invoices/LineItemEditor.tsx b/components/invoices/LineItemEditor.tsx
index 3ba4572..f729aae 100644
--- a/components/invoices/LineItemEditor.tsx
+++ b/components/invoices/LineItemEditor.tsx
@@ -25,28 +25,10 @@ type LineItemEditorProps = {
isLast?: boolean;
};
-export function LineItemsTableHeader() {
+function FieldLabel({ children }: { children: string }) {
const { colors } = useAppTheme();
-
return (
-
-
- Description
-
-
- Date
-
-
- Hrs
-
-
- Rate
-
-
- Amt
-
-
-
+ {children}
);
}
@@ -68,20 +50,20 @@ export function LineItemEditor({
return (
- {index + 1}
-
-
- {item.description.trim() || "Untitled line"}
-
-
- {formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
-
-
-
+
+ Line {index + 1}
+
+
+ {item.description.trim() || "Untitled line"}
+
+
+ {formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
+
+
{formatCurrency(amount, currency)}
@@ -92,154 +74,159 @@ export function LineItemEditor({
-
- {index + 1}
- onChange({ description })}
- placeholder="What was done?"
- placeholderTextColor={colors.mutedForeground}
- style={[
- styles.descriptionInput,
- {
- color: colors.foreground,
- borderColor: colors.border,
- backgroundColor: colors.cardGlass,
- },
- ]}
- />
-
+ Line {index + 1}
-
- onChange({ date })}
- style={styles.dateField}
- />
- onChange({ hours })}
- step={0.25}
- style={styles.hoursField}
- />
-
- $
- onChange({ rate })}
- keyboardType="decimal-pad"
- placeholder="0"
- placeholderTextColor={colors.mutedForeground}
- style={[styles.rateInput, { color: colors.foreground }]}
+ onChange({ description })}
+ placeholder="What was done?"
+ placeholderTextColor={colors.mutedForeground}
+ style={[
+ styles.descriptionInput,
+ {
+ color: colors.foreground,
+ borderColor: colors.border,
+ backgroundColor: colors.cardGlass,
+ },
+ ]}
+ />
+
+
+
+ Date
+ onChange({ date })}
+ style={styles.fieldControl}
/>
-
- {formatCurrency(amount, currency)}
-
+
+ Hours
+ onChange({ hours })}
+ step={0.25}
+ style={styles.fieldControl}
+ />
+
+
+ Rate
+
+ $
+ onChange({ rate })}
+ keyboardType="decimal-pad"
+ placeholder="0"
+ placeholderTextColor={colors.mutedForeground}
+ style={[styles.rateInput, { color: colors.foreground }]}
+ />
+
+
+
+
+
+
+ Amount
+
+ {formatCurrency(amount, currency)}
+
+
[styles.remove, pressed && styles.removePressed]}
+ style={({ pressed }) => [
+ styles.remove,
+ { borderColor: colors.border, backgroundColor: colors.cardGlass },
+ pressed && styles.removePressed,
+ ]}
>
-
+
);
}
-const headerStyles = StyleSheet.create({
- row: {
- flexDirection: "row",
- alignItems: "center",
+const styles = StyleSheet.create({
+ readBlock: {
+ paddingVertical: spacing.md,
gap: spacing.xs,
- paddingBottom: spacing.xs,
- marginBottom: spacing.xs,
- borderBottomWidth: 1,
},
- cell: {
+ readIndex: {
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,
+ fontSize: 15,
+ lineHeight: 20,
},
readSub: {
fontFamily: fonts.body,
+ fontSize: 13,
+ },
+ readAmount: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ marginTop: 2,
+ },
+ editBlock: {
+ paddingVertical: spacing.md,
+ gap: spacing.sm,
+ },
+ lineLabel: {
+ fontFamily: fonts.bodySemiBold,
fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: 0.4,
},
descriptionInput: {
- flex: 1,
- minHeight: 36,
+ width: "100%",
+ minHeight: 40,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.sm,
fontFamily: fonts.body,
- fontSize: 14,
- paddingVertical: 6,
+ fontSize: 15,
+ paddingVertical: 8,
},
- metricsRow: {
+ fieldsRow: {
flexDirection: "row",
- alignItems: "center",
- gap: spacing.xs,
- paddingLeft: 22,
+ gap: spacing.sm,
},
- dateField: {
- width: 72,
+ fieldCol: {
+ flex: 1,
+ gap: 4,
+ minWidth: 0,
},
- hoursField: {
- width: 88,
+ fieldLabel: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: 0.3,
+ },
+ fieldControl: {
+ width: "100%",
},
rateField: {
- width: 72,
+ minHeight: 36,
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: radii.md,
- minHeight: 36,
paddingHorizontal: spacing.xs,
},
ratePrefix: {
@@ -248,23 +235,40 @@ const styles = StyleSheet.create({
},
rateInput: {
flex: 1,
- fontFamily: fonts.body,
+ fontFamily: fonts.bodyMedium,
fontSize: 13,
paddingVertical: 4,
textAlign: "right",
+ minWidth: 0,
},
- amount: {
- width: 64,
- fontFamily: fonts.bodySemiBold,
- fontSize: 13,
- textAlign: "right",
+ footerRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: spacing.sm,
+ marginTop: 2,
},
- amountEdit: {
+ amountGroup: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "baseline",
+ gap: spacing.sm,
+ },
+ amountLabel: {
+ fontFamily: fonts.bodyMedium,
fontSize: 12,
+ textTransform: "uppercase",
+ letterSpacing: 0.3,
+ },
+ amountValue: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 17,
},
remove: {
- width: 32,
- height: 36,
+ width: 40,
+ height: 40,
+ borderRadius: radii.md,
+ borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
diff --git a/components/legal/LegalAgreementNotice.tsx b/components/legal/LegalAgreementNotice.tsx
new file mode 100644
index 0000000..3ca3a00
--- /dev/null
+++ b/components/legal/LegalAgreementNotice.tsx
@@ -0,0 +1,53 @@
+import * as WebBrowser from "expo-web-browser";
+import { StyleSheet, Text } from "react-native";
+
+import { fonts } from "@/constants/theme";
+import { useAccounts } from "@/contexts/AccountsContext";
+import { useAppTheme } from "@/contexts/ThemeContext";
+
+type LegalAgreementNoticeProps = {
+ action: string;
+};
+
+function openLegalPage(baseUrl: string, path: "/terms" | "/privacy") {
+ const origin = baseUrl.replace(/\/$/, "");
+ void WebBrowser.openBrowserAsync(`${origin}${path}`);
+}
+
+export function LegalAgreementNotice({ action }: LegalAgreementNoticeProps) {
+ const { colors } = useAppTheme();
+ const { apiUrl } = useAccounts();
+
+ return (
+
+ By {action}, you agree to our{" "}
+ openLegalPage(apiUrl, "/terms")}
+ >
+ Terms of Service
+ {" "}
+ and{" "}
+ openLegalPage(apiUrl, "/privacy")}
+ >
+ Privacy Policy
+
+ .
+
+ );
+}
+
+const styles = StyleSheet.create({
+ text: {
+ textAlign: "center",
+ fontSize: 12,
+ fontFamily: fonts.body,
+ lineHeight: 18,
+ },
+ link: {
+ fontFamily: fonts.bodyMedium,
+ textDecorationLine: "underline",
+ },
+});
diff --git a/components/time-clock/TimeClockLiveActivitySync.tsx b/components/time-clock/TimeClockLiveActivitySync.tsx
new file mode 100644
index 0000000..eca5fe8
--- /dev/null
+++ b/components/time-clock/TimeClockLiveActivitySync.tsx
@@ -0,0 +1,44 @@
+import { useEffect } from "react";
+import { AppState } from "react-native";
+
+import {
+ endTimeClockLiveActivity,
+ syncTimeClockLiveActivity,
+} from "@/lib/time-clock-live-activity";
+import { api } from "@/lib/trpc";
+
+/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
+export function TimeClockLiveActivitySync() {
+ const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
+ refetchInterval: 60_000,
+ });
+ const running = runningQuery.data;
+
+ useEffect(() => {
+ if (!running) {
+ void endTimeClockLiveActivity();
+ return;
+ }
+
+ const sync = () => {
+ const seconds = Math.floor(
+ (Date.now() - new Date(running.startedAt).getTime()) / 1000,
+ );
+ void syncTimeClockLiveActivity(running, seconds);
+ };
+
+ sync();
+
+ const interval = setInterval(sync, 60_000);
+ const subscription = AppState.addEventListener("change", (state) => {
+ if (state === "active") sync();
+ });
+
+ return () => {
+ clearInterval(interval);
+ subscription.remove();
+ };
+ }, [running]);
+
+ return null;
+}
diff --git a/components/time-clock/TimeClockPanel.tsx b/components/time-clock/TimeClockPanel.tsx
index 11a85bc..fb0e5d1 100644
--- a/components/time-clock/TimeClockPanel.tsx
+++ b/components/time-clock/TimeClockPanel.tsx
@@ -31,12 +31,12 @@ import {
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
- syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
+ formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
@@ -90,6 +90,7 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
+ const [stopNote, setStopNote] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState("now");
@@ -139,18 +140,6 @@ export function TimeClockPanel({
},
});
- 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();
@@ -175,6 +164,7 @@ export function TimeClockPanel({
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
+ setStopNote("");
},
});
@@ -194,7 +184,7 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
- setDescription(running.description?.trim() ?? "");
+ setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
@@ -247,24 +237,6 @@ export function TimeClockPanel({
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(
@@ -404,43 +376,19 @@ export function TimeClockPanel({
async function handleClockOut() {
try {
await clockOut.mutateAsync({
- description: description.trim() ? description.trim() : undefined,
+ description: stopNote.trim() ? stopNote.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 ;
}
+ const runningTitle = formatRunningTimerLabel(running?.description);
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
@@ -451,7 +399,6 @@ export function TimeClockPanel({
.filter(Boolean)
.join(" · ");
- const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) {
return (
@@ -459,11 +406,7 @@ export function TimeClockPanel({
key={client.id}
label={client.name}
active={clientId === client.id}
- onPress={() => {
- if (controlsDisabled) return;
- if (running) void handleRunningClientChange(client.id);
- else selectClient(client.id);
- }}
+ onPress={() => selectClient(client.id)}
/>
);
}
@@ -487,18 +430,18 @@ export function TimeClockPanel({
>
{running || !compact ? (
-
+
{running ? (
<>
- Running
+ Timer running
{formatElapsedSeconds(elapsed)}
-
- Started {formatDateTime(running.startedAt)}
- {runningMeta ? ` · ${runningMeta}` : ""}
-
+ {runningTitle}
+ {runningMeta ? (
+ {runningMeta}
+ ) : null}
>
) : (
@@ -510,13 +453,37 @@ export function TimeClockPanel({
) : null}
- {running ? "Update & stop" : "Clock in"}
+
+ {running ? (
+
+
+
+
+ ) : (
+ <>
+
@@ -533,10 +500,7 @@ export function TimeClockPanel({
{
- if (controlsDisabled) return;
- setClientsExpanded((open) => !open);
- }}
+ onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
@@ -547,7 +511,7 @@ export function TimeClockPanel({
) : null}
>
)}
- {clockInErrors.clientId && !running ? (
+ {clockInErrors.clientId ? (
{clockInErrors.clientId}
) : null}
@@ -559,11 +523,7 @@ export function TimeClockPanel({
{
- if (controlsDisabled) return;
- if (running) void handleRunningInvoiceChange("");
- else setInvoiceId("");
- }}
+ onPress={() => setInvoiceId("")}
/>
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
@@ -572,11 +532,7 @@ export function TimeClockPanel({
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
- onPress={() => {
- if (controlsDisabled) return;
- if (running) void handleRunningInvoiceChange(invoice.id);
- else setInvoiceId(invoice.id);
- }}
+ onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
@@ -584,7 +540,7 @@ export function TimeClockPanel({
) : null}
- {!running && clientId ? (
+ {clientId ? (
) : null}
-
- ) : null}
+
+
+ >
+ )}
- {running ? (
-
- ) : (
-
- )}
-
{todayEntries.length > 0 ? (
-
+
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
@@ -720,7 +668,7 @@ export function TimeClockPanel({
const row = (
<>
- {resolveClockDescription(entry.description)}
+ {formatRunningTimerLabel(entry.description)}
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
@@ -762,41 +710,52 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
flex: 1,
},
runningCard: {
- borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
+ borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
+ backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
},
hero: {
- padding: spacing.md,
+ padding: spacing.lg,
gap: spacing.sm,
},
+ heroRunning: {
+ alignItems: "center",
+ },
heroHeader: {
flexDirection: "row",
alignItems: "center",
+ justifyContent: "center",
gap: spacing.sm,
},
pulseDot: {
- width: 8,
- height: 8,
- borderRadius: 4,
+ width: 10,
+ height: 10,
+ borderRadius: 5,
backgroundColor: colors.primary,
},
- heroLabel: {
- fontSize: 13,
+ heroLabelRunning: {
+ fontSize: 14,
fontFamily: fonts.bodyMedium,
- color: colors.mutedForeground,
- textTransform: "uppercase",
- letterSpacing: 0.4,
+ color: colors.primary,
},
timerValue: {
- fontSize: 52,
- lineHeight: 56,
+ fontSize: 56,
+ lineHeight: 60,
fontFamily: fonts.mono,
- color: colors.foreground,
+ color: colors.primary,
fontVariant: ["tabular-nums"],
+ textAlign: "center",
+ },
+ runningTitle: {
+ fontSize: 18,
+ fontFamily: fonts.bodyMedium,
+ color: colors.foreground,
+ textAlign: "center",
},
runningMeta: {
- fontSize: 13,
+ fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
+ textAlign: "center",
},
idleHint: {
fontSize: 14,
@@ -805,20 +764,27 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
lineHeight: 20,
},
setupCard: {
- padding: spacing.md,
+ padding: spacing.lg,
gap: spacing.lg,
},
+ cardTitle: {
+ fontSize: 16,
+ fontFamily: fonts.bodySemiBold,
+ color: colors.foreground,
+ },
+ formSection: {
+ gap: spacing.md,
+ },
+ titleField: {
+ fontSize: 18,
+ fontFamily: fonts.bodyMedium,
+ minHeight: 48,
+ paddingVertical: spacing.xs,
+ },
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
- titleInput: {
- minHeight: 44,
- textAlignVertical: "center",
- },
- titleInputPlaceholder: {
- textAlign: "center",
- },
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
index 959b831..af3c786 100644
--- a/components/ui/Button.tsx
+++ b/components/ui/Button.tsx
@@ -3,9 +3,11 @@ import {
Pressable,
StyleSheet,
Text,
+ View,
type PressableProps,
type ViewStyle,
} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
@@ -15,6 +17,7 @@ type ButtonProps = PressableProps & {
loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle;
+ showArrow?: boolean;
};
export function Button({
@@ -23,6 +26,7 @@ export function Button({
variant = "primary",
disabled,
style,
+ showArrow = false,
...props
}: ButtonProps) {
const { colors } = useAppTheme();
@@ -68,7 +72,17 @@ export function Button({
color={variant === "primary" ? colors.primaryForeground : colors.primary}
/>
) : (
- {title}
+
+ {title}
+ {showArrow ? (
+
+ ) : null}
+
)}
);
@@ -76,12 +90,20 @@ export function Button({
const styles = StyleSheet.create({
base: {
- minHeight: 40,
+ minHeight: 44,
borderRadius: radii.lg,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.md,
},
+ content: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ },
+ arrow: {
+ marginTop: 1,
+ },
pressed: {
opacity: 0.92,
},
diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx
index 3fbe057..e0789bb 100644
--- a/components/ui/Input.tsx
+++ b/components/ui/Input.tsx
@@ -5,6 +5,7 @@ import {
View,
type TextInputProps,
} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
@@ -13,31 +14,60 @@ type InputProps = TextInputProps & {
label: string;
error?: string;
required?: boolean;
+ leftIcon?: keyof typeof Ionicons.glyphMap;
+ labelAccessory?: React.ReactNode;
+ hint?: string;
};
-export function Input({ label, error, required, style, ...props }: InputProps) {
+export function Input({
+ label,
+ error,
+ required,
+ leftIcon,
+ labelAccessory,
+ hint,
+ style,
+ ...props
+}: InputProps) {
const { colors } = useAppTheme();
return (
-
- {label}
- {required ? * : null}
-
-
+
+
+ {label}
+ {required ? * : null}
+
+ {labelAccessory}
+
+
+ {leftIcon ? (
+
+ ) : null}
+
+
+ {hint && !error ? (
+ {hint}
+ ) : null}
{error ? {error} : null}
);
@@ -47,18 +77,40 @@ const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
+ labelRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: spacing.sm,
+ },
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
+ field: {
+ position: "relative",
+ justifyContent: "center",
+ },
+ leftIcon: {
+ position: "absolute",
+ left: spacing.md,
+ zIndex: 1,
+ },
input: {
- minHeight: 40,
+ minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: fonts.body,
},
+ inputWithIcon: {
+ paddingLeft: spacing.md + 24,
+ },
+ hint: {
+ fontSize: 12,
+ fontFamily: fonts.body,
+ },
error: {
fontSize: 13,
fontFamily: fonts.body,
diff --git a/contexts/AccountsContext.tsx b/contexts/AccountsContext.tsx
index fc5d0d9..38039f8 100644
--- a/contexts/AccountsContext.tsx
+++ b/contexts/AccountsContext.tsx
@@ -13,16 +13,15 @@ import {
authStoragePrefix,
buildAccountId,
loadAccounts,
- loadActiveAccountId,
- loadDraftInstanceUrl,
saveAccounts,
saveActiveAccountId,
saveDraftInstanceUrl,
type SavedAccount,
} from "@/lib/accounts";
-import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
+import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL, invalidServerUrlMessage } from "@/lib/config";
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
+import { migrateStoredOfficialUrls } from "@/lib/official-url-migration";
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
export type RemoveAccountResult = {
@@ -58,8 +57,8 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
const [apiUrl, setApiUrl] = useState(getApiUrl);
useEffect(() => {
- Promise.all([loadAccounts(), loadActiveAccountId(), loadDraftInstanceUrl()])
- .then(([storedAccounts, activeId, draftUrl]) => {
+ migrateStoredOfficialUrls()
+ .then(({ accounts: storedAccounts, activeAccountId: activeId, draftUrl }) => {
setAccounts(storedAccounts);
const active = storedAccounts.find((account) => account.id === activeId) ?? null;
@@ -86,7 +85,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
async (url: string) => {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
- throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
+ throw new Error(invalidServerUrlMessage());
}
if (activeAccount) {
diff --git a/contexts/AppLockContext.tsx b/contexts/AppLockContext.tsx
index 9c64f85..4ae4ea6 100644
--- a/contexts/AppLockContext.tsx
+++ b/contexts/AppLockContext.tsx
@@ -22,7 +22,7 @@ import {
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
-import { hasPendingShortcut } from "@/lib/shortcut-queue";
+import { hasPendingShortcut, subscribeShortcutQueue } from "@/lib/shortcut-queue";
type AppLockContextValue = {
enabled: boolean;
@@ -120,13 +120,29 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
!biometricUnlockInProgress.current
) {
wasBackgrounded.current = false;
- setIsLocked(true);
+ void hasPendingShortcut().then((shortcutPending) => {
+ if (!shortcutPending) {
+ setIsLocked(true);
+ }
+ });
}
});
return () => subscription.remove();
}, [enabled, activeAccountId]);
+ useEffect(() => {
+ if (!enabled) return;
+
+ return subscribeShortcutQueue(() => {
+ void hasPendingShortcut().then((shortcutPending) => {
+ if (shortcutPending) {
+ setIsLocked(false);
+ }
+ });
+ });
+ }, [enabled]);
+
const unlockWithPin = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
diff --git a/docs/APP_STORE_CONNECT.md b/docs/APP_STORE_CONNECT.md
index 4b76496..a73ec81 100644
--- a/docs/APP_STORE_CONNECT.md
+++ b/docs/APP_STORE_CONNECT.md
@@ -1,6 +1,6 @@
# 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`.
+Copy-paste reference for submitting **beenvoice** (`com.beenvoice.app`, v1.0.0). Production web host: `beenvoice.app`.
---
@@ -32,12 +32,10 @@ Deploy the Next.js legal pages before submission. Privacy Policy and Terms must
| 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.
+| **Privacy Policy URL** | `https://beenvoice.app/privacy` |
+| **Terms of Use (EULA)** | Use Apple Standard EULA *or* link `https://beenvoice.app/terms` |
+| **Support URL** | `https://beenvoice.app` (or a dedicated `/support` page when available) |
+| **Marketing URL** (optional) | `https://beenvoice.app` |
---
@@ -80,7 +78,7 @@ Sign in to the official beenvoice cloud or point the app at your own beenvoice s
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.
+Questions or feedback: support via your beenvoice administrator or the contact on beenvoice.app.
```
---
@@ -132,7 +130,7 @@ beenvoice is a client for the beenvoice invoicing and time-tracking platform (we
SIGN IN
1. Open the app.
-2. Leave "Official" server selected (https://beenvoice.soconnor.dev) unless we specify otherwise in this note.
+2. Leave "Official" server selected (https://beenvoice.app) unless we specify otherwise in this note.
3. Sign in with the demo account above.
WHAT TO TEST
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 120b0fe..7c1116a 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -10,7 +10,7 @@ Dense reference for the Expo 56 mobile companion. Talks to **beenvoice** over tR
| 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` |
+| Types | `AppRouter` imported from `../beenvoice-web/src/server/api/root` |
## Boot sequence
@@ -98,7 +98,7 @@ Without migration, remounting loses the session and forces a second login.
`components/AuthServerPicker.tsx` + `lib/server-mode.ts`:
-- **Official** — `DEFAULT_API_URL` (`https://beenvoice.soconnor.dev` in `lib/config.ts`)
+- **Official** — `DEFAULT_API_URL` (`https://beenvoice.app` 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.
@@ -149,7 +149,7 @@ UI: `AppLockOverlay.tsx`, `PinPrompt.tsx`, settings toggles.
- 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
+- Syncs iOS Live Activity when timer metadata changes (client, invoice, description); timer uses native `timerInterval` on the lock screen
### Live Activity
@@ -263,4 +263,4 @@ Requires beenvoice with:
- `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).
+See [beenvoice-web/docs/ARCHITECTURE.md](../../beenvoice-web/docs/ARCHITECTURE.md).
diff --git a/docs/README.md b/docs/README.md
index a337c3b..4ddb78e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,5 +8,5 @@
## Related
-- [beenvoice docs](../../beenvoice/docs/README.md) — server API and web app
+- [beenvoice-web docs](../../beenvoice-web/docs/README.md) — server API and web app
- [Workspace README](../../README.md) — full-stack layout
diff --git a/lib/config.ts b/lib/config.ts
index 31fcb62..e36abb3 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -1,7 +1,15 @@
import Constants from "expo-constants";
/** Production API used by default (App Store review + production builds). */
-export const DEFAULT_API_URL = "https://beenvoice.soconnor.dev";
+export const DEFAULT_API_URL = "https://beenvoice.app";
+
+export const OFFICIAL_SERVER_HOST = new URL(DEFAULT_API_URL).host;
+
+export const OFFICIAL_SERVER_PLACEHOLDER = `${OFFICIAL_SERVER_HOST} or localhost:3000`;
+
+export function invalidServerUrlMessage(): string {
+ return `Enter a valid server URL (e.g. ${OFFICIAL_SERVER_PLACEHOLDER})`;
+}
let runtimeOverride: string | null = null;
diff --git a/lib/form-validation.ts b/lib/form-validation.ts
index 63eef9d..edaed41 100644
--- a/lib/form-validation.ts
+++ b/lib/form-validation.ts
@@ -54,7 +54,7 @@ export type LineItemInput = {
};
export function validateLineItems(items: LineItemInput[]): string | null {
- if (items.length === 0) return "Add at least one line item";
+ if (items.length === 0) return null;
for (const item of items) {
if (!isRequiredString(item.description)) return "Each line needs a description";
@@ -64,3 +64,8 @@ export function validateLineItems(items: LineItemInput[]): string | null {
return null;
}
+
+export function validateLineItemsForSend(items: LineItemInput[]): string | null {
+ if (items.length === 0) return "Add at least one line item before sending";
+ return validateLineItems(items);
+}
diff --git a/lib/instance-url.ts b/lib/instance-url.ts
index 6c76fcb..8a60c27 100644
--- a/lib/instance-url.ts
+++ b/lib/instance-url.ts
@@ -1,5 +1,7 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
+import { invalidServerUrlMessage } from "@/lib/config";
+
const STORAGE_KEY = "beenvoice:instance-url";
export function normalizeInstanceUrl(input: string): string | null {
@@ -31,7 +33,7 @@ export async function loadStoredInstanceUrl(): Promise {
export async function saveStoredInstanceUrl(url: string): Promise {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
- throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
+ throw new Error(invalidServerUrlMessage());
}
await AsyncStorage.setItem(STORAGE_KEY, normalized);
return normalized;
diff --git a/lib/invoice-business.ts b/lib/invoice-business.ts
new file mode 100644
index 0000000..47aeaff
--- /dev/null
+++ b/lib/invoice-business.ts
@@ -0,0 +1,17 @@
+type BusinessOption = {
+ id: string;
+ isDefault?: boolean | null;
+};
+
+export function pickDefaultBusinessId(businesses: BusinessOption[] | undefined): string {
+ if (!businesses?.length) return "";
+ return businesses.find((business) => business.isDefault)?.id ?? businesses[0]!.id;
+}
+
+export function resolveInvoiceBusinessId(
+ explicitId: string | null | undefined,
+ businesses: BusinessOption[] | undefined,
+): string {
+ if (explicitId?.trim()) return explicitId.trim();
+ return pickDefaultBusinessId(businesses);
+}
diff --git a/lib/invoice-pdf-input.ts b/lib/invoice-pdf-input.ts
index 3473370..0b485aa 100644
--- a/lib/invoice-pdf-input.ts
+++ b/lib/invoice-pdf-input.ts
@@ -56,7 +56,7 @@ export function buildPreviewPdfInputFromInvoice(invoice: InvoiceDetail): Invoice
return {
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
- businessId: invoice.businessId ?? "",
+ businessId: invoice.businessId ?? invoice.business?.id ?? "",
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate: new Date(invoice.dueDate),
diff --git a/lib/official-url-migration.ts b/lib/official-url-migration.ts
new file mode 100644
index 0000000..67d9f58
--- /dev/null
+++ b/lib/official-url-migration.ts
@@ -0,0 +1,109 @@
+import {
+ authStoragePrefix,
+ buildAccountId,
+ loadAccounts,
+ loadActiveAccountId,
+ loadDraftInstanceUrl,
+ saveAccounts,
+ saveActiveAccountId,
+ saveDraftInstanceUrl,
+ type SavedAccount,
+} from "@/lib/accounts";
+import { migrateAuthStorage } from "@/lib/auth-storage";
+import { DEFAULT_API_URL } from "@/lib/config";
+import { loadStoredInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
+import {
+ clearTimeClockPrefsForAccount,
+ getLastTimeClockClientId,
+ setLastTimeClockClientId,
+} from "@/lib/time-clock-prefs";
+
+const LEGACY_OFFICIAL_HOSTS = ["beenvoice.soconnor.dev"];
+
+export function isLegacyOfficialUrl(url: string): boolean {
+ const trimmed = url.trim();
+ if (!trimmed) return false;
+
+ try {
+ const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
+ return LEGACY_OFFICIAL_HOSTS.includes(new URL(withProtocol).host);
+ } catch {
+ const host = trimmed.replace(/^https?:\/\//, "").replace(/\/$/, "").split("/")[0] ?? "";
+ return LEGACY_OFFICIAL_HOSTS.includes(host);
+ }
+}
+
+export function migrateOfficialUrl(url: string): string {
+ return isLegacyOfficialUrl(url) ? DEFAULT_API_URL : url;
+}
+
+export async function migrateStoredOfficialUrls(): Promise<{
+ accounts: SavedAccount[];
+ activeAccountId: string | null;
+ draftUrl: string | null;
+}> {
+ const [accounts, activeId, draftUrl, storedInstanceUrl] = await Promise.all([
+ loadAccounts(),
+ loadActiveAccountId(),
+ loadDraftInstanceUrl(),
+ loadStoredInstanceUrl(),
+ ]);
+
+ let nextActiveId = activeId;
+ const migratedAccounts: SavedAccount[] = [];
+
+ for (const account of accounts) {
+ if (!isLegacyOfficialUrl(account.instanceUrl)) {
+ migratedAccounts.push(account);
+ continue;
+ }
+
+ const newUrl = DEFAULT_API_URL;
+ const newId = buildAccountId(newUrl, account.userId);
+ const oldPrefix = authStoragePrefix(account.id);
+ const newPrefix = authStoragePrefix(newId);
+
+ if (oldPrefix !== newPrefix) {
+ await migrateAuthStorage(oldPrefix, newPrefix);
+ }
+
+ const lastClientId = await getLastTimeClockClientId(account.id);
+ if (lastClientId && account.id !== newId) {
+ await setLastTimeClockClientId(newId, lastClientId);
+ await clearTimeClockPrefsForAccount(account.id);
+ }
+
+ migratedAccounts.push({
+ ...account,
+ id: newId,
+ instanceUrl: newUrl,
+ });
+
+ if (nextActiveId === account.id) {
+ nextActiveId = newId;
+ }
+ }
+
+ const newDraft = draftUrl && isLegacyOfficialUrl(draftUrl) ? DEFAULT_API_URL : draftUrl;
+ const accountsChanged = migratedAccounts.some(
+ (account, index) =>
+ account.id !== accounts[index]?.id || account.instanceUrl !== accounts[index]?.instanceUrl,
+ );
+ const activeChanged = nextActiveId !== activeId;
+ const draftChanged = newDraft !== draftUrl;
+ const instanceChanged =
+ storedInstanceUrl != null &&
+ isLegacyOfficialUrl(storedInstanceUrl) &&
+ storedInstanceUrl !== DEFAULT_API_URL;
+
+ if (accountsChanged) await saveAccounts(migratedAccounts);
+ if (activeChanged) await saveActiveAccountId(nextActiveId);
+ if (draftChanged) await saveDraftInstanceUrl(newDraft);
+ if (instanceChanged) await saveStoredInstanceUrl(DEFAULT_API_URL);
+
+ return {
+ accounts: migratedAccounts,
+ activeAccountId: nextActiveId,
+ draftUrl: newDraft,
+ };
+}
diff --git a/lib/shortcuts.ts b/lib/shortcuts.ts
index 87544e3..418d2d9 100644
--- a/lib/shortcuts.ts
+++ b/lib/shortcuts.ts
@@ -60,6 +60,7 @@ export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut
export const SHORTCUT_URLS = {
timer: "beenvoice://timer",
+ openTimer: "beenvoice://timer",
clockIn: "beenvoice://shortcuts/clock-in",
clockOut: "beenvoice://shortcuts/clock-out",
} as const;
diff --git a/lib/time-clock-live-activity.ts b/lib/time-clock-live-activity.ts
index 6eba195..23297b2 100644
--- a/lib/time-clock-live-activity.ts
+++ b/lib/time-clock-live-activity.ts
@@ -91,7 +91,8 @@ export async function syncTimeClockLiveActivity(
return;
}
- factory.start(props, "beenvoice://timer");
+ const instance = factory.start(props, "beenvoice://timer");
+ await instance.update(props);
} catch (error) {
if (__DEV__) {
console.warn("[LiveActivity] sync failed:", error);
diff --git a/lib/time-clock.ts b/lib/time-clock.ts
index 86d7fae..9a311aa 100644
--- a/lib/time-clock.ts
+++ b/lib/time-clock.ts
@@ -6,11 +6,26 @@ export type ClockOutOutcome =
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
+/** Stored on entries clocked in before empty descriptions were allowed. */
+export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
+
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
return trimmed || DEFAULT_CLOCK_DESCRIPTION;
}
+export function formatRunningTimerLabel(description?: string | null): string {
+ const trimmed = description?.trim() ?? "";
+ if (
+ !trimmed ||
+ trimmed === DEFAULT_CLOCK_DESCRIPTION ||
+ trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION
+ ) {
+ return "Clocked in";
+ }
+ return trimmed;
+}
+
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
diff --git a/plugins/app-intents/BeenVoiceIntentHelpers.swift b/plugins/app-intents/BeenVoiceIntentHelpers.swift
index b8df8ab..648e7a8 100644
--- a/plugins/app-intents/BeenVoiceIntentHelpers.swift
+++ b/plugins/app-intents/BeenVoiceIntentHelpers.swift
@@ -1,9 +1,8 @@
-import SwiftUI
+import UIKit
-@available(iOS 18.0, *)
enum BeenVoiceIntentHelpers {
@MainActor
static func openDeepLink(_ url: URL) {
- EnvironmentValues().openURL(url)
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
diff --git a/plugins/app-intents/BeenVoiceShortcuts.swift b/plugins/app-intents/BeenVoiceShortcuts.swift
index a7b686a..184b2c1 100644
--- a/plugins/app-intents/BeenVoiceShortcuts.swift
+++ b/plugins/app-intents/BeenVoiceShortcuts.swift
@@ -1,6 +1,5 @@
import AppIntents
-@available(iOS 18.0, *)
struct BeenVoiceShortcuts: AppShortcutsProvider {
@AppShortcutsBuilder
static var appShortcuts: [AppShortcut] {
diff --git a/plugins/app-intents/ClockInIntent.swift b/plugins/app-intents/ClockInIntent.swift
index f6be119..30f5383 100644
--- a/plugins/app-intents/ClockInIntent.swift
+++ b/plugins/app-intents/ClockInIntent.swift
@@ -1,10 +1,10 @@
import AppIntents
-@available(iOS 18.0, *)
struct ClockInIntent: AppIntent {
static var title: LocalizedStringResource = "Clock In"
static var description = IntentDescription("Start the beenvoice time clock with your last client.")
static var openAppWhenRun: Bool = true
+ static var isDiscoverable: Bool = true
@Parameter(title: "Title")
var title: String?
diff --git a/plugins/app-intents/ClockOutIntent.swift b/plugins/app-intents/ClockOutIntent.swift
index 8d350a7..f0b062d 100644
--- a/plugins/app-intents/ClockOutIntent.swift
+++ b/plugins/app-intents/ClockOutIntent.swift
@@ -1,10 +1,10 @@
import AppIntents
-@available(iOS 18.0, *)
struct ClockOutIntent: AppIntent {
static var title: LocalizedStringResource = "Clock Out"
static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
static var openAppWhenRun: Bool = true
+ static var isDiscoverable: Bool = true
@MainActor
func perform() async throws -> some IntentResult {
diff --git a/plugins/app-intents/OpenTimerIntent.swift b/plugins/app-intents/OpenTimerIntent.swift
index 30dc04a..a600907 100644
--- a/plugins/app-intents/OpenTimerIntent.swift
+++ b/plugins/app-intents/OpenTimerIntent.swift
@@ -1,10 +1,10 @@
import AppIntents
-@available(iOS 18.0, *)
struct OpenTimerIntent: AppIntent {
static var title: LocalizedStringResource = "Open Time Clock"
static var description = IntentDescription("Open the beenvoice time clock.")
static var openAppWhenRun: Bool = true
+ static var isDiscoverable: Bool = true
@MainActor
func perform() async throws -> some IntentResult {
diff --git a/plugins/withAppIntents.js b/plugins/withAppIntents.js
index 593273f..db8bc96 100644
--- a/plugins/withAppIntents.js
+++ b/plugins/withAppIntents.js
@@ -15,6 +15,10 @@ const SWIFT_FILES = [
"BeenVoiceShortcuts.swift",
];
+const SHORTCUT_REGISTRATION = `Task {
+ await BeenVoiceShortcuts.updateAppShortcutParameters()
+ }`;
+
/** @type {import('@expo/config-plugins').ConfigPlugin} */
function withAppIntents(config) {
const appIntentsSource = path.join(
@@ -42,7 +46,6 @@ function withAppIntents(config) {
const appDelegatePath = path.join(targetDir, "AppDelegate.swift");
if (fs.existsSync(appDelegatePath)) {
let appDelegate = fs.readFileSync(appDelegatePath, "utf8");
- const marker = "BeenVoiceShortcuts.updateAppShortcutParameters";
if (!appDelegate.includes("import AppIntents")) {
appDelegate = appDelegate.replace(
@@ -51,21 +54,29 @@ function withAppIntents(config) {
);
}
- if (appDelegate.includes(marker)) {
+ if (!appDelegate.includes("BeenVoiceShortcuts.updateAppShortcutParameters")) {
appDelegate = appDelegate.replace(
- /if #available\(iOS 16\.0, \*\)/g,
- "if #available(iOS 18.0, *)",
+ "return super.application(application, didFinishLaunchingWithOptions: launchOptions)",
+ `${SHORTCUT_REGISTRATION}
+
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)`,
);
} else {
appDelegate = appDelegate.replace(
- "return super.application(application, didFinishLaunchingWithOptions: launchOptions)",
- `if #available(iOS 18.0, *) {
- Task {
- await BeenVoiceShortcuts.updateAppShortcutParameters()
- }
- }
+ /if #available\(iOS 1[68]\.0, \*\) \{\s*Task \{\s*await BeenVoiceShortcuts\.updateAppShortcutParameters\(\)\s*\}\s*\}/g,
+ SHORTCUT_REGISTRATION,
+ );
+ }
- return super.application(application, didFinishLaunchingWithOptions: launchOptions)`,
+ if (!appDelegate.includes("applicationDidBecomeActive")) {
+ appDelegate = appDelegate.replace(
+ " // Linking API",
+ ` public override func applicationDidBecomeActive(_ application: UIApplication) {
+ ${SHORTCUT_REGISTRATION}
+ super.applicationDidBecomeActive(application)
+ }
+
+ // Linking API`,
);
}
@@ -111,6 +122,20 @@ function withAppIntents(config) {
}
}
+ const configurations = project.pbxXCBuildConfigurationSection();
+ for (const key of Object.keys(configurations)) {
+ const buildConfig = configurations[key];
+ if (
+ typeof buildConfig !== "object" ||
+ !buildConfig.buildSettings ||
+ buildConfig.buildSettings.PRODUCT_BUNDLE_IDENTIFIER !== "com.beenvoice.app"
+ ) {
+ continue;
+ }
+
+ buildConfig.buildSettings.EXTRACT_APP_INTENTS_METADATA = "YES";
+ }
+
return config;
});
}
diff --git a/scripts/ios-release.sh b/scripts/ios-release.sh
index f632d29..46b9854 100755
--- a/scripts/ios-release.sh
+++ b/scripts/ios-release.sh
@@ -229,7 +229,7 @@ read_ipa_build_number() {
archive_app() {
mkdir -p "$(dirname "$ARCHIVE_PATH")"
- export EXPO_PUBLIC_API_URL="${EXPO_PUBLIC_API_URL:-https://beenvoice.soconnor.dev}"
+ export EXPO_PUBLIC_API_URL="${EXPO_PUBLIC_API_URL:-https://beenvoice.app}"
load_api_auth_args
echo "==> Archiving (EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL)…"
diff --git a/tsconfig.json b/tsconfig.json
index 7262b12..eab282a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,9 +5,9 @@
"skipLibCheck": true,
"paths": {
"@/*": ["./*"],
- "~/*": ["../beenvoice/src/*"],
- "src/*": ["../beenvoice/src/*"],
- "beenvoice/*": ["../beenvoice/src/*"]
+ "~/*": ["../beenvoice-web/src/*"],
+ "src/*": ["../beenvoice-web/src/*"],
+ "beenvoice/*": ["../beenvoice-web/src/*"]
}
},
"include": [
diff --git a/widgets/TimeClockActivity.tsx b/widgets/TimeClockActivity.tsx
index e293085..27b587a 100644
--- a/widgets/TimeClockActivity.tsx
+++ b/widgets/TimeClockActivity.tsx
@@ -12,6 +12,24 @@ import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
+const TIMER_HORIZON_MS = 24 * 60 * 60 * 1000;
+
+function liveTimer(
+ startedAtMs: number,
+ modifiers: ReturnType[],
+) {
+ const lower = new Date(startedAtMs);
+ const upper = new Date(startedAtMs + TIMER_HORIZON_MS);
+
+ return (
+
+ );
+}
+
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
"widget";
@@ -19,6 +37,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
const title = props.description.trim() || "Clock In";
const clientLabel = props.clientName.trim() || title;
const subtitle = props.invoiceLabel.trim();
+ const startedAtMs = props.startedAtMs > 0 ? props.startedAtMs : Date.now();
const timerMods = [
font({ design: "monospaced", weight: "bold", size: 20 }),
@@ -47,6 +66,9 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
minimumScaleFactor(0.85),
];
+ const bannerTimer = liveTimer(startedAtMs, timerMods);
+ const compactTimer = liveTimer(startedAtMs, compactTimerMods);
+
return {
banner: (
@@ -58,7 +80,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
/>
{clientLabel}
- {props.elapsedShort}
+ {bannerTimer}
),
bannerSmall: (
@@ -71,7 +93,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
/>
{clientLabel}
- {props.elapsedShort}
+ {compactTimer}
),
compactLeading: (
@@ -82,7 +104,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
),
- compactTrailing: {props.elapsedShort},
+ compactTrailing: compactTimer,
minimal: (
),
expandedCenter: {clientLabel},
- expandedTrailing: {props.elapsedShort},
+ expandedTrailing: bannerTimer,
expandedBottom: (
{subtitle || "beenvoice"}
),