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>
+66 -118
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" }, return;
{ }
text: "Send", router.push(`/(app)/invoices/send/${invoice.id}`);
onPress: () => sendInvoice.mutate({ invoiceId: 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,20 +203,27 @@ export default function InvoiceDetailScreen() {
</Card> </Card>
<Card title="Line items"> <Card title="Line items">
{invoice.items.map((item) => ( {invoice.items.length === 0 ? (
<View key={item.id} style={styles.lineItem}> <Text style={styles.emptyLines}>
<View style={styles.lineMeta}> No line items yet. Clock time to this invoice from the Timer tab, or edit to
<Text style={styles.lineDescription}>{item.description}</Text> add lines manually.
<Text style={styles.lineSub}> </Text>
{formatDate(item.date)} · {item.hours}h ×{" "} ) : (
{formatCurrency(item.rate, invoice.currency)} invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text> </Text>
</View> </View>
<Text style={styles.lineAmount}> ))
{formatCurrency(item.amount, invoice.currency)} )}
</Text>
</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={{
+173 -145
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,96 +272,120 @@ 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> <Card title="Line items">
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} /> {!isDraft ? (
{isDraft ? ( <Text style={styles.lockedHint}>
<> Line items are locked after an invoice is sent. Mark as draft on the invoice
<DateTimeField screen to edit entries.
label="Remind me to send" </Text>
mode="date" ) : items.length === 0 ? (
value={sendReminderAt ?? dueDate} <Text style={styles.emptyLines}>
minimumDate={new Date()} No line items yet. Add lines here or clock time to this invoice from the
maximumDate={new Date(2100, 0, 1)} Timer tab.
onChange={setSendReminderAt} </Text>
/> ) : null}
{sendReminderAt ? ( {items.map((item, index) => (
<Pressable onPress={() => setSendReminderAt(null)}> <LineItemEditor
<Text style={[styles.clearReminder, { color: colors.primary }]}> key={item.id ?? `new-${index}`}
Clear send reminder index={index}
</Text> item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
) : null} ) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items"> <InvoiceTotals
{!isDraft ? ( subtotal={formatCurrency(subtotal, currency)}
<Text style={styles.lockedHint}> taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
Line items are locked after an invoice is sent. Mark as draft on the invoice taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
screen to edit entries. total={formatCurrency(total, currency)}
</Text> />
) : ( </Card>
<LineItemsTableHeader />
)}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
{isDraft ? ( {lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined}
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Button
title="Save changes"
loading={updateInvoice.isPending}
disabled={!canSave}
onPress={handleSave}
/>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
variant="secondary"
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
</View>
</> </>
)} )}
{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>
+161 -125
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(), ? []
description: "", : [
hours: "1", {
rate: "0", date: new Date(),
}, description: "",
]); hours: "1",
const [section, setSection] = useState<InvoiceEditorSection>("edit"); rate: "0",
},
],
);
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,105 +261,101 @@ 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">
{clientOptions.length === 0 || businessOptions.length === 0 ? (
<View style={styles.noEntities}>
<Text style={styles.noEntitiesText}>
{businessOptions.length === 0
? "Add a business before creating an invoice."
: "Add a client before creating an invoice."}
</Text>
<Button
title={businessOptions.length === 0 ? "Add business" : "Add client"}
variant="secondary"
onPress={() =>
router.push(
businessOptions.length === 0
? "/(app)/entities/businesses/new"
: "/(app)/entities/clients/new",
)
}
/>
</View>
) : (
<InvoiceSetupForm
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
clientId={clientId}
onClientIdChange={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}
/>
)}
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
{invoiceNumberError ? (
<Text style={styles.error}>{invoiceNumberError}</Text>
) : null}
</Card>
) : ( ) : (
<> <>
<Card title="Details"> <Card title="Line items">
{clientOptions.length === 0 ? ( {isBlank && items.length === 0 ? (
<View style={styles.noClients}> <Text style={styles.emptyLines}>
<Text style={styles.noClientsText}> No line items yet. Save this draft and clock time to it from the Timer tab,
Add a client before creating an invoice. or add lines here.
</Text> </Text>
<Button ) : null}
title="Add client" {items.map((item, index) => (
variant="secondary" <LineItemEditor
onPress={() => router.push("/(app)/entities/clients/new")} key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/> />
</View> </Card>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
onValueChange={setClientId}
/>
)}
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
autoCapitalize="characters"
required
error={invoiceNumberError}
/>
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
setIssueDate(date);
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
}}
/>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={setTaxRate}
keyboardType="decimal-pad"
error={taxError}
/>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items"> {lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
<LineItemsTableHeader />
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Create invoice"
loading={createInvoice.isPending}
disabled={!canCreate}
onPress={handleCreate}
/>
</> </>
)} )}
{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}>
+105 -138
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,141 +95,109 @@ 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 />
<View style={styles.form}> <View style={styles.form}>
<View style={styles.row}> <View style={styles.row}>
<View style={styles.half}> <View style={styles.half}>
<Input <Input
label="First name" label="First name"
value={firstName} leftIcon="person-outline"
onChangeText={setFirstName} value={firstName}
onBlur={() => touch("firstName")} onChangeText={setFirstName}
autoComplete="given-name" onBlur={() => touch("firstName")}
placeholder="Jane" autoComplete="given-name"
required placeholder="John"
error={visible("firstName") ? firstNameError : undefined} required
/> error={visible("firstName") ? firstNameError : undefined}
</View> />
<View style={styles.half}>
<Input
label="Last name"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title="Create Account"
loading={loading}
disabled={!canRegister}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Already have an account?{" "}
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
Sign in
</Link>
</Text>
</Card>
</View> </View>
</ScrollView> <View style={styles.half}>
</KeyboardAvoidingView> <Input
</FullScreen> label="Last name"
</AuthBackground> leftIcon="person-outline"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
<Input
label="Email"
leftIcon="mail-outline"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
leftIcon="lock-closed-outline"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
hint="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title={loading ? "Creating account…" : "Create account"}
loading={loading}
disabled={!canRegister}
showArrow={!loading}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Already have an account?{" "}
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
Sign in
</Link>
</Text>
<LegalAgreementNotice action="creating an account" />
</AuthCard>
</AuthScreenLayout>
); );
} }
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,
+82 -165
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,177 +119,99 @@ 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. ) : null}
</Text>
) : null}
{authentikEnabled ? ( {authentikEnabled ? (
<View style={styles.ssoSection}> <View style={styles.ssoSection}>
<Button <Button
title="Sign in with Authentik" title="Sign in with Authentik"
variant="secondary" variant="secondary"
loading={loading} loading={loading}
disabled={!serverReady} disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()} onPress={() => void handleAuthentikSignIn()}
/> />
<View style={styles.dividerRow}> <AuthDivider />
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> </View>
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}> ) : null}
or
</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
</View>
) : null}
<View style={styles.form}> <View style={styles.form}>
<Input <Input
label="Email" label="Email"
autoCapitalize="none" leftIcon="mail-outline"
autoComplete="email" autoCapitalize="none"
keyboardType="email-address" autoComplete="email"
value={email} keyboardType="email-address"
onChangeText={setEmail} value={email}
onBlur={() => touch("email")} onChangeText={setEmail}
placeholder="you@example.com" onBlur={() => touch("email")}
required placeholder="you@example.com"
error={visible("email") ? emailValidationError : undefined} required
/> error={visible("email") ? emailValidationError : undefined}
<Input />
label="Password" <Input
secureTextEntry label="Password"
autoComplete="password" leftIcon="lock-closed-outline"
value={password} secureTextEntry
onChangeText={setPassword} autoComplete="password"
onBlur={() => touch("password")} value={password}
placeholder="••••••••" onChangeText={setPassword}
required onBlur={() => touch("password")}
error={visible("password") ? passwordValidationError : undefined} placeholder="••••••••"
/> required
error={visible("password") ? passwordValidationError : undefined}
labelAccessory={
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}> {error ? (
<Text style={[styles.forgot, { color: colors.mutedForeground }]}> <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
Forgot password? ) : null}
</Text>
</Pressable>
{error ? ( <Button
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> title={loading ? "Signing in…" : "Sign in"}
) : null} loading={loading}
disabled={!canSignIn}
showArrow={!loading}
onPress={handleSignIn}
/>
</View>
<Button {!signupsDisabled ? (
title="Sign In" <Text style={[styles.footer, { color: colors.mutedForeground }]}>
loading={loading} Don&apos;t have an account?{" "}
disabled={!canSignIn} <Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
onPress={handleSignIn} Create account
/> </Link>
</View> </Text>
) : null}
{!signupsDisabled ? ( <LegalAgreementNotice action="signing in" />
<Text style={[styles.footer, { color: colors.mutedForeground }]}> </AuthCard>
Don&apos;t have an account?{" "} </AuthScreenLayout>
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one
</Link>
</Text>
) : null}
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
); );
} }
const styles = StyleSheet.create({ 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 =
value: InvoiceEditorSection; | {
onChange: (value: InvoiceEditorSection) => void; mode?: "edit";
editLabel?: string; value: InvoiceEditorSection;
previewLabel?: string; onChange: (value: InvoiceEditorSection) => void;
}; }
| {
mode: "view";
value: InvoiceViewSection;
onChange: (value: InvoiceViewSection) => void;
};
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
const tabs =
props.mode === "view"
? [
{ id: "details" as const, label: "Details" },
{ id: "preview" as const, label: "PDF" },
]
: [
{ id: "setup" as const, label: "Setup" },
{ id: "lines" as const, label: "Line items" },
{ id: "preview" as const, label: "PDF preview" },
];
export function InvoiceEditorSectionTabs({
value,
onChange,
editLabel = "Edit",
previewLabel = "PDF preview",
}: InvoiceEditorSectionTabsProps) {
return ( return (
<View> <View>
<ScrollView <ScrollView
@@ -25,16 +38,14 @@ export function InvoiceEditorSectionTabs({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row} contentContainerStyle={styles.row}
> >
<FilterChip {tabs.map((tab) => (
label={editLabel} <FilterChip
active={value === "edit"} key={tab.id}
onPress={() => onChange("edit")} label={tab.label}
/> active={props.value === tab.id}
<FilterChip onPress={() => props.onChange(tab.id as never)}
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,
},
});
+147 -143
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>
{item.description.trim() || "Untitled line"} <Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
</Text> {item.description.trim() || "Untitled line"}
<Text style={[styles.readSub, { color: colors.mutedForeground }]}> </Text>
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)} <Text style={[styles.readSub, { color: colors.mutedForeground }]}>
</Text> {formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</View> </Text>
<Text style={[styles.amount, { color: colors.foreground }]}> <Text style={[styles.readAmount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)} {formatCurrency(amount, currency)}
</Text> </Text>
</View> </View>
@@ -92,154 +74,159 @@ 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
value={item.description}
onChangeText={(description) => onChange({ description })}
placeholder="What was done?"
placeholderTextColor={colors.mutedForeground}
style={[
styles.descriptionInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
/>
</View>
<View style={styles.metricsRow}> <TextInput
<CompactDateField value={item.description}
value={item.date} onChangeText={(description) => onChange({ description })}
onChange={(date) => onChange({ date })} placeholder="What was done?"
style={styles.dateField} placeholderTextColor={colors.mutedForeground}
/> style={[
<CompactStepperInput styles.descriptionInput,
value={item.hours} {
onChangeText={(hours) => onChange({ hours })} color: colors.foreground,
step={0.25} borderColor: colors.border,
style={styles.hoursField} backgroundColor: colors.cardGlass,
/> },
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}> ]}
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text> />
<TextInput
value={item.rate} <View style={styles.fieldsRow}>
onChangeText={(rate) => onChange({ rate })} <View style={styles.fieldCol}>
keyboardType="decimal-pad" <FieldLabel>Date</FieldLabel>
placeholder="0" <CompactDateField
placeholderTextColor={colors.mutedForeground} value={item.date}
style={[styles.rateInput, { color: colors.foreground }]} onChange={(date) => onChange({ date })}
style={styles.fieldControl}
/> />
</View> </View>
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}> <View style={styles.fieldCol}>
{formatCurrency(amount, currency)} <FieldLabel>Hours</FieldLabel>
</Text> <CompactStepperInput
value={item.hours}
onChangeText={(hours) => onChange({ hours })}
step={0.25}
style={styles.fieldControl}
/>
</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>
<TextInput
value={item.rate}
onChangeText={(rate) => onChange({ rate })}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.rateInput, { color: colors.foreground }]}
/>
</View>
</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)}
</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;
}
+97 -131
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}>
<Input <Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
label="Title"
{running ? (
<View style={styles.formSection}>
<Input
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} <Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
</>
)}
</GlassSurface> </GlassSurface>
{running ? (
<Button
title="Clock out"
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
) : (
<Button
title="Clock in"
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
onPress={handleClockIn}
/>
)}
{todayEntries.length > 0 ? ( {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,
+24 -2
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}
/> />
) : ( ) : (
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text> <View style={styles.content}>
<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,
}, },
+72 -20
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,31 +14,60 @@ 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}>
<Text style={[styles.label, { color: colors.foreground }]}> <View style={styles.labelRow}>
{label} <Text style={[styles.label, { color: colors.foreground }]}>
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null} {label}
</Text> {required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
<TextInput </Text>
placeholderTextColor={colors.mutedForeground} {labelAccessory}
style={[ </View>
styles.input, <View style={styles.field}>
{ {leftIcon ? (
borderColor: colors.border, <Ionicons
color: colors.foreground, name={leftIcon}
backgroundColor: colors.cardGlass, size={16}
}, color={colors.mutedForeground}
error && { borderColor: colors.destructive }, style={styles.leftIcon}
style, />
]} ) : null}
{...props} <TextInput
/> placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
leftIcon && styles.inputWithIcon,
{
borderColor: colors.border,
color: colors.foreground,
backgroundColor: colors.cardGlass,
},
error && { borderColor: colors.destructive },
style,
]}
{...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) {
+18 -2
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;
setIsLocked(true); void hasPendingShortcut().then((shortcutPending) => {
if (!shortcutPending) {
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 {
+36 -11
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>
), ),