Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.

Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:48 -04:00
co-authored by Cursor
parent e17c4c6854
commit 6762a9bff3
60 changed files with 2544 additions and 1091 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# beenvoice API base URL (no trailing slash) # 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 # 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 EXPO_PUBLIC_API_URL=http://localhost:3000
+1 -1
View File
@@ -12,7 +12,7 @@ APPLE_TEAM_ID=
# then re-run the full release (not --export-only). # then re-run the full release (not --export-only).
# Production API baked into the JS bundle (App Store / TestFlight) # 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) # 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. # Create a key with Developer role. Download the .p8 once — Apple won't show it again.
+2 -2
View File
@@ -10,7 +10,7 @@ Expo SDK **56**. Read [Expo v56 docs](https://docs.expo.dev/versions/v56.0.0/) b
## Conventions ## Conventions
- **Package manager**: Bun only - **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` - **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`) - **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`) - **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 ## 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.
+19 -12
View File
@@ -1,13 +1,13 @@
# beenvoice Mobile # 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) **Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
## Prerequisites ## Prerequisites
- [Bun](https://bun.sh) 1.3+ - [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 - Xcode + iOS Simulator (or device) for native dev build
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client` - **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 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`. 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 ```bash
# Terminal 1 — API # Terminal 1 — API
cd ../beenvoice && bun run dev cd ../beenvoice-web && bun run dev
# Terminal 2 — mobile (builds native app if needed) # Terminal 2 — mobile (builds native app if needed)
cd beenvoice-app && bun run ios cd beenvoice-app && bun run ios
@@ -80,7 +80,7 @@ bun run ios
- **Guest** auth storage: `beenvoice:guest` until first successful login - **Guest** auth storage: `beenvoice:guest` until first successful login
- **Per account**: `beenvoice:auth:{host::userId}` in SecureStore - **Per account**: `beenvoice:auth:{host::userId}` in SecureStore
- After login, `finalizeAuthenticatedAccount()` migrates session keys before activating the account (avoids double login) - 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) 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-in?title=…` | Clock in with title |
| `beenvoice://shortcuts/clock-out` | Clock out running timer | | `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 In** — starts the timer with your last client
- **Clock Out** — stops the running timer - **Clock Out** — stops the running timer
- **Open Time Clock** — opens the timer tab - **Open Time Clock** — opens the timer tab
1. Install a fresh build on a physical iPhone (iOS 16+). Shortcuts are **not pre-installed** in your Shortcuts library. To add one:
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”. 1. Install a fresh native build (`bunx expo prebuild --platform ios && bun run ios`, or a new EAS/TestFlight build).
4. Pick a client once on the Timer tab before the first clock-in shortcut. 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:** **Test deep links:**
@@ -138,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
## Related ## Related
- [beenvoice README](../beenvoice/README.md) - [beenvoice-web README](../beenvoice-web/README.md)
- [beenvoice ARCHITECTURE](../beenvoice/docs/ARCHITECTURE.md) - [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md)
- [Workspace root README](../README.md) - [Workspace root README](../README.md)
+1 -1
View File
@@ -10,7 +10,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"buildNumber": "7", "buildNumber": "11",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
+2
View File
@@ -4,6 +4,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay"; import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync"; import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { ShortcutHandler } from "@/components/ShortcutHandler"; import { ShortcutHandler } from "@/components/ShortcutHandler";
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext"; import { AppLockProvider } from "@/contexts/AppLockContext";
@@ -74,6 +75,7 @@ export default function AppLayout() {
</NativeTabs.Trigger> </NativeTabs.Trigger>
</NativeTabs> </NativeTabs>
<InvoiceReminderSync /> <InvoiceReminderSync />
<TimeClockLiveActivitySync />
<ShortcutHandler /> <ShortcutHandler />
<AppLockOverlay /> <AppLockOverlay />
</AppLockProvider> </AppLockProvider>
+52 -104
View File
@@ -1,14 +1,12 @@
import { router, Stack, useLocalSearchParams } from "expo-router"; import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react"; 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 { AppBackground } from "@/components/AppBackground";
import { import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -24,12 +22,11 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceDetailStyles); const styles = useThemedStyles(createInvoiceDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils(); const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding(); const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceEditorSection>("edit"); const [section, setSection] = useState<InvoiceViewSection>("details");
const invoiceQuery = api.invoices.getById.useQuery( const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" }, { id: id ?? "" },
@@ -45,16 +42,6 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Update failed", err.message), 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({ const sendPaymentReminder = api.invoices.sendReminder.useMutation({
onSuccess: () => { onSuccess: () => {
Alert.alert("Reminder sent", "Payment reminder emailed to the client."); 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), onError: (err) => Alert.alert("Could not send reminder", err.message),
}); });
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
);
if (!id) { if (!id) {
return <LoadingScreen message="Invalid invoice" />; return <LoadingScreen message="Invalid invoice" />;
} }
@@ -85,17 +78,12 @@ export default function InvoiceDetailScreen() {
); );
} }
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice); const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100); const taxAmount = subtotal * (invoice.taxRate / 100);
const clientEmail = invoice.client?.email?.trim() ?? ""; const clientEmail = invoice.client?.email?.trim() ?? "";
const previewInput = useMemo(
() => buildPreviewPdfInputFromInvoice(invoice),
[invoice],
);
function promptSendInvoice() { function openSendScreen() {
if (!clientEmail) { if (!clientEmail) {
Alert.alert( Alert.alert(
"No client email", "No client email",
@@ -103,18 +91,14 @@ export default function InvoiceDetailScreen() {
); );
return; return;
} }
if (invoice.items.length === 0) {
Alert.alert( Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice", "No line items",
`Email this invoice to ${clientEmail}?`, "Add line items or clock time to this invoice before sending.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice.id }),
},
],
); );
return;
}
router.push(`/(app)/invoices/send/${invoice.id}`);
} }
function promptPaymentReminder() { function promptPaymentReminder() {
@@ -159,25 +143,7 @@ export default function InvoiceDetailScreen() {
return ( return (
<AppBackground> <AppBackground>
<Stack.Screen <Stack.Screen options={{ headerBackTitle: "Invoices" }} />
options={{
headerBackTitle: "Invoices",
headerRight: () =>
status !== "paid" ? (
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={promptSendInvoice}
disabled={sendInvoice.isPending}
style={({ pressed }) => pressed && styles.headerPressed}
>
<Text style={[styles.headerAction, { color: colors.primary }]}>
{status === "draft" ? "Send" : "Resend"}
</Text>
</Pressable>
) : null,
}}
/>
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
@@ -201,11 +167,12 @@ export default function InvoiceDetailScreen() {
</Text> </Text>
</Card> </Card>
<InvoiceEditorSectionTabs <InvoiceViewChips
value={section} section={section}
onChange={setSection} onSectionChange={setSection}
editLabel="Details" status={status}
previewLabel="PDF" onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
onSend={openSendScreen}
/> />
{section === "preview" ? ( {section === "preview" ? (
@@ -215,6 +182,8 @@ export default function InvoiceDetailScreen() {
) : ( ) : (
<> <>
<Card title="Details"> <Card title="Details">
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} /> <DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} /> <DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} /> <DetailRow label="Currency" value={invoice.currency} />
@@ -234,7 +203,13 @@ export default function InvoiceDetailScreen() {
</Card> </Card>
<Card title="Line items"> <Card title="Line items">
{invoice.items.map((item) => ( {invoice.items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer tab, or edit to
add lines manually.
</Text>
) : (
invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}> <View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}> <View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text> <Text style={styles.lineDescription}>{item.description}</Text>
@@ -247,7 +222,8 @@ export default function InvoiceDetailScreen() {
{formatCurrency(item.amount, invoice.currency)} {formatCurrency(item.amount, invoice.currency)}
</Text> </Text>
</View> </View>
))} ))
)}
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)} subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined} taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
@@ -264,43 +240,19 @@ export default function InvoiceDetailScreen() {
</Card> </Card>
) : null} ) : null}
<View style={styles.actions}> <InvoiceDetailActions
{status !== "paid" ? ( status={status}
<Button clientEmail={clientEmail}
title={status === "draft" ? "Send invoice" : "Resend invoice"} onPaymentReminder={
onPress={promptSendInvoice} status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
loading={sendInvoice.isPending} }
/> paymentReminderLoading={sendPaymentReminder.isPending}
) : null} onUpdateStatus={() => promptStatusChange(status)}
{status === "sent" || status === "overdue" ? ( updateStatusLoading={updateStatus.isPending}
<Button onTrackTime={() =>
title="Send payment reminder" router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
variant="secondary"
onPress={promptPaymentReminder}
loading={sendPaymentReminder.isPending}
/>
) : null}
<Button
title="Edit invoice"
variant="secondary"
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
/>
<Button
title="Update status"
variant="ghost"
onPress={() => promptStatusChange(status)}
loading={updateStatus.isPending}
/>
<Button
title="Track time to this invoice"
variant="ghost"
onPress={() =>
router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
} }
/> />
</View>
</> </>
)} )}
</ScrollView> </ScrollView>
@@ -398,22 +350,18 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
color: colors.foreground, color: colors.foreground,
fontSize: 14, fontSize: 14,
}, },
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: { notes: {
fontFamily: fonts.body, fontFamily: fonts.body,
color: colors.foreground, color: colors.foreground,
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 20,
}, },
actions: {
gap: spacing.sm,
},
headerAction: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
headerPressed: {
opacity: 0.65,
},
errorBox: { errorBox: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",
+7
View File
@@ -43,6 +43,13 @@ export default function InvoicesLayout() {
headerBackTitle: "Invoices", headerBackTitle: "Invoices",
}} }}
/> />
<Stack.Screen
name="send/[id]"
options={{
title: "Send invoice",
headerBackTitle: "Invoice",
}}
/>
<Stack.Screen <Stack.Screen
name="edit/[id]" name="edit/[id]"
options={{ options={{
+146 -118
View File
@@ -16,20 +16,20 @@ import {
InvoiceEditorSectionTabs, InvoiceEditorSectionTabs,
type InvoiceEditorSection, type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs"; } from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; 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 { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
import { isValidTaxRate, validateLineItems } from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
import { getInvoiceStatus } from "@/lib/invoice-status"; import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input"; import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { validateLineItems } from "@/lib/form-validation";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders"; import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
@@ -47,19 +47,27 @@ export default function InvoiceEditScreen() {
{ id: id ?? "" }, { id: id ?? "" },
{ enabled: Boolean(id) }, { enabled: Boolean(id) },
); );
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date()); const [dueDate, setDueDate] = useState(() => new Date());
const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null); const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]); const [items, setItems] = useState<EditableLineItem[]>([]);
const [section, setSection] = useState<InvoiceEditorSection>("edit"); const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const invoice = invoiceQuery.data; const invoice = invoiceQuery.data;
if (!invoice) return; if (!invoice) return;
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
setClientId(invoice.clientId);
setNotes(invoice.notes ?? ""); setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate)); setDueDate(new Date(invoice.dueDate));
setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null); setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems( setItems(
invoice.items.map((item) => ({ invoice.items.map((item) => ({
@@ -72,6 +80,11 @@ export default function InvoiceEditScreen() {
); );
}, [invoiceQuery.data]); }, [invoiceQuery.data]);
useEffect(() => {
if (businessId || !businessesQuery.data?.length) return;
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
}, [businessId, businessesQuery.data]);
const updateInvoice = api.invoices.update.useMutation({ const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" }); void utils.invoices.getById.invalidate({ id: id ?? "" });
@@ -85,19 +98,31 @@ export default function InvoiceEditScreen() {
onError: (err) => setError(err.message), 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 invoice = invoiceQuery.data;
const isDraft = invoice?.status === "draft"; 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( const subtotal = useMemo(
() => () =>
items.reduce((sum, item) => { items.reduce((sum, item) => {
@@ -108,35 +133,39 @@ export default function InvoiceEditScreen() {
[items], [items],
); );
const taxRate = invoice?.taxRate ?? 0; const parsedTaxRate = Number(taxRate) || 0;
const taxAmount = subtotal * (taxRate / 100); const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
const lineItemsError = isDraft ? validateLineItems(items) : null; 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(() => { const previewInput = useMemo(() => {
if (!invoice) return null; if (!invoice) return null;
return buildPreviewPdfInput({ return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix, invoicePrefix: invoice.invoicePrefix,
businessId: invoice.businessId, businessId: resolvedBusinessId,
clientId: invoice.clientId, clientId,
issueDate: new Date(invoice.issueDate), issueDate: new Date(invoice.issueDate),
dueDate, dueDate,
status: invoice.status as "draft" | "sent" | "paid", status: invoice.status as "draft" | "sent" | "paid",
notes, notes,
taxRate, taxRate: parsedTaxRate,
currency, currency,
items, items,
}); });
}, [invoice, dueDate, notes, taxRate, currency, items]); }, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
if (!id) { if (!id) {
return <LoadingScreen message="Invalid invoice" />; return <LoadingScreen message="Invalid invoice" />;
} }
if (invoiceQuery.isLoading) { if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />; return <LoadingScreen message="Loading invoice…" />;
} }
@@ -147,28 +176,6 @@ export default function InvoiceEditScreen() {
const status = getInvoiceStatus(invoice); const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? ""; const clientEmail = invoice.client?.email?.trim() ?? "";
function promptSendInvoice() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
},
],
);
}
function updateItem(index: number, patch: Partial<EditableLineItem>) { function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
} }
@@ -186,10 +193,6 @@ export default function InvoiceEditScreen() {
} }
function removeItem(index: number) { 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)); setItems((prev) => prev.filter((_, i) => i !== index));
} }
@@ -230,6 +233,10 @@ export default function InvoiceEditScreen() {
sendReminderAt, sendReminderAt,
...(isDraft ...(isDraft
? { ? {
businessId: resolvedBusinessId,
clientId,
taxRate: parsedTaxRate,
currency,
items: parsedItems, items: parsedItems,
} }
: {}), : {}),
@@ -254,7 +261,9 @@ export default function InvoiceEditScreen() {
{invoice.invoicePrefix} {invoice.invoicePrefix}
{invoice.invoiceNumber} {invoice.invoiceNumber}
</Text> </Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text> <Text style={styles.clientName}>
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
</Text>
</View> </View>
<InvoiceEditorSectionTabs value={section} onChange={setSection} /> <InvoiceEditorSectionTabs value={section} onChange={setSection} />
@@ -263,48 +272,50 @@ export default function InvoiceEditScreen() {
<Card title="PDF preview"> <Card title="PDF preview">
<InvoicePdfPreview input={previewInput} /> <InvoicePdfPreview input={previewInput} />
</Card> </Card>
) : section === "setup" ? (
<Card title="Invoice setup">
<InvoiceSetupForm
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
businessReadOnly={!isDraft}
clientId={clientId}
onClientIdChange={setClientId}
clientOptions={clientOptions}
clientError={clientError}
clientReadOnly={!isDraft}
invoiceNumber={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
invoiceNumberReadOnly
issueDate={new Date(invoice.issueDate)}
issueDateReadOnly
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={isDraft ? setTaxRate : undefined}
taxRateReadOnly={!isDraft}
notes={notes}
onNotesChange={setNotes}
sendReminderAt={sendReminderAt}
onSendReminderAtChange={isDraft ? setSendReminderAt : undefined}
showSendReminder={isDraft}
/>
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
</Card>
) : ( ) : (
<> <>
<Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
{isDraft ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={setSendReminderAt}
/>
{sendReminderAt ? (
<Pressable onPress={() => setSendReminderAt(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items"> <Card title="Line items">
{!isDraft ? ( {!isDraft ? (
<Text style={styles.lockedHint}> <Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries. screen to edit entries.
</Text> </Text>
) : ( ) : items.length === 0 ? (
<LineItemsTableHeader /> <Text style={styles.emptyLines}>
)} No line items yet. Add lines here or clock time to this invoice from the
Timer tab.
</Text>
) : null}
{items.map((item, index) => ( {items.map((item, index) => (
<LineItemEditor <LineItemEditor
key={item.id ?? `new-${index}`} key={item.id ?? `new-${index}`}
@@ -320,39 +331,61 @@ export default function InvoiceEditScreen() {
{isDraft ? ( {isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}> <Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text> <Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
) : null} ) : null}
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, currency)} subtotal={formatCurrency(subtotal, currency)}
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined} taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined} taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)} total={formatCurrency(total, currency)}
/> />
</Card> </Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null} {lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Button
title="Save changes"
loading={updateInvoice.isPending}
disabled={!canSave}
onPress={handleSave}
/>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
variant="secondary"
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
</View>
</> </>
)} )}
{error ? <Text style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle="Save changes"
onPrimary={handleSave}
primaryLoading={updateInvoice.isPending}
primaryDisabled={!canSave}
secondary={
status !== "paid"
? {
title: status === "draft" ? "Send invoice" : "Resend invoice",
subtitle: clientEmail
? items.length === 0
? "Add line items before sending"
: `Review PDF and email to ${clientEmail}`
: "Add a client email on the web app first",
icon: "mail-outline",
onPress: () => {
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
}
/>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</AppBackground> </AppBackground>
@@ -380,23 +413,21 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
fontFamily: fonts.body, fontFamily: fonts.body,
color: colors.mutedForeground, color: colors.mutedForeground,
}, },
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
lockedHint: { lockedHint: {
fontFamily: fonts.body, fontFamily: fonts.body,
fontSize: 13, fontSize: 13,
color: colors.mutedForeground, color: colors.mutedForeground,
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
clearReminder: { emptyLines: {
fontFamily: fonts.bodyMedium, fontFamily: fonts.body,
fontSize: 13, fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
addLine: { addLine: {
paddingTop: spacing.sm, paddingTop: spacing.md,
paddingBottom: spacing.xs, paddingBottom: spacing.xs,
}, },
addLineText: { addLineText: {
@@ -409,7 +440,4 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
fontFamily: fonts.body, fontFamily: fonts.body,
fontSize: 14, fontSize: 14,
}, },
actions: {
gap: spacing.sm,
},
}); });
+13 -1
View File
@@ -169,7 +169,19 @@ export default function InvoicesScreen() {
</TabScrollView> </TabScrollView>
<FloatingActionButton <FloatingActionButton
accessibilityLabel="Create invoice" accessibilityLabel="Create invoice"
onPress={() => 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"),
},
]);
}}
/> />
</TabPage> </TabPage>
</AppBackground> </AppBackground>
+123 -87
View File
@@ -1,4 +1,4 @@
import { router, Stack } from "expo-router"; import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Alert, Alert,
@@ -16,55 +16,75 @@ import {
InvoiceEditorSectionTabs, InvoiceEditorSectionTabs,
type InvoiceEditorSection, type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs"; } from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; 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 { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; 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 { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { import {
isRequiredString, isRequiredString,
isValidTaxRate, isValidTaxRate,
validateLineItems, validateLineItems,
} from "@/lib/form-validation"; } 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 { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export default function NewInvoiceScreen() { export default function NewInvoiceScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createNewInvoiceStyles); const styles = useThemedStyles(createNewInvoiceStyles);
const utils = api.useUtils(); const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding(); 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 clientsQuery = api.clients.getAll.useQuery();
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState(""); const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber); const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date()); const [issueDate, setIssueDate] = useState(() => new Date());
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date())); const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0"); const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>([ const [items, setItems] = useState<EditableLineItem[]>(() =>
isBlank
? []
: [
{ {
date: new Date(), date: new Date(),
description: "", description: "",
hours: "1", hours: "1",
rate: "0", rate: "0",
}, },
]); ],
const [section, setSection] = useState<InvoiceEditorSection>("edit"); );
const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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( const clientOptions = useMemo(
() => () =>
(clientsQuery.data ?? []).map((client) => ({ (clientsQuery.data ?? []).map((client) => ({
@@ -76,6 +96,7 @@ export default function NewInvoiceScreen() {
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId); const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? "USD"; const currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
useEffect(() => { useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return; if (!selectedClient?.defaultHourlyRate) return;
@@ -120,6 +141,7 @@ export default function NewInvoiceScreen() {
() => () =>
buildPreviewPdfInput({ buildPreviewPdfInput({
invoiceNumber, invoiceNumber,
businessId: resolvedBusinessId,
clientId, clientId,
issueDate, issueDate,
dueDate, dueDate,
@@ -128,9 +150,20 @@ export default function NewInvoiceScreen() {
notes, notes,
items, 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 clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber) const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined ? undefined
@@ -138,13 +171,15 @@ export default function NewInvoiceScreen() {
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100"; const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items); const lineItemsError = validateLineItems(items);
const canCreate = const canCreate =
businessOptions.length > 0 &&
clientOptions.length > 0 && clientOptions.length > 0 &&
!businessError &&
!clientError && !clientError &&
!invoiceNumberError && !invoiceNumberError &&
!taxError && !taxError &&
!lineItemsError; !lineItemsError;
if (clientsQuery.isLoading) { if (businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading…" />; return <LoadingScreen message="Loading…" />;
} }
@@ -165,10 +200,6 @@ export default function NewInvoiceScreen() {
} }
function removeItem(index: number) { 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)); setItems((prev) => prev.filter((_, i) => i !== index));
} }
@@ -193,6 +224,7 @@ export default function NewInvoiceScreen() {
} }
createInvoice.mutate({ createInvoice.mutate({
businessId: resolvedBusinessId,
clientId, clientId,
invoiceNumber: invoiceNumber.trim(), invoiceNumber: invoiceNumber.trim(),
issueDate, issueDate,
@@ -207,7 +239,12 @@ export default function NewInvoiceScreen() {
return ( return (
<AppBackground> <AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} /> <Stack.Screen
options={{
headerBackTitle: "Invoices",
title: isBlank ? "Blank invoice" : "New invoice",
}}
/>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex} style={styles.flex}
@@ -224,68 +261,63 @@ export default function NewInvoiceScreen() {
<Card title="PDF preview"> <Card title="PDF preview">
<InvoicePdfPreview input={previewInput} /> <InvoicePdfPreview input={previewInput} />
</Card> </Card>
) : ( ) : section === "setup" ? (
<> <Card title="Invoice setup">
<Card title="Details"> {clientOptions.length === 0 || businessOptions.length === 0 ? (
{clientOptions.length === 0 ? ( <View style={styles.noEntities}>
<View style={styles.noClients}> <Text style={styles.noEntitiesText}>
<Text style={styles.noClientsText}> {businessOptions.length === 0
Add a client before creating an invoice. ? "Add a business before creating an invoice."
: "Add a client before creating an invoice."}
</Text> </Text>
<Button <Button
title="Add client" title={businessOptions.length === 0 ? "Add business" : "Add client"}
variant="secondary" variant="secondary"
onPress={() => router.push("/(app)/entities/clients/new")} onPress={() =>
router.push(
businessOptions.length === 0
? "/(app)/entities/businesses/new"
: "/(app)/entities/clients/new",
)
}
/> />
</View> </View>
) : ( ) : (
<SelectField <InvoiceSetupForm
label="Client" businessId={businessId}
placeholder="Select client…" onBusinessIdChange={setBusinessId}
value={clientId} businessOptions={businessOptions}
options={clientOptions} businessError={businessError}
required clientId={clientId}
error={clientError} onClientIdChange={setClientId}
onValueChange={setClientId} clientOptions={clientOptions}
clientError={clientError}
invoiceNumber={invoiceNumber}
onInvoiceNumberChange={setInvoiceNumber}
issueDate={issueDate}
onIssueDateChange={setIssueDate}
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={setTaxRate}
notes={notes}
onNotesChange={setNotes}
/> />
)} )}
<Input {taxError ? <Text style={styles.error}>{taxError}</Text> : null}
label="Invoice number" {invoiceNumberError ? (
value={invoiceNumber} <Text style={styles.error}>{invoiceNumberError}</Text>
onChangeText={setInvoiceNumber} ) : null}
autoCapitalize="characters"
required
error={invoiceNumberError}
/>
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
setIssueDate(date);
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
}}
/>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={setTaxRate}
keyboardType="decimal-pad"
error={taxError}
/>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card> </Card>
) : (
<>
<Card title="Line items"> <Card title="Line items">
<LineItemsTableHeader /> {isBlank && items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Save this draft and clock time to it from the Timer tab,
or add lines here.
</Text>
) : null}
{items.map((item, index) => ( {items.map((item, index) => (
<LineItemEditor <LineItemEditor
key={`new-${index}`} key={`new-${index}`}
@@ -299,7 +331,7 @@ export default function NewInvoiceScreen() {
))} ))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}> <Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text> <Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
<InvoiceTotals <InvoiceTotals
@@ -313,16 +345,17 @@ export default function NewInvoiceScreen() {
</Card> </Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null} {lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Create invoice"
loading={createInvoice.isPending}
disabled={!canCreate}
onPress={handleCreate}
/>
</> </>
)} )}
{error ? <Text style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
onPrimary={handleCreate}
primaryLoading={createInvoice.isPending}
primaryDisabled={!canCreate}
/>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</AppBackground> </AppBackground>
@@ -336,21 +369,24 @@ const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
padding: spacing.md, padding: spacing.md,
gap: spacing.md, gap: spacing.md,
}, },
notesInput: { noEntities: {
minHeight: 72,
textAlignVertical: "top",
},
noClients: {
gap: spacing.sm, gap: spacing.sm,
}, },
noClientsText: { noEntitiesText: {
fontFamily: fonts.body, fontFamily: fonts.body,
fontSize: 14, fontSize: 14,
color: colors.mutedForeground, color: colors.mutedForeground,
lineHeight: 20, lineHeight: 20,
}, },
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: { addLine: {
paddingTop: spacing.sm, paddingTop: spacing.md,
paddingBottom: spacing.xs, paddingBottom: spacing.xs,
}, },
addLineText: { addLineText: {
+237
View File
@@ -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 <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
return <LoadingScreen message="Invoice not found" />;
}
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
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 (
<AppBackground>
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Email summary">
<SummaryRow label="From" value={businessName} />
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
<SummaryRow
label="Invoice"
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
/>
<SummaryRow label="Due" value={formatDate(invoice.dueDate)} />
<SummaryRow
label="Amount"
value={formatCurrency(invoice.totalAmount, invoice.currency)}
bold
/>
</Card>
<Card title="PDF attachment">
<InvoicePdfPreview input={previewInput} height={480} />
</Card>
<Card title="Message">
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
Optional note included in the email body.
</Text>
<Input
label="Personal message"
value={customMessage}
onChangeText={setCustomMessage}
placeholder="Thanks for your business!"
multiline
style={styles.messageInput}
/>
</Card>
<Button
title={sendLabel}
onPress={handleSend}
loading={sendInvoice.isPending}
disabled={!clientEmail || invoice.items.length === 0}
/>
{!clientEmail ? (
<Text style={[styles.warning, { color: colors.destructive }]}>
Add a client email address before sending.
</Text>
) : invoice.items.length === 0 ? (
<Text style={[styles.warning, { color: colors.destructive }]}>
Add line items before sending this invoice.
</Text>
) : null}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
function SummaryRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={summaryStyles.row}>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text
style={[
summaryStyles.value,
{ color: colors.foreground },
bold && summaryStyles.bold,
]}
>
{value}
</Text>
</View>
);
}
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",
},
});
+7
View File
@@ -11,6 +11,7 @@ import { InstanceUrlField } from "@/components/InstanceUrlField";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { PinPrompt } from "@/components/PinPrompt"; import { PinPrompt } from "@/components/PinPrompt";
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
@@ -294,6 +295,12 @@ export default function SettingsScreen() {
</Text> </Text>
</Card> </Card>
{Platform.OS === "ios" ? (
<Card title="Shortcuts & Siri">
<ShortcutsSetupCard />
</Card>
) : null}
<Card title="Security"> <Card title="Security">
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingCopy}> <View style={styles.settingCopy}>
+38 -71
View File
@@ -1,19 +1,13 @@
import { Link } from "expo-router"; import { Link } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import { StyleSheet, Text, View } from "react-native";
KeyboardAvoidingView,
Platform, import { AuthCard } from "@/components/auth/AuthCard";
ScrollView, import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
StyleSheet, import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
Text,
View,
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker"; import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo"; import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
@@ -21,7 +15,12 @@ import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api"; import { registerAccount } from "@/lib/auth-api";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in"; 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() { export default function RegisterScreen() {
const authClient = useAuthClient(); const authClient = useAuthClient();
@@ -96,25 +95,12 @@ export default function RegisterScreen() {
} }
return ( return (
<AuthBackground> <AuthScreenLayout>
<FullScreen style={styles.safe}> <AuthCard>
<KeyboardAvoidingView <AuthCardHeader
behavior={Platform.OS === "ios" ? "padding" : undefined} title="Create your account"
style={styles.flex} description="Get started with your workspace"
> />
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Create your account</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Get started today
</Text>
</View>
<AuthServerPicker onReadyChange={setServerReady} embedded /> <AuthServerPicker onReadyChange={setServerReady} embedded />
@@ -123,11 +109,12 @@ export default function RegisterScreen() {
<View style={styles.half}> <View style={styles.half}>
<Input <Input
label="First name" label="First name"
leftIcon="person-outline"
value={firstName} value={firstName}
onChangeText={setFirstName} onChangeText={setFirstName}
onBlur={() => touch("firstName")} onBlur={() => touch("firstName")}
autoComplete="given-name" autoComplete="given-name"
placeholder="Jane" placeholder="John"
required required
error={visible("firstName") ? firstNameError : undefined} error={visible("firstName") ? firstNameError : undefined}
/> />
@@ -135,6 +122,7 @@ export default function RegisterScreen() {
<View style={styles.half}> <View style={styles.half}>
<Input <Input
label="Last name" label="Last name"
leftIcon="person-outline"
value={lastName} value={lastName}
onChangeText={setLastName} onChangeText={setLastName}
onBlur={() => touch("lastName")} onBlur={() => touch("lastName")}
@@ -148,6 +136,7 @@ export default function RegisterScreen() {
<Input <Input
label="Email" label="Email"
leftIcon="mail-outline"
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
keyboardType="email-address" keyboardType="email-address"
@@ -160,12 +149,14 @@ export default function RegisterScreen() {
/> />
<Input <Input
label="Password" label="Password"
leftIcon="lock-closed-outline"
secureTextEntry secureTextEntry
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
onBlur={() => touch("password")} onBlur={() => touch("password")}
placeholder="At least 8 characters" placeholder="••••••••"
hint="At least 8 characters"
required required
error={visible("password") ? passwordValidationError : undefined} error={visible("password") ? passwordValidationError : undefined}
/> />
@@ -175,9 +166,10 @@ export default function RegisterScreen() {
) : null} ) : null}
<Button <Button
title="Create Account" title={loading ? "Creating account…" : "Create account"}
loading={loading} loading={loading}
disabled={!canRegister} disabled={!canRegister}
showArrow={!loading}
onPress={handleRegister} onPress={handleRegister}
/> />
</View> </View>
@@ -188,49 +180,24 @@ export default function RegisterScreen() {
Sign in Sign in
</Link> </Link>
</Text> </Text>
</Card>
</View> <LegalAgreementNotice action="creating an account" />
</ScrollView> </AuthCard>
</KeyboardAvoidingView> </AuthScreenLayout>
</FullScreen>
</AuthBackground>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1 }, form: {
flex: { flex: 1 }, gap: spacing.md,
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingVertical: spacing.xl,
}, },
content: { row: {
width: "100%", flexDirection: "row",
maxWidth: 420, gap: spacing.md,
}, },
card: { half: {
gap: spacing.lg, 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: { error: {
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
+26 -109
View File
@@ -1,21 +1,16 @@
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { Pressable, StyleSheet, Text, View } from "react-native";
KeyboardAvoidingView,
Platform, import { AuthCard } from "@/components/auth/AuthCard";
Pressable, import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
ScrollView, import { AuthDivider } from "@/components/auth/AuthDivider";
StyleSheet, import { AuthNotice } from "@/components/auth/AuthNotice";
Text, import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
View,
} from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker"; import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo"; import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { FullScreen } from "@/components/Screen";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
@@ -124,32 +119,14 @@ export default function SignInScreen() {
} }
return ( return (
<AuthBackground> <AuthScreenLayout>
<FullScreen style={styles.safe}> <AuthCard>
<KeyboardAvoidingView <AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Welcome back</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Sign in to manage invoices on the go
</Text>
</View>
<AuthServerPicker onReadyChange={setServerReady} embedded /> <AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? ( {signupsDisabled ? (
<Text style={[styles.notice, { color: colors.mutedForeground }]}> <AuthNotice>New account registration is currently disabled.</AuthNotice>
New account registration is currently disabled on this server.
</Text>
) : null} ) : null}
{authentikEnabled ? ( {authentikEnabled ? (
@@ -161,19 +138,14 @@ export default function SignInScreen() {
disabled={!serverReady} disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()} onPress={() => void handleAuthentikSignIn()}
/> />
<View style={styles.dividerRow}> <AuthDivider />
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}>
or
</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
</View> </View>
) : null} ) : null}
<View style={styles.form}> <View style={styles.form}>
<Input <Input
label="Email" label="Email"
leftIcon="mail-outline"
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
keyboardType="email-address" keyboardType="email-address"
@@ -186,6 +158,7 @@ export default function SignInScreen() {
/> />
<Input <Input
label="Password" label="Password"
leftIcon="lock-closed-outline"
secureTextEntry secureTextEntry
autoComplete="password" autoComplete="password"
value={password} value={password}
@@ -194,22 +167,24 @@ export default function SignInScreen() {
placeholder="••••••••" placeholder="••••••••"
required required
error={visible("password") ? passwordValidationError : undefined} error={visible("password") ? passwordValidationError : undefined}
/> labelAccessory={
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}> <Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password? Forgot password?
</Text> </Text>
</Pressable> </Pressable>
}
/>
{error ? ( {error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null} ) : null}
<Button <Button
title="Sign In" title={loading ? "Signing in…" : "Sign in"}
loading={loading} loading={loading}
disabled={!canSignIn} disabled={!canSignIn}
showArrow={!loading}
onPress={handleSignIn} onPress={handleSignIn}
/> />
</View> </View>
@@ -218,83 +193,25 @@ export default function SignInScreen() {
<Text style={[styles.footer, { color: colors.mutedForeground }]}> <Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}> <Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one Create account
</Link> </Link>
</Text> </Text>
) : null} ) : null}
</Card>
</View> <LegalAgreementNotice action="signing in" />
</ScrollView> </AuthCard>
</KeyboardAvoidingView> </AuthScreenLayout>
</FullScreen>
</AuthBackground>
); );
} }
const styles = StyleSheet.create({ 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: { ssoSection: {
gap: spacing.md, 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: { form: {
gap: spacing.md, gap: spacing.md,
}, },
forgot: { forgot: {
alignSelf: "flex-end",
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
fontSize: 12, fontSize: 12,
}, },
+3 -3
View File
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { DEFAULT_API_URL } from "@/lib/config"; import { DEFAULT_API_URL, OFFICIAL_SERVER_PLACEHOLDER, invalidServerUrlMessage } from "@/lib/config";
import { import {
formatServerHost, formatServerHost,
isServerConfigValid, isServerConfigValid,
@@ -80,7 +80,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
async function commitSelfHostedUrl() { async function commitSelfHostedUrl() {
const resolved = resolveServerUrl("self-hosted", selfHostedUrl); const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) { if (!resolved) {
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)"); setUrlError(invalidServerUrlMessage());
return; return;
} }
@@ -164,7 +164,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
placeholder="beenvoice.app or localhost:3000" placeholder={OFFICIAL_SERVER_PLACEHOLDER}
required required
error={urlError ?? undefined} error={urlError ?? undefined}
/> />
+2 -1
View File
@@ -8,6 +8,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { hasConfiguredInstanceUrl } from "@/lib/accounts"; import { hasConfiguredInstanceUrl } from "@/lib/accounts";
import { OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
type CollapsibleServerFieldProps = { type CollapsibleServerFieldProps = {
defaultExpanded?: boolean; defaultExpanded?: boolean;
@@ -100,7 +101,7 @@ export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleS
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
placeholder="beenvoice.app or localhost:3000" placeholder={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined} error={error ?? undefined}
/> />
<Text style={[styles.hint, { color: colors.mutedForeground }]}> <Text style={[styles.hint, { color: colors.mutedForeground }]}>
+3 -2
View File
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { invalidServerUrlMessage, OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
import { normalizeInstanceUrl } from "@/lib/instance-url"; import { normalizeInstanceUrl } from "@/lib/instance-url";
type InstanceUrlFieldProps = { type InstanceUrlFieldProps = {
@@ -30,7 +31,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
const normalized = normalizeInstanceUrl(trimmed); const normalized = normalizeInstanceUrl(trimmed);
if (!normalized) { if (!normalized) {
setError("Enter a valid URL like beenvoice.app or localhost:3000"); setError(invalidServerUrlMessage());
return; return;
} }
@@ -55,7 +56,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
placeholder="beenvoice.app or localhost:3000" placeholder={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined} error={error ?? undefined}
/> />
<Text style={[styles.hint, { color: colors.mutedForeground }]}> <Text style={[styles.hint, { color: colors.mutedForeground }]}>
+12
View File
@@ -15,6 +15,10 @@ import {
resolveEffectiveHourlyRate, resolveEffectiveHourlyRate,
} from "@/lib/time-clock"; } from "@/lib/time-clock";
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs"; import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import type { ParsedShortcut } from "@/lib/shortcuts"; import type { ParsedShortcut } from "@/lib/shortcuts";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
@@ -75,6 +79,7 @@ export function ShortcutHandler() {
} }
await clockOut.mutateAsync({}); await clockOut.mutateAsync({});
await endTimeClockLiveActivity();
await Promise.all([ await Promise.all([
utils.timeEntries.getRunning.invalidate(), utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(), utils.timeEntries.getAll.invalidate(),
@@ -121,6 +126,13 @@ export function ShortcutHandler() {
rate: rate ?? undefined, rate: rate ?? undefined,
}); });
await utils.timeEntries.getRunning.invalidate(); 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(); await clearPendingShortcut();
setPending(null); setPending(null);
router.push("/(app)/timer"); router.push("/(app)/timer");
+139
View File
@@ -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 (
<View style={styles.stack}>
<Text style={[styles.lead, { color: colors.mutedForeground }]}>
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:
</Text>
<View style={[styles.steps, { borderColor: colors.border, backgroundColor: colors.muted }]}>
<Text style={[styles.step, { color: colors.foreground }]}>
1. Open the Shortcuts app tap + Add Action
</Text>
<Text style={[styles.step, { color: colors.foreground }]}>
2. Search <Text style={styles.emphasis}>beenvoice</Text> (or Clock In / Clock Out)
</Text>
<Text style={[styles.step, { color: colors.foreground }]}>
3. Pick Clock In, Clock Out, or Open Time Clock
</Text>
</View>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
You can also ask Siri: Clock in with beenvoice. Pick a client once on the Timer tab
before your first clock-in shortcut.
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
If nothing shows up, reinstall from a fresh native build (TestFlight or{" "}
<Text style={styles.emphasis}>bun run ios</Text>). Shortcuts require iOS 18+.
</Text>
<View style={styles.actions}>
{SHORTCUT_ACTIONS.map((action) => (
<Pressable
key={action.title}
accessibilityRole="button"
onPress={() => void Linking.openURL(action.url)}
style={({ pressed }) => [
styles.actionRow,
{
borderColor: colors.border,
backgroundColor: pressed ? colors.muted : "transparent",
},
]}
>
<View style={styles.actionCopy}>
<Text style={[styles.actionTitle, { color: colors.foreground }]}>{action.title}</Text>
<Text style={[styles.actionSubtitle, { color: colors.mutedForeground }]}>
{action.subtitle}
</Text>
</View>
<Ionicons name="open-outline" size={18} color={colors.mutedForeground} />
</Pressable>
))}
</View>
<Button
title="Open Shortcuts app"
variant="secondary"
onPress={() => void Linking.openURL("shortcuts://")}
/>
</View>
);
}
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,
},
});
+28
View File
@@ -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<ViewStyle>;
};
export function AuthCard({ children, style }: AuthCardProps) {
return (
<GlassSurface radius={AUTH_CARD_RADIUS} style={style}>
<View style={styles.inner}>{children}</View>
</GlassSurface>
);
}
const styles = StyleSheet.create({
inner: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
gap: spacing.lg,
},
});
+45
View File
@@ -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 (
<View style={styles.wrapper}>
<Logo size="md" />
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.description, { color: colors.mutedForeground }]}>
{description}
</Text>
</View>
</View>
);
}
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,
},
});
+34
View File
@@ -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 (
<View style={styles.row}>
<View style={[styles.line, { backgroundColor: colors.border }]} />
<Text style={[styles.label, { color: colors.mutedForeground }]}>or</Text>
<View style={[styles.line, { backgroundColor: colors.border }]} />
</View>
);
}
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,
},
});
+39
View File
@@ -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 (
<Text
style={[
styles.notice,
{
color: colors.mutedForeground,
backgroundColor: colors.muted,
borderColor: colors.border,
},
]}
>
{children}
</Text>
);
}
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,
},
});
+47
View File
@@ -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 (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={[styles.content, style]} {...props}>
{children}
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
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",
},
});
@@ -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 (
<View
style={[
styles.card,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<View style={styles.list}>
{rows.map((row, index) => (
<View key={row.key}>
{index > 0 ? (
<View style={[styles.divider, { backgroundColor: colors.border }]} />
) : null}
<Pressable
accessibilityRole="button"
disabled={row.loading}
onPress={row.onPress}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={row.icon} size={20} color={colors.foreground} />
</View>
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{row.title}</Text>
{row.subtitle ? (
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
{row.subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
</View>
);
}
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,
},
});
+129
View File
@@ -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 (
<View
style={[
styles.card,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<Button
title={primaryTitle}
loading={primaryLoading}
disabled={primaryDisabled}
onPress={onPrimary}
/>
{secondary ? (
<>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<Pressable
accessibilityRole="button"
disabled={secondary.disabled || secondary.loading}
onPress={secondary.onPress}
style={({ pressed }) => [
styles.secondaryRow,
(pressed || secondary.loading) && styles.secondaryPressed,
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
]}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
</View>
<View style={styles.secondaryCopy}>
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
{secondary.title}
</Text>
{secondary.subtitle ? (
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
{secondary.subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</>
) : null}
</View>
);
}
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,
},
});
@@ -3,21 +3,34 @@ import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip"; import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme"; import { spacing } from "@/constants/theme";
export type InvoiceEditorSection = "edit" | "preview"; export type InvoiceEditorSection = "setup" | "lines" | "preview";
export type InvoiceViewSection = "details" | "preview";
type InvoiceEditorSectionTabsProps = { type InvoiceEditorSectionTabsProps =
| {
mode?: "edit";
value: InvoiceEditorSection; value: InvoiceEditorSection;
onChange: (value: InvoiceEditorSection) => void; onChange: (value: InvoiceEditorSection) => void;
editLabel?: string; }
previewLabel?: string; | {
}; 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 ( return (
<View> <View>
<ScrollView <ScrollView
@@ -25,16 +38,14 @@ export function InvoiceEditorSectionTabs({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row} contentContainerStyle={styles.row}
> >
{tabs.map((tab) => (
<FilterChip <FilterChip
label={editLabel} key={tab.id}
active={value === "edit"} label={tab.label}
onPress={() => onChange("edit")} active={props.value === tab.id}
/> onPress={() => props.onChange(tab.id as never)}
<FilterChip
label={previewLabel}
active={value === "preview"}
onPress={() => onChange("preview")}
/> />
))}
</ScrollView> </ScrollView>
</View> </View>
); );
+229
View File
@@ -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 (
<View style={styles.form}>
{businessOptions.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add a business in Entities before invoicing.
</Text>
) : (
<SelectField
label="Business"
placeholder="Select business…"
value={businessId}
options={businessOptions}
required
error={businessError}
disabled={businessReadOnly}
onValueChange={onBusinessIdChange}
/>
)}
{clientOptions.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add a client in Entities before invoicing.
</Text>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
disabled={clientReadOnly}
onValueChange={onClientIdChange}
/>
)}
{invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Invoice number
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{invoiceNumber}
</Text>
</View>
) : (
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={onInvoiceNumberChange}
autoCapitalize="characters"
required
/>
)}
{issueDateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Issue date
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()}
</Text>
</View>
) : (
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
onIssueDateChange?.(date);
if (dueDate < date) onDueDateChange(defaultDueDate(date));
}}
/>
)}
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
{taxRateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Tax rate
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{taxRate}%
</Text>
</View>
) : (
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={onTaxRateChange}
keyboardType="decimal-pad"
/>
)}
{showSendReminder && onSendReminderAtChange ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={onSendReminderAtChange}
/>
{sendReminderAt ? (
<Pressable onPress={() => onSendReminderAtChange(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={onNotesChange}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</View>
);
}
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,
},
});
+4 -4
View File
@@ -65,10 +65,10 @@ function TotalRow({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
totals: { totals: {
marginTop: spacing.sm, marginTop: spacing.md,
paddingTop: spacing.sm, paddingTop: spacing.md,
borderTopWidth: 1, borderTopWidth: StyleSheet.hairlineWidth,
gap: 6, gap: spacing.xs,
}, },
row: { row: {
flexDirection: "row", flexDirection: "row",
+58
View File
@@ -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 (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
<FilterChip
label="Details"
active={section === "details"}
onPress={() => onSectionChange("details")}
/>
<FilterChip label="Edit" onPress={onEdit} />
{status !== "paid" ? (
<FilterChip label={sendLabel} onPress={onSend} />
) : null}
<FilterChip
label="View PDF"
active={section === "preview"}
onPress={() => onSectionChange("preview")}
/>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
});
+109 -105
View File
@@ -25,28 +25,10 @@ type LineItemEditorProps = {
isLast?: boolean; isLast?: boolean;
}; };
export function LineItemsTableHeader() { function FieldLabel({ children }: { children: string }) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
return ( return (
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}> <Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
Description
</Text>
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
Date
</Text>
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
Hrs
</Text>
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
Rate
</Text>
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
Amt
</Text>
<View style={headerStyles.spacer} />
</View>
); );
} }
@@ -68,20 +50,20 @@ export function LineItemEditor({
return ( return (
<View <View
style={[ style={[
styles.row, styles.readBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 }, !isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
]} ]}
> >
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text> <Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
<View style={styles.descCol}> Line {index + 1}
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}> </Text>
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
{item.description.trim() || "Untitled line"} {item.description.trim() || "Untitled line"}
</Text> </Text>
<Text style={[styles.readSub, { color: colors.mutedForeground }]}> <Text style={[styles.readSub, { color: colors.mutedForeground }]}>
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)} {formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</Text> </Text>
</View> <Text style={[styles.readAmount, { color: colors.foreground }]}>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)} {formatCurrency(amount, currency)}
</Text> </Text>
</View> </View>
@@ -92,11 +74,11 @@ export function LineItemEditor({
<View <View
style={[ style={[
styles.editBlock, styles.editBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 }, !isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
]} ]}
> >
<View style={styles.editTop}> <Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
<TextInput <TextInput
value={item.description} value={item.description}
onChangeText={(description) => onChange({ description })} onChangeText={(description) => onChange({ description })}
@@ -111,21 +93,33 @@ export function LineItemEditor({
}, },
]} ]}
/> />
</View>
<View style={styles.metricsRow}> <View style={styles.fieldsRow}>
<View style={styles.fieldCol}>
<FieldLabel>Date</FieldLabel>
<CompactDateField <CompactDateField
value={item.date} value={item.date}
onChange={(date) => onChange({ date })} onChange={(date) => onChange({ date })}
style={styles.dateField} style={styles.fieldControl}
/> />
</View>
<View style={styles.fieldCol}>
<FieldLabel>Hours</FieldLabel>
<CompactStepperInput <CompactStepperInput
value={item.hours} value={item.hours}
onChangeText={(hours) => onChange({ hours })} onChangeText={(hours) => onChange({ hours })}
step={0.25} step={0.25}
style={styles.hoursField} style={styles.fieldControl}
/> />
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}> </View>
<View style={styles.fieldCol}>
<FieldLabel>Rate</FieldLabel>
<View
style={[
styles.rateField,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text> <Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
<TextInput <TextInput
value={item.rate} value={item.rate}
@@ -136,110 +130,103 @@ export function LineItemEditor({
style={[styles.rateInput, { color: colors.foreground }]} style={[styles.rateInput, { color: colors.foreground }]}
/> />
</View> </View>
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}> </View>
</View>
<View style={styles.footerRow}>
<View style={styles.amountGroup}>
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
<Text style={[styles.amountValue, { color: colors.foreground }]}>
{formatCurrency(amount, currency)} {formatCurrency(amount, currency)}
</Text> </Text>
</View>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Remove line item" accessibilityLabel="Remove line item"
onPress={onRemove} onPress={onRemove}
hitSlop={8} hitSlop={8}
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]} style={({ pressed }) => [
styles.remove,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
pressed && styles.removePressed,
]}
> >
<Ionicons name="trash-outline" size={17} color={colors.destructive} /> <Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable> </Pressable>
</View> </View>
</View> </View>
); );
} }
const headerStyles = StyleSheet.create({ const styles = StyleSheet.create({
row: { readBlock: {
flexDirection: "row", paddingVertical: spacing.md,
alignItems: "center",
gap: spacing.xs, gap: spacing.xs,
paddingBottom: spacing.xs,
marginBottom: spacing.xs,
borderBottomWidth: 1,
}, },
cell: { readIndex: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 11, fontSize: 11,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: 0.4, 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: { readTitle: {
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
fontSize: 14, fontSize: 15,
lineHeight: 18, lineHeight: 20,
}, },
readSub: { readSub: {
fontFamily: fonts.body, 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, fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.4,
}, },
descriptionInput: { descriptionInput: {
flex: 1, width: "100%",
minHeight: 36, minHeight: 40,
borderWidth: 1, borderWidth: 1,
borderRadius: radii.md, borderRadius: radii.md,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
fontFamily: fonts.body, fontFamily: fonts.body,
fontSize: 14, fontSize: 15,
paddingVertical: 6, paddingVertical: 8,
}, },
metricsRow: { fieldsRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", gap: spacing.sm,
gap: spacing.xs,
paddingLeft: 22,
}, },
dateField: { fieldCol: {
width: 72, flex: 1,
gap: 4,
minWidth: 0,
}, },
hoursField: { fieldLabel: {
width: 88, fontFamily: fonts.bodyMedium,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.3,
},
fieldControl: {
width: "100%",
}, },
rateField: { rateField: {
width: 72, minHeight: 36,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderRadius: radii.md, borderRadius: radii.md,
minHeight: 36,
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
}, },
ratePrefix: { ratePrefix: {
@@ -248,23 +235,40 @@ const styles = StyleSheet.create({
}, },
rateInput: { rateInput: {
flex: 1, flex: 1,
fontFamily: fonts.body, fontFamily: fonts.bodyMedium,
fontSize: 13, fontSize: 13,
paddingVertical: 4, paddingVertical: 4,
textAlign: "right", textAlign: "right",
minWidth: 0,
}, },
amount: { footerRow: {
width: 64, flexDirection: "row",
fontFamily: fonts.bodySemiBold, alignItems: "center",
fontSize: 13, justifyContent: "space-between",
textAlign: "right", gap: spacing.sm,
marginTop: 2,
}, },
amountEdit: { amountGroup: {
flex: 1,
flexDirection: "row",
alignItems: "baseline",
gap: spacing.sm,
},
amountLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 12, fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0.3,
},
amountValue: {
fontFamily: fonts.bodySemiBold,
fontSize: 17,
}, },
remove: { remove: {
width: 32, width: 40,
height: 36, height: 40,
borderRadius: radii.md,
borderWidth: 1,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
+53
View File
@@ -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 (
<Text style={[styles.text, { color: colors.mutedForeground }]}>
By {action}, you agree to our{" "}
<Text
style={[styles.link, { color: colors.foreground }]}
onPress={() => openLegalPage(apiUrl, "/terms")}
>
Terms of Service
</Text>{" "}
and{" "}
<Text
style={[styles.link, { color: colors.foreground }]}
onPress={() => openLegalPage(apiUrl, "/privacy")}
>
Privacy Policy
</Text>
.
</Text>
);
}
const styles = StyleSheet.create({
text: {
textAlign: "center",
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 18,
},
link: {
fontFamily: fonts.bodyMedium,
textDecorationLine: "underline",
},
});
@@ -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;
}
+90 -124
View File
@@ -31,12 +31,12 @@ import {
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { import {
endTimeClockLiveActivity, endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity"; } from "@/lib/time-clock-live-activity";
import { import {
DEFAULT_CLOCK_DESCRIPTION, DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome, describeClockOutOutcome,
formatElapsedSeconds, formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription, resolveClockDescription,
resolveEffectiveHourlyRate, resolveEffectiveHourlyRate,
startedAtFromMinutesAgo, startedAtFromMinutesAgo,
@@ -90,6 +90,7 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId); const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId); const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [stopNote, setStopNote] = useState("");
const [rateText, setRateText] = useState(""); const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date()); const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now"); const [startMode, setStartMode] = useState<StartMode>("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({ const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => { onSuccess: async (data) => {
await endTimeClockLiveActivity(); await endTimeClockLiveActivity();
@@ -175,6 +164,7 @@ export function TimeClockPanel({
utils.dashboard.getStats.invalidate(), utils.dashboard.getStats.invalidate(),
]); ]);
setDescription(""); setDescription("");
setStopNote("");
}, },
}); });
@@ -194,7 +184,7 @@ export function TimeClockPanel({
if (!running) return; if (!running) return;
setClientId(running.clientId ?? ""); setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? ""); setInvoiceId(running.invoiceId ?? "");
setDescription(running.description?.trim() ?? ""); setStopNote("");
setRateText(running.rate != null ? String(running.rate) : ""); setRateText(running.rate != null ? String(running.rate) : "");
}, [running]); }, [running]);
@@ -247,24 +237,6 @@ export function TimeClockPanel({
setFeaturedClientIds(ids.slice(0, 1)); setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]); }, [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 selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD"; const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate( const effectiveRate = resolveEffectiveHourlyRate(
@@ -404,43 +376,19 @@ export function TimeClockPanel({
async function handleClockOut() { async function handleClockOut() {
try { try {
await clockOut.mutateAsync({ await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined, description: stopNote.trim() ? stopNote.trim() : undefined,
}); });
} catch (err) { } catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again"); 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) { if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />; return <LoadingScreen message="Loading time clock…" />;
} }
const runningTitle = formatRunningTimerLabel(running?.description);
const runningMeta = [ const runningMeta = [
running?.client?.name ?? (running ? "No client" : null), running?.client?.name ?? (running ? "No client" : null),
running?.invoice running?.invoice
@@ -451,7 +399,6 @@ export function TimeClockPanel({
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) { function renderClientChip(client: (typeof clients)[number]) {
return ( return (
@@ -459,11 +406,7 @@ export function TimeClockPanel({
key={client.id} key={client.id}
label={client.name} label={client.name}
active={clientId === client.id} active={clientId === client.id}
onPress={() => { onPress={() => selectClient(client.id)}
if (controlsDisabled) return;
if (running) void handleRunningClientChange(client.id);
else selectClient(client.id);
}}
/> />
); );
} }
@@ -487,18 +430,18 @@ export function TimeClockPanel({
> >
{running || !compact ? ( {running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}> <GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}> <View style={[styles.hero, running && styles.heroRunning]}>
{running ? ( {running ? (
<> <>
<View style={styles.heroHeader}> <View style={styles.heroHeader}>
<View style={styles.pulseDot} /> <View style={styles.pulseDot} />
<Text style={styles.heroLabel}>Running</Text> <Text style={styles.heroLabelRunning}>Timer running</Text>
</View> </View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text> <Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningMeta}> <Text style={styles.runningTitle}>{runningTitle}</Text>
Started {formatDateTime(running.startedAt)} {runningMeta ? (
{runningMeta ? ` · ${runningMeta}` : ""} <Text style={styles.runningMeta}>{runningMeta}</Text>
</Text> ) : null}
</> </>
) : ( ) : (
<Text style={styles.idleHint}> <Text style={styles.idleHint}>
@@ -510,13 +453,37 @@ export function TimeClockPanel({
) : null} ) : null}
<GlassSurface style={styles.setupCard}> <GlassSurface style={styles.setupCard}>
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
{running ? (
<View style={styles.formSection}>
<Input <Input
label="Title" label="Note on stop (optional)"
value={stopNote}
onChangeText={setStopNote}
placeholder={
running.description?.trim()
? running.description
: "Update description when you stop"
}
returnKeyType="done"
/>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
) : (
<>
<TextInput
value={description} value={description}
onChangeText={setDescription} onChangeText={setDescription}
placeholder="What are you working on?" placeholder="What are you working on?"
placeholderTextColor={colors.mutedForeground}
returnKeyType="done" returnKeyType="done"
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]} style={[styles.titleField, { color: colors.foreground }]}
/> />
<View style={styles.setupSection}> <View style={styles.setupSection}>
@@ -533,10 +500,7 @@ export function TimeClockPanel({
<FilterChip <FilterChip
label={clientsExpanded ? "Show less" : "Show more"} label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded} active={clientsExpanded}
onPress={() => { onPress={() => setClientsExpanded((open) => !open)}
if (controlsDisabled) return;
setClientsExpanded((open) => !open);
}}
/> />
) : null} ) : null}
</View> </View>
@@ -547,7 +511,7 @@ export function TimeClockPanel({
) : null} ) : null}
</> </>
)} )}
{clockInErrors.clientId && !running ? ( {clockInErrors.clientId ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text> <Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null} ) : null}
</View> </View>
@@ -559,11 +523,7 @@ export function TimeClockPanel({
<FilterChip <FilterChip
label="Entry only" label="Entry only"
active={!invoiceId} active={!invoiceId}
onPress={() => { onPress={() => setInvoiceId("")}
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange("");
else setInvoiceId("");
}}
/> />
{billableInvoices.map((invoice) => { {billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`; const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
@@ -572,11 +532,7 @@ export function TimeClockPanel({
key={invoice.id} key={invoice.id}
label={label} label={label}
active={invoiceId === invoice.id} active={invoiceId === invoice.id}
onPress={() => { onPress={() => setInvoiceId(invoice.id)}
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange(invoice.id);
else setInvoiceId(invoice.id);
}}
/> />
); );
})} })}
@@ -584,7 +540,7 @@ export function TimeClockPanel({
</View> </View>
) : null} ) : null}
{!running && clientId ? ( {clientId ? (
<View style={styles.setupSection}> <View style={styles.setupSection}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
@@ -690,28 +646,20 @@ export function TimeClockPanel({
) : null} ) : null}
</View> </View>
) : null} ) : null}
</View>
) : null}
</GlassSurface>
{running ? (
<Button <Button
title="Clock out" title={clockIn.isPending ? "Starting…" : "Start timer"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
) : (
<Button
title="Clock in"
loading={clockIn.isPending} loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0} disabled={!canClockIn || clients.length === 0}
showArrow={!clockIn.isPending}
onPress={handleClockIn} onPress={handleClockIn}
/> />
</>
)} )}
</GlassSurface>
{todayEntries.length > 0 ? ( {todayEntries.length > 0 ? (
<Card title="Today"> <Card title="Today's entries">
{todayEntries.map((entry) => { {todayEntries.map((entry) => {
const invoiceLabel = entry.invoice const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` ? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
@@ -720,7 +668,7 @@ export function TimeClockPanel({
const row = ( const row = (
<> <>
<View style={styles.entryMeta}> <View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text> <Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}> <Text style={styles.entrySub}>
{entry.client?.name ?? "No client"} {entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"} {invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
@@ -762,41 +710,52 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
flex: 1, flex: 1,
}, },
runningCard: { 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: { hero: {
padding: spacing.md, padding: spacing.lg,
gap: spacing.sm, gap: spacing.sm,
}, },
heroRunning: {
alignItems: "center",
},
heroHeader: { heroHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center",
gap: spacing.sm, gap: spacing.sm,
}, },
pulseDot: { pulseDot: {
width: 8, width: 10,
height: 8, height: 10,
borderRadius: 4, borderRadius: 5,
backgroundColor: colors.primary, backgroundColor: colors.primary,
}, },
heroLabel: { heroLabelRunning: {
fontSize: 13, fontSize: 14,
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
color: colors.mutedForeground, color: colors.primary,
textTransform: "uppercase",
letterSpacing: 0.4,
}, },
timerValue: { timerValue: {
fontSize: 52, fontSize: 56,
lineHeight: 56, lineHeight: 60,
fontFamily: fonts.mono, fontFamily: fonts.mono,
color: colors.foreground, color: colors.primary,
fontVariant: ["tabular-nums"], fontVariant: ["tabular-nums"],
textAlign: "center",
},
runningTitle: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
color: colors.foreground,
textAlign: "center",
}, },
runningMeta: { runningMeta: {
fontSize: 13, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
color: colors.mutedForeground, color: colors.mutedForeground,
textAlign: "center",
}, },
idleHint: { idleHint: {
fontSize: 14, fontSize: 14,
@@ -805,20 +764,27 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
lineHeight: 20, lineHeight: 20,
}, },
setupCard: { setupCard: {
padding: spacing.md, padding: spacing.lg,
gap: 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: { setupSection: {
gap: spacing.sm, gap: spacing.sm,
paddingTop: spacing.lg, paddingTop: spacing.lg,
}, },
titleInput: {
minHeight: 44,
textAlignVertical: "center",
},
titleInputPlaceholder: {
textAlign: "center",
},
sectionLabel: { sectionLabel: {
fontSize: 11, fontSize: 11,
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
+23 -1
View File
@@ -3,9 +3,11 @@ import {
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
View,
type PressableProps, type PressableProps,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
@@ -15,6 +17,7 @@ type ButtonProps = PressableProps & {
loading?: boolean; loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost"; variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle; style?: ViewStyle;
showArrow?: boolean;
}; };
export function Button({ export function Button({
@@ -23,6 +26,7 @@ export function Button({
variant = "primary", variant = "primary",
disabled, disabled,
style, style,
showArrow = false,
...props ...props
}: ButtonProps) { }: ButtonProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@@ -68,7 +72,17 @@ export function Button({
color={variant === "primary" ? colors.primaryForeground : colors.primary} color={variant === "primary" ? colors.primaryForeground : colors.primary}
/> />
) : ( ) : (
<View style={styles.content}>
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text> <Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
{showArrow ? (
<Ionicons
name="arrow-forward"
size={16}
color={labelStyles[variant].color}
style={styles.arrow}
/>
) : null}
</View>
)} )}
</Pressable> </Pressable>
); );
@@ -76,12 +90,20 @@ export function Button({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
base: { base: {
minHeight: 40, minHeight: 44,
borderRadius: radii.lg, borderRadius: radii.lg,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
}, },
content: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
arrow: {
marginTop: 1,
},
pressed: { pressed: {
opacity: 0.92, opacity: 0.92,
}, },
+54 -2
View File
@@ -5,6 +5,7 @@ import {
View, View,
type TextInputProps, type TextInputProps,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
@@ -13,21 +14,46 @@ type InputProps = TextInputProps & {
label: string; label: string;
error?: string; error?: string;
required?: boolean; 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(); const { colors } = useAppTheme();
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.foreground }]}> <Text style={[styles.label, { color: colors.foreground }]}>
{label} {label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null} {required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text> </Text>
{labelAccessory}
</View>
<View style={styles.field}>
{leftIcon ? (
<Ionicons
name={leftIcon}
size={16}
color={colors.mutedForeground}
style={styles.leftIcon}
/>
) : null}
<TextInput <TextInput
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
style={[ style={[
styles.input, styles.input,
leftIcon && styles.inputWithIcon,
{ {
borderColor: colors.border, borderColor: colors.border,
color: colors.foreground, color: colors.foreground,
@@ -38,6 +64,10 @@ export function Input({ label, error, required, style, ...props }: InputProps) {
]} ]}
{...props} {...props}
/> />
</View>
{hint && !error ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
) : null}
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null} {error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
</View> </View>
); );
@@ -47,18 +77,40 @@ const styles = StyleSheet.create({
wrapper: { wrapper: {
gap: spacing.sm, gap: spacing.sm,
}, },
labelRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
label: { label: {
fontSize: 14, fontSize: 14,
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
}, },
field: {
position: "relative",
justifyContent: "center",
},
leftIcon: {
position: "absolute",
left: spacing.md,
zIndex: 1,
},
input: { input: {
minHeight: 40, minHeight: 44,
borderWidth: 1, borderWidth: 1,
borderRadius: radii.md, borderRadius: radii.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
}, },
inputWithIcon: {
paddingLeft: spacing.md + 24,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
},
error: { error: {
fontSize: 13, fontSize: 13,
fontFamily: fonts.body, fontFamily: fonts.body,
+5 -6
View File
@@ -13,16 +13,15 @@ import {
authStoragePrefix, authStoragePrefix,
buildAccountId, buildAccountId,
loadAccounts, loadAccounts,
loadActiveAccountId,
loadDraftInstanceUrl,
saveAccounts, saveAccounts,
saveActiveAccountId, saveActiveAccountId,
saveDraftInstanceUrl, saveDraftInstanceUrl,
type SavedAccount, type SavedAccount,
} from "@/lib/accounts"; } 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 { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url"; import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import { migrateStoredOfficialUrls } from "@/lib/official-url-migration";
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs"; import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
export type RemoveAccountResult = { export type RemoveAccountResult = {
@@ -58,8 +57,8 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
const [apiUrl, setApiUrl] = useState(getApiUrl); const [apiUrl, setApiUrl] = useState(getApiUrl);
useEffect(() => { useEffect(() => {
Promise.all([loadAccounts(), loadActiveAccountId(), loadDraftInstanceUrl()]) migrateStoredOfficialUrls()
.then(([storedAccounts, activeId, draftUrl]) => { .then(({ accounts: storedAccounts, activeAccountId: activeId, draftUrl }) => {
setAccounts(storedAccounts); setAccounts(storedAccounts);
const active = storedAccounts.find((account) => account.id === activeId) ?? null; const active = storedAccounts.find((account) => account.id === activeId) ?? null;
@@ -86,7 +85,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
async (url: string) => { async (url: string) => {
const normalized = normalizeInstanceUrl(url); const normalized = normalizeInstanceUrl(url);
if (!normalized) { if (!normalized) {
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)"); throw new Error(invalidServerUrlMessage());
} }
if (activeAccount) { if (activeAccount) {
+17 -1
View File
@@ -22,7 +22,7 @@ import {
setBiometricEnabled, setBiometricEnabled,
setStoredPin, setStoredPin,
} from "@/lib/app-lock"; } from "@/lib/app-lock";
import { hasPendingShortcut } from "@/lib/shortcut-queue"; import { hasPendingShortcut, subscribeShortcutQueue } from "@/lib/shortcut-queue";
type AppLockContextValue = { type AppLockContextValue = {
enabled: boolean; enabled: boolean;
@@ -120,13 +120,29 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
!biometricUnlockInProgress.current !biometricUnlockInProgress.current
) { ) {
wasBackgrounded.current = false; wasBackgrounded.current = false;
void hasPendingShortcut().then((shortcutPending) => {
if (!shortcutPending) {
setIsLocked(true); setIsLocked(true);
} }
}); });
}
});
return () => subscription.remove(); return () => subscription.remove();
}, [enabled, activeAccountId]); }, [enabled, activeAccountId]);
useEffect(() => {
if (!enabled) return;
return subscribeShortcutQueue(() => {
void hasPendingShortcut().then((shortcutPending) => {
if (shortcutPending) {
setIsLocked(false);
}
});
});
}, [enabled]);
const unlockWithPin = useCallback( const unlockWithPin = useCallback(
async (pin: string) => { async (pin: string) => {
if (!activeAccountId) return false; if (!activeAccountId) return false;
+7 -9
View File
@@ -1,6 +1,6 @@
# App Store Connect — beenvoice iOS # 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 | | Field | URL |
|-------|-----| |-------|-----|
| **Privacy Policy URL** | `https://beenvoice.com/privacy` | | **Privacy Policy URL** | `https://beenvoice.app/privacy` |
| **Terms of Use (EULA)** | Use Apple Standard EULA *or* link `https://beenvoice.com/terms` | | **Terms of Use (EULA)** | Use Apple Standard EULA *or* link `https://beenvoice.app/terms` |
| **Support URL** | `https://beenvoice.com` (or a dedicated `/support` page when available) | | **Support URL** | `https://beenvoice.app` (or a dedicated `/support` page when available) |
| **Marketing URL** (optional) | `https://beenvoice.com` | | **Marketing URL** (optional) | `https://beenvoice.app` |
If production web is still on `beenvoice.soconnor.dev`, use `https://beenvoice.soconnor.dev/privacy` and `/terms` until `beenvoice.com` is live.
--- ---
@@ -80,7 +78,7 @@ Sign in to the official beenvoice cloud or point the app at your own beenvoice s
REQUIREMENTS 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. 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 SIGN IN
1. Open the app. 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. 3. Sign in with the demo account above.
WHAT TO TEST WHAT TO TEST
+4 -4
View File
@@ -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) | | UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
| API | tRPC 11 + TanStack Query, SuperJSON | | API | tRPC 11 + TanStack Query, SuperJSON |
| Auth | better-auth + `@better-auth/expo``expo-secure-store` | | 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 ## Boot sequence
@@ -98,7 +98,7 @@ Without migration, remounting loses the session and forces a second login.
`components/AuthServerPicker.tsx` + `lib/server-mode.ts`: `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) - **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. `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`) - Client required; description optional (defaults to **"Clock In"** via `lib/time-clock.ts`)
- Optional invoice, hourly rate, backdated start - Optional invoice, hourly rate, backdated start
- `clockOut` sends optional description update - `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 ### Live Activity
@@ -263,4 +263,4 @@ Requires beenvoice with:
- `trustedOrigins` including `beenvoice://` and `exp://` - `trustedOrigins` including `beenvoice://` and `exp://`
- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`) - 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).
+1 -1
View File
@@ -8,5 +8,5 @@
## Related ## 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 - [Workspace README](../../README.md) — full-stack layout
+9 -1
View File
@@ -1,7 +1,15 @@
import Constants from "expo-constants"; import Constants from "expo-constants";
/** Production API used by default (App Store review + production builds). */ /** 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; let runtimeOverride: string | null = null;
+6 -1
View File
@@ -54,7 +54,7 @@ export type LineItemInput = {
}; };
export function validateLineItems(items: LineItemInput[]): string | null { 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) { for (const item of items) {
if (!isRequiredString(item.description)) return "Each line needs a description"; if (!isRequiredString(item.description)) return "Each line needs a description";
@@ -64,3 +64,8 @@ export function validateLineItems(items: LineItemInput[]): string | null {
return 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);
}
+3 -1
View File
@@ -1,5 +1,7 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { invalidServerUrlMessage } from "@/lib/config";
const STORAGE_KEY = "beenvoice:instance-url"; const STORAGE_KEY = "beenvoice:instance-url";
export function normalizeInstanceUrl(input: string): string | null { export function normalizeInstanceUrl(input: string): string | null {
@@ -31,7 +33,7 @@ export async function loadStoredInstanceUrl(): Promise<string | null> {
export async function saveStoredInstanceUrl(url: string): Promise<string> { export async function saveStoredInstanceUrl(url: string): Promise<string> {
const normalized = normalizeInstanceUrl(url); const normalized = normalizeInstanceUrl(url);
if (!normalized) { 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); await AsyncStorage.setItem(STORAGE_KEY, normalized);
return normalized; return normalized;
+17
View File
@@ -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);
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function buildPreviewPdfInputFromInvoice(invoice: InvoiceDetail): Invoice
return { return {
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#", invoicePrefix: invoice.invoicePrefix ?? "#",
businessId: invoice.businessId ?? "", businessId: invoice.businessId ?? invoice.business?.id ?? "",
clientId: invoice.clientId, clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate), issueDate: new Date(invoice.issueDate),
dueDate: new Date(invoice.dueDate), dueDate: new Date(invoice.dueDate),
+109
View File
@@ -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,
};
}
+1
View File
@@ -60,6 +60,7 @@ export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut
export const SHORTCUT_URLS = { export const SHORTCUT_URLS = {
timer: "beenvoice://timer", timer: "beenvoice://timer",
openTimer: "beenvoice://timer",
clockIn: "beenvoice://shortcuts/clock-in", clockIn: "beenvoice://shortcuts/clock-in",
clockOut: "beenvoice://shortcuts/clock-out", clockOut: "beenvoice://shortcuts/clock-out",
} as const; } as const;
+2 -1
View File
@@ -91,7 +91,8 @@ export async function syncTimeClockLiveActivity(
return; return;
} }
factory.start(props, "beenvoice://timer"); const instance = factory.start(props, "beenvoice://timer");
await instance.update(props);
} catch (error) { } catch (error) {
if (__DEV__) { if (__DEV__) {
console.warn("[LiveActivity] sync failed:", error); console.warn("[LiveActivity] sync failed:", error);
+15
View File
@@ -6,11 +6,26 @@ export type ClockOutOutcome =
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In"; 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 { export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim(); const trimmed = description?.trim();
return trimmed || DEFAULT_CLOCK_DESCRIPTION; 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 { export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
@@ -1,9 +1,8 @@
import SwiftUI import UIKit
@available(iOS 18.0, *)
enum BeenVoiceIntentHelpers { enum BeenVoiceIntentHelpers {
@MainActor @MainActor
static func openDeepLink(_ url: URL) { static func openDeepLink(_ url: URL) {
EnvironmentValues().openURL(url) UIApplication.shared.open(url, options: [:], completionHandler: nil)
} }
} }
@@ -1,6 +1,5 @@
import AppIntents import AppIntents
@available(iOS 18.0, *)
struct BeenVoiceShortcuts: AppShortcutsProvider { struct BeenVoiceShortcuts: AppShortcutsProvider {
@AppShortcutsBuilder @AppShortcutsBuilder
static var appShortcuts: [AppShortcut] { static var appShortcuts: [AppShortcut] {
+1 -1
View File
@@ -1,10 +1,10 @@
import AppIntents import AppIntents
@available(iOS 18.0, *)
struct ClockInIntent: AppIntent { struct ClockInIntent: AppIntent {
static var title: LocalizedStringResource = "Clock In" static var title: LocalizedStringResource = "Clock In"
static var description = IntentDescription("Start the beenvoice time clock with your last client.") static var description = IntentDescription("Start the beenvoice time clock with your last client.")
static var openAppWhenRun: Bool = true static var openAppWhenRun: Bool = true
static var isDiscoverable: Bool = true
@Parameter(title: "Title") @Parameter(title: "Title")
var title: String? var title: String?
+1 -1
View File
@@ -1,10 +1,10 @@
import AppIntents import AppIntents
@available(iOS 18.0, *)
struct ClockOutIntent: AppIntent { struct ClockOutIntent: AppIntent {
static var title: LocalizedStringResource = "Clock Out" static var title: LocalizedStringResource = "Clock Out"
static var description = IntentDescription("Stop the running beenvoice timer and save your time.") static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
static var openAppWhenRun: Bool = true static var openAppWhenRun: Bool = true
static var isDiscoverable: Bool = true
@MainActor @MainActor
func perform() async throws -> some IntentResult { func perform() async throws -> some IntentResult {
+1 -1
View File
@@ -1,10 +1,10 @@
import AppIntents import AppIntents
@available(iOS 18.0, *)
struct OpenTimerIntent: AppIntent { struct OpenTimerIntent: AppIntent {
static var title: LocalizedStringResource = "Open Time Clock" static var title: LocalizedStringResource = "Open Time Clock"
static var description = IntentDescription("Open the beenvoice time clock.") static var description = IntentDescription("Open the beenvoice time clock.")
static var openAppWhenRun: Bool = true static var openAppWhenRun: Bool = true
static var isDiscoverable: Bool = true
@MainActor @MainActor
func perform() async throws -> some IntentResult { func perform() async throws -> some IntentResult {
+35 -10
View File
@@ -15,6 +15,10 @@ const SWIFT_FILES = [
"BeenVoiceShortcuts.swift", "BeenVoiceShortcuts.swift",
]; ];
const SHORTCUT_REGISTRATION = `Task {
await BeenVoiceShortcuts.updateAppShortcutParameters()
}`;
/** @type {import('@expo/config-plugins').ConfigPlugin} */ /** @type {import('@expo/config-plugins').ConfigPlugin} */
function withAppIntents(config) { function withAppIntents(config) {
const appIntentsSource = path.join( const appIntentsSource = path.join(
@@ -42,7 +46,6 @@ function withAppIntents(config) {
const appDelegatePath = path.join(targetDir, "AppDelegate.swift"); const appDelegatePath = path.join(targetDir, "AppDelegate.swift");
if (fs.existsSync(appDelegatePath)) { if (fs.existsSync(appDelegatePath)) {
let appDelegate = fs.readFileSync(appDelegatePath, "utf8"); let appDelegate = fs.readFileSync(appDelegatePath, "utf8");
const marker = "BeenVoiceShortcuts.updateAppShortcutParameters";
if (!appDelegate.includes("import AppIntents")) { if (!appDelegate.includes("import AppIntents")) {
appDelegate = appDelegate.replace( appDelegate = appDelegate.replace(
@@ -51,21 +54,29 @@ function withAppIntents(config) {
); );
} }
if (appDelegate.includes(marker)) { if (!appDelegate.includes("BeenVoiceShortcuts.updateAppShortcutParameters")) {
appDelegate = appDelegate.replace( appDelegate = appDelegate.replace(
/if #available\(iOS 16\.0, \*\)/g, "return super.application(application, didFinishLaunchingWithOptions: launchOptions)",
"if #available(iOS 18.0, *)", `${SHORTCUT_REGISTRATION}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)`,
); );
} else { } else {
appDelegate = appDelegate.replace( appDelegate = appDelegate.replace(
"return super.application(application, didFinishLaunchingWithOptions: launchOptions)", /if #available\(iOS 1[68]\.0, \*\) \{\s*Task \{\s*await BeenVoiceShortcuts\.updateAppShortcutParameters\(\)\s*\}\s*\}/g,
`if #available(iOS 18.0, *) { SHORTCUT_REGISTRATION,
Task { );
await BeenVoiceShortcuts.updateAppShortcutParameters()
}
} }
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; return config;
}); });
} }
+1 -1
View File
@@ -229,7 +229,7 @@ read_ipa_build_number() {
archive_app() { archive_app() {
mkdir -p "$(dirname "$ARCHIVE_PATH")" 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 load_api_auth_args
echo "==> Archiving (EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL)…" echo "==> Archiving (EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL)…"
+3 -3
View File
@@ -5,9 +5,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"],
"~/*": ["../beenvoice/src/*"], "~/*": ["../beenvoice-web/src/*"],
"src/*": ["../beenvoice/src/*"], "src/*": ["../beenvoice-web/src/*"],
"beenvoice/*": ["../beenvoice/src/*"] "beenvoice/*": ["../beenvoice-web/src/*"]
} }
}, },
"include": [ "include": [
+26 -4
View File
@@ -12,6 +12,24 @@ import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types"; import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
const TIMER_HORIZON_MS = 24 * 60 * 60 * 1000;
function liveTimer(
startedAtMs: number,
modifiers: ReturnType<typeof font>[],
) {
const lower = new Date(startedAtMs);
const upper = new Date(startedAtMs + TIMER_HORIZON_MS);
return (
<Text
timerInterval={{ lower, upper }}
countsDown={false}
modifiers={modifiers}
/>
);
}
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) { function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
"widget"; "widget";
@@ -19,6 +37,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
const title = props.description.trim() || "Clock In"; const title = props.description.trim() || "Clock In";
const clientLabel = props.clientName.trim() || title; const clientLabel = props.clientName.trim() || title;
const subtitle = props.invoiceLabel.trim(); const subtitle = props.invoiceLabel.trim();
const startedAtMs = props.startedAtMs > 0 ? props.startedAtMs : Date.now();
const timerMods = [ const timerMods = [
font({ design: "monospaced", weight: "bold", size: 20 }), font({ design: "monospaced", weight: "bold", size: 20 }),
@@ -47,6 +66,9 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
minimumScaleFactor(0.85), minimumScaleFactor(0.85),
]; ];
const bannerTimer = liveTimer(startedAtMs, timerMods);
const compactTimer = liveTimer(startedAtMs, compactTimerMods);
return { return {
banner: ( banner: (
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 14, vertical: 12 })]}> <HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 14, vertical: 12 })]}>
@@ -58,7 +80,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
/> />
<Text modifiers={clientMods}>{clientLabel}</Text> <Text modifiers={clientMods}>{clientLabel}</Text>
<Spacer minLength={12} /> <Spacer minLength={12} />
<Text modifiers={timerMods}>{props.elapsedShort}</Text> {bannerTimer}
</HStack> </HStack>
), ),
bannerSmall: ( bannerSmall: (
@@ -71,7 +93,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
/> />
<Text modifiers={clientMods}>{clientLabel}</Text> <Text modifiers={clientMods}>{clientLabel}</Text>
<Spacer minLength={8} /> <Spacer minLength={8} />
<Text modifiers={compactTimerMods}>{props.elapsedShort}</Text> {compactTimer}
</HStack> </HStack>
), ),
compactLeading: ( compactLeading: (
@@ -82,7 +104,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
modifiers={[widgetAccentedRenderingMode("fullColor")]} modifiers={[widgetAccentedRenderingMode("fullColor")]}
/> />
), ),
compactTrailing: <Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>, compactTrailing: compactTimer,
minimal: ( minimal: (
<Image <Image
systemName="dollarsign.circle.fill" systemName="dollarsign.circle.fill"
@@ -100,7 +122,7 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
/> />
), ),
expandedCenter: <Text modifiers={clientMods}>{clientLabel}</Text>, expandedCenter: <Text modifiers={clientMods}>{clientLabel}</Text>,
expandedTrailing: <Text modifiers={timerMods}>{props.elapsedShort}</Text>, expandedTrailing: bannerTimer,
expandedBottom: ( expandedBottom: (
<Text modifiers={subtitleMods}>{subtitle || "beenvoice"}</Text> <Text modifiers={subtitleMods}>{subtitle || "beenvoice"}</Text>
), ),