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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
|
|||||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||||
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||||
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||||
|
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { AppLockProvider } from "@/contexts/AppLockContext";
|
import { AppLockProvider } from "@/contexts/AppLockContext";
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export default function AppLayout() {
|
|||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<InvoiceReminderSync />
|
<InvoiceReminderSync />
|
||||||
|
<TimeClockLiveActivitySync />
|
||||||
<ShortcutHandler />
|
<ShortcutHandler />
|
||||||
<AppLockOverlay />
|
<AppLockOverlay />
|
||||||
</AppLockProvider>
|
</AppLockProvider>
|
||||||
|
|||||||
+52
-104
@@ -1,14 +1,12 @@
|
|||||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import {
|
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
|
||||||
InvoiceEditorSectionTabs,
|
|
||||||
type InvoiceEditorSection,
|
|
||||||
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
|
||||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
|
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -24,12 +22,11 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
|||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
export default function InvoiceDetailScreen() {
|
export default function InvoiceDetailScreen() {
|
||||||
const { colors } = useAppTheme();
|
|
||||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const scrollPadding = useTabBarScrollPadding();
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
const [section, setSection] = useState<InvoiceEditorSection>("edit");
|
const [section, setSection] = useState<InvoiceViewSection>("details");
|
||||||
|
|
||||||
const invoiceQuery = api.invoices.getById.useQuery(
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
{ id: id ?? "" },
|
{ id: id ?? "" },
|
||||||
@@ -45,16 +42,6 @@ export default function InvoiceDetailScreen() {
|
|||||||
onError: (err) => Alert.alert("Update failed", err.message),
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendInvoice = api.email.sendInvoice.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert("Invoice sent", data.message);
|
|
||||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
void utils.dashboard.getStats.invalidate();
|
|
||||||
},
|
|
||||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
||||||
@@ -63,6 +50,12 @@ export default function InvoiceDetailScreen() {
|
|||||||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
const previewInput = useMemo(
|
||||||
|
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
|
||||||
|
[invoice],
|
||||||
|
);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return <LoadingScreen message="Invalid invoice" />;
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
}
|
}
|
||||||
@@ -85,17 +78,12 @@ export default function InvoiceDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
const previewInput = useMemo(
|
|
||||||
() => buildPreviewPdfInputFromInvoice(invoice),
|
|
||||||
[invoice],
|
|
||||||
);
|
|
||||||
|
|
||||||
function promptSendInvoice() {
|
function openSendScreen() {
|
||||||
if (!clientEmail) {
|
if (!clientEmail) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"No client email",
|
"No client email",
|
||||||
@@ -103,18 +91,14 @@ export default function InvoiceDetailScreen() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (invoice.items.length === 0) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
status === "draft" ? "Send invoice" : "Resend invoice",
|
"No line items",
|
||||||
`Email this invoice to ${clientEmail}?`,
|
"Add line items or clock time to this invoice before sending.",
|
||||||
[
|
|
||||||
{ text: "Cancel", style: "cancel" },
|
|
||||||
{
|
|
||||||
text: "Send",
|
|
||||||
onPress: () => sendInvoice.mutate({ invoiceId: invoice.id }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/(app)/invoices/send/${invoice.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptPaymentReminder() {
|
function promptPaymentReminder() {
|
||||||
@@ -159,25 +143,7 @@ export default function InvoiceDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<Stack.Screen
|
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||||
options={{
|
|
||||||
headerBackTitle: "Invoices",
|
|
||||||
headerRight: () =>
|
|
||||||
status !== "paid" ? (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
hitSlop={8}
|
|
||||||
onPress={promptSendInvoice}
|
|
||||||
disabled={sendInvoice.isPending}
|
|
||||||
style={({ pressed }) => pressed && styles.headerPressed}
|
|
||||||
>
|
|
||||||
<Text style={[styles.headerAction, { color: colors.primary }]}>
|
|
||||||
{status === "draft" ? "Send" : "Resend"}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
@@ -201,11 +167,12 @@ export default function InvoiceDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<InvoiceEditorSectionTabs
|
<InvoiceViewChips
|
||||||
value={section}
|
section={section}
|
||||||
onChange={setSection}
|
onSectionChange={setSection}
|
||||||
editLabel="Details"
|
status={status}
|
||||||
previewLabel="PDF"
|
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||||||
|
onSend={openSendScreen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{section === "preview" ? (
|
{section === "preview" ? (
|
||||||
@@ -215,6 +182,8 @@ export default function InvoiceDetailScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Card title="Details">
|
<Card title="Details">
|
||||||
|
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
|
||||||
|
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
|
||||||
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||||||
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||||
<DetailRow label="Currency" value={invoice.currency} />
|
<DetailRow label="Currency" value={invoice.currency} />
|
||||||
@@ -234,7 +203,13 @@ export default function InvoiceDetailScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Line items">
|
<Card title="Line items">
|
||||||
{invoice.items.map((item) => (
|
{invoice.items.length === 0 ? (
|
||||||
|
<Text style={styles.emptyLines}>
|
||||||
|
No line items yet. Clock time to this invoice from the Timer tab, or edit to
|
||||||
|
add lines manually.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
invoice.items.map((item) => (
|
||||||
<View key={item.id} style={styles.lineItem}>
|
<View key={item.id} style={styles.lineItem}>
|
||||||
<View style={styles.lineMeta}>
|
<View style={styles.lineMeta}>
|
||||||
<Text style={styles.lineDescription}>{item.description}</Text>
|
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||||
@@ -247,7 +222,8 @@ export default function InvoiceDetailScreen() {
|
|||||||
{formatCurrency(item.amount, invoice.currency)}
|
{formatCurrency(item.amount, invoice.currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||||
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
||||||
@@ -264,43 +240,19 @@ export default function InvoiceDetailScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<InvoiceDetailActions
|
||||||
{status !== "paid" ? (
|
status={status}
|
||||||
<Button
|
clientEmail={clientEmail}
|
||||||
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
onPaymentReminder={
|
||||||
onPress={promptSendInvoice}
|
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
|
||||||
loading={sendInvoice.isPending}
|
}
|
||||||
/>
|
paymentReminderLoading={sendPaymentReminder.isPending}
|
||||||
) : null}
|
onUpdateStatus={() => promptStatusChange(status)}
|
||||||
{status === "sent" || status === "overdue" ? (
|
updateStatusLoading={updateStatus.isPending}
|
||||||
<Button
|
onTrackTime={() =>
|
||||||
title="Send payment reminder"
|
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
|
||||||
variant="secondary"
|
|
||||||
onPress={promptPaymentReminder}
|
|
||||||
loading={sendPaymentReminder.isPending}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
title="Edit invoice"
|
|
||||||
variant="secondary"
|
|
||||||
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Update status"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() => promptStatusChange(status)}
|
|
||||||
loading={updateStatus.isPending}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Track time to this invoice"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() =>
|
|
||||||
router.push(
|
|
||||||
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -398,22 +350,18 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
emptyLines: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
notes: {
|
notes: {
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
actions: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
headerAction: {
|
|
||||||
fontFamily: fonts.bodySemiBold,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
headerPressed: {
|
|
||||||
opacity: 0.65,
|
|
||||||
},
|
|
||||||
errorBox: {
|
errorBox: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ export default function InvoicesLayout() {
|
|||||||
headerBackTitle: "Invoices",
|
headerBackTitle: "Invoices",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="send/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Send invoice",
|
||||||
|
headerBackTitle: "Invoice",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="edit/[id]"
|
name="edit/[id]"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
+146
-118
@@ -16,20 +16,20 @@ import {
|
|||||||
InvoiceEditorSectionTabs,
|
InvoiceEditorSectionTabs,
|
||||||
type InvoiceEditorSection,
|
type InvoiceEditorSection,
|
||||||
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||||
|
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
|
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { isValidTaxRate, validateLineItems } from "@/lib/form-validation";
|
||||||
|
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||||
import { validateLineItems } from "@/lib/form-validation";
|
|
||||||
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
|
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
|
||||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
@@ -47,19 +47,27 @@ export default function InvoiceEditScreen() {
|
|||||||
{ id: id ?? "" },
|
{ id: id ?? "" },
|
||||||
{ enabled: Boolean(id) },
|
{ enabled: Boolean(id) },
|
||||||
);
|
);
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const [businessId, setBusinessId] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [dueDate, setDueDate] = useState(() => new Date());
|
const [dueDate, setDueDate] = useState(() => new Date());
|
||||||
|
const [taxRate, setTaxRate] = useState("0");
|
||||||
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||||
const [section, setSection] = useState<InvoiceEditorSection>("edit");
|
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const invoice = invoiceQuery.data;
|
const invoice = invoiceQuery.data;
|
||||||
if (!invoice) return;
|
if (!invoice) return;
|
||||||
|
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
|
||||||
|
setClientId(invoice.clientId);
|
||||||
setNotes(invoice.notes ?? "");
|
setNotes(invoice.notes ?? "");
|
||||||
setDueDate(new Date(invoice.dueDate));
|
setDueDate(new Date(invoice.dueDate));
|
||||||
|
setTaxRate(String(invoice.taxRate));
|
||||||
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
||||||
setItems(
|
setItems(
|
||||||
invoice.items.map((item) => ({
|
invoice.items.map((item) => ({
|
||||||
@@ -72,6 +80,11 @@ export default function InvoiceEditScreen() {
|
|||||||
);
|
);
|
||||||
}, [invoiceQuery.data]);
|
}, [invoiceQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (businessId || !businessesQuery.data?.length) return;
|
||||||
|
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||||
|
}, [businessId, businessesQuery.data]);
|
||||||
|
|
||||||
const updateInvoice = api.invoices.update.useMutation({
|
const updateInvoice = api.invoices.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
@@ -85,19 +98,31 @@ export default function InvoiceEditScreen() {
|
|||||||
onError: (err) => setError(err.message),
|
onError: (err) => setError(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendInvoice = api.email.sendInvoice.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert("Invoice sent", data.message);
|
|
||||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
void utils.dashboard.getStats.invalidate();
|
|
||||||
},
|
|
||||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
const invoice = invoiceQuery.data;
|
||||||
const isDraft = invoice?.status === "draft";
|
const isDraft = invoice?.status === "draft";
|
||||||
|
|
||||||
|
const businessOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(businessesQuery.data ?? []).map((business) => ({
|
||||||
|
label: business.name,
|
||||||
|
value: business.id,
|
||||||
|
})),
|
||||||
|
[businessesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(clientsQuery.data ?? []).map((client) => ({
|
||||||
|
label: client.name,
|
||||||
|
value: client.id,
|
||||||
|
})),
|
||||||
|
[clientsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||||
|
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
||||||
|
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||||
|
|
||||||
const subtotal = useMemo(
|
const subtotal = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.reduce((sum, item) => {
|
items.reduce((sum, item) => {
|
||||||
@@ -108,35 +133,39 @@ export default function InvoiceEditScreen() {
|
|||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const taxRate = invoice?.taxRate ?? 0;
|
const parsedTaxRate = Number(taxRate) || 0;
|
||||||
const taxAmount = subtotal * (taxRate / 100);
|
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
const currency = invoice?.currency ?? "USD";
|
|
||||||
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||||
const canSave = isDraft ? !lineItemsError : true;
|
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
|
||||||
|
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
||||||
|
const clientError = isDraft && !clientId ? "Select a client" : undefined;
|
||||||
|
const canSave = isDraft
|
||||||
|
? !lineItemsError && !taxError && !businessError && !clientError
|
||||||
|
: true;
|
||||||
|
|
||||||
const previewInput = useMemo(() => {
|
const previewInput = useMemo(() => {
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
return buildPreviewPdfInput({
|
return buildPreviewPdfInput({
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
invoicePrefix: invoice.invoicePrefix,
|
invoicePrefix: invoice.invoicePrefix,
|
||||||
businessId: invoice.businessId,
|
businessId: resolvedBusinessId,
|
||||||
clientId: invoice.clientId,
|
clientId,
|
||||||
issueDate: new Date(invoice.issueDate),
|
issueDate: new Date(invoice.issueDate),
|
||||||
dueDate,
|
dueDate,
|
||||||
status: invoice.status as "draft" | "sent" | "paid",
|
status: invoice.status as "draft" | "sent" | "paid",
|
||||||
notes,
|
notes,
|
||||||
taxRate,
|
taxRate: parsedTaxRate,
|
||||||
currency,
|
currency,
|
||||||
items,
|
items,
|
||||||
});
|
});
|
||||||
}, [invoice, dueDate, notes, taxRate, currency, items]);
|
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return <LoadingScreen message="Invalid invoice" />;
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoiceQuery.isLoading) {
|
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||||
return <LoadingScreen message="Loading invoice…" />;
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,28 +176,6 @@ export default function InvoiceEditScreen() {
|
|||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
|
||||||
function promptSendInvoice() {
|
|
||||||
if (!clientEmail) {
|
|
||||||
Alert.alert(
|
|
||||||
"No client email",
|
|
||||||
"Add an email address to this client on the web app before sending invoices.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
status === "draft" ? "Send invoice" : "Resend invoice",
|
|
||||||
`Email this invoice to ${clientEmail}?`,
|
|
||||||
[
|
|
||||||
{ text: "Cancel", style: "cancel" },
|
|
||||||
{
|
|
||||||
text: "Send",
|
|
||||||
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
}
|
}
|
||||||
@@ -186,10 +193,6 @@ export default function InvoiceEditScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(index: number) {
|
function removeItem(index: number) {
|
||||||
if (items.length <= 1) {
|
|
||||||
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +233,10 @@ export default function InvoiceEditScreen() {
|
|||||||
sendReminderAt,
|
sendReminderAt,
|
||||||
...(isDraft
|
...(isDraft
|
||||||
? {
|
? {
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
taxRate: parsedTaxRate,
|
||||||
|
currency,
|
||||||
items: parsedItems,
|
items: parsedItems,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -254,7 +261,9 @@ export default function InvoiceEditScreen() {
|
|||||||
{invoice.invoicePrefix}
|
{invoice.invoicePrefix}
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
<Text style={styles.clientName}>
|
||||||
|
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
||||||
@@ -263,48 +272,50 @@ export default function InvoiceEditScreen() {
|
|||||||
<Card title="PDF preview">
|
<Card title="PDF preview">
|
||||||
<InvoicePdfPreview input={previewInput} />
|
<InvoicePdfPreview input={previewInput} />
|
||||||
</Card>
|
</Card>
|
||||||
|
) : section === "setup" ? (
|
||||||
|
<Card title="Invoice setup">
|
||||||
|
<InvoiceSetupForm
|
||||||
|
businessId={businessId}
|
||||||
|
onBusinessIdChange={setBusinessId}
|
||||||
|
businessOptions={businessOptions}
|
||||||
|
businessError={businessError}
|
||||||
|
businessReadOnly={!isDraft}
|
||||||
|
clientId={clientId}
|
||||||
|
onClientIdChange={setClientId}
|
||||||
|
clientOptions={clientOptions}
|
||||||
|
clientError={clientError}
|
||||||
|
clientReadOnly={!isDraft}
|
||||||
|
invoiceNumber={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||||
|
invoiceNumberReadOnly
|
||||||
|
issueDate={new Date(invoice.issueDate)}
|
||||||
|
issueDateReadOnly
|
||||||
|
dueDate={dueDate}
|
||||||
|
onDueDateChange={setDueDate}
|
||||||
|
taxRate={taxRate}
|
||||||
|
onTaxRateChange={isDraft ? setTaxRate : undefined}
|
||||||
|
taxRateReadOnly={!isDraft}
|
||||||
|
notes={notes}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
sendReminderAt={sendReminderAt}
|
||||||
|
onSendReminderAtChange={isDraft ? setSendReminderAt : undefined}
|
||||||
|
showSendReminder={isDraft}
|
||||||
|
/>
|
||||||
|
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Card>
|
|
||||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
|
||||||
{isDraft ? (
|
|
||||||
<>
|
|
||||||
<DateTimeField
|
|
||||||
label="Remind me to send"
|
|
||||||
mode="date"
|
|
||||||
value={sendReminderAt ?? dueDate}
|
|
||||||
minimumDate={new Date()}
|
|
||||||
maximumDate={new Date(2100, 0, 1)}
|
|
||||||
onChange={setSendReminderAt}
|
|
||||||
/>
|
|
||||||
{sendReminderAt ? (
|
|
||||||
<Pressable onPress={() => setSendReminderAt(null)}>
|
|
||||||
<Text style={[styles.clearReminder, { color: colors.primary }]}>
|
|
||||||
Clear send reminder
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
label="Notes"
|
|
||||||
value={notes}
|
|
||||||
onChangeText={setNotes}
|
|
||||||
placeholder="Optional notes for the client"
|
|
||||||
multiline
|
|
||||||
style={styles.notesInput}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Line items">
|
<Card title="Line items">
|
||||||
{!isDraft ? (
|
{!isDraft ? (
|
||||||
<Text style={styles.lockedHint}>
|
<Text style={styles.lockedHint}>
|
||||||
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
||||||
screen to edit entries.
|
screen to edit entries.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : items.length === 0 ? (
|
||||||
<LineItemsTableHeader />
|
<Text style={styles.emptyLines}>
|
||||||
)}
|
No line items yet. Add lines here or clock time to this invoice from the
|
||||||
|
Timer tab.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<LineItemEditor
|
<LineItemEditor
|
||||||
key={item.id ?? `new-${index}`}
|
key={item.id ?? `new-${index}`}
|
||||||
@@ -320,39 +331,61 @@ export default function InvoiceEditScreen() {
|
|||||||
|
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||||
<Text style={styles.addLineText}>+ Add line</Text>
|
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
subtotal={formatCurrency(subtotal, currency)}
|
subtotal={formatCurrency(subtotal, currency)}
|
||||||
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined}
|
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||||
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
||||||
total={formatCurrency(total, currency)}
|
total={formatCurrency(total, currency)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<Button
|
|
||||||
title="Save changes"
|
|
||||||
loading={updateInvoice.isPending}
|
|
||||||
disabled={!canSave}
|
|
||||||
onPress={handleSave}
|
|
||||||
/>
|
|
||||||
{status !== "paid" ? (
|
|
||||||
<Button
|
|
||||||
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
|
||||||
variant="secondary"
|
|
||||||
onPress={promptSendInvoice}
|
|
||||||
loading={sendInvoice.isPending}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<InvoiceEditorFooter
|
||||||
|
primaryTitle="Save changes"
|
||||||
|
onPrimary={handleSave}
|
||||||
|
primaryLoading={updateInvoice.isPending}
|
||||||
|
primaryDisabled={!canSave}
|
||||||
|
secondary={
|
||||||
|
status !== "paid"
|
||||||
|
? {
|
||||||
|
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
||||||
|
subtitle: clientEmail
|
||||||
|
? items.length === 0
|
||||||
|
? "Add line items before sending"
|
||||||
|
: `Review PDF and email to ${clientEmail}`
|
||||||
|
: "Add a client email on the web app first",
|
||||||
|
icon: "mail-outline",
|
||||||
|
onPress: () => {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client on the web app before sending invoices.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
Alert.alert(
|
||||||
|
"No line items",
|
||||||
|
"Add line items or clock time to this invoice before sending.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/(app)/invoices/send/${invoice.id}`);
|
||||||
|
},
|
||||||
|
disabled: !clientEmail || items.length === 0,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
@@ -380,23 +413,21 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
color: colors.mutedForeground,
|
color: colors.mutedForeground,
|
||||||
},
|
},
|
||||||
notesInput: {
|
|
||||||
minHeight: 72,
|
|
||||||
textAlignVertical: "top",
|
|
||||||
},
|
|
||||||
lockedHint: {
|
lockedHint: {
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colors.mutedForeground,
|
color: colors.mutedForeground,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
clearReminder: {
|
emptyLines: {
|
||||||
fontFamily: fonts.bodyMedium,
|
fontFamily: fonts.body,
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.mutedForeground,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
addLine: {
|
addLine: {
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.md,
|
||||||
paddingBottom: spacing.xs,
|
paddingBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
addLineText: {
|
addLineText: {
|
||||||
@@ -409,7 +440,4 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
actions: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,7 +169,19 @@ export default function InvoicesScreen() {
|
|||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
accessibilityLabel="Create invoice"
|
accessibilityLabel="Create invoice"
|
||||||
onPress={() => router.push("/(app)/invoices/new")}
|
onPress={() => {
|
||||||
|
Alert.alert("Create invoice", "Choose how to start", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "With line items",
|
||||||
|
onPress: () => router.push("/(app)/invoices/new"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Blank (for timer)",
|
||||||
|
onPress: () => router.push("/(app)/invoices/new?blank=1"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TabPage>
|
</TabPage>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
|
|||||||
+123
-87
@@ -1,4 +1,4 @@
|
|||||||
import { router, Stack } from "expo-router";
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -16,55 +16,75 @@ import {
|
|||||||
InvoiceEditorSectionTabs,
|
InvoiceEditorSectionTabs,
|
||||||
type InvoiceEditorSection,
|
type InvoiceEditorSection,
|
||||||
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||||
|
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||||
|
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
|
||||||
import { SelectField } from "@/components/ui/SelectField";
|
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { formatCurrency } from "@/lib/format";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
|
||||||
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
|
||||||
import {
|
import {
|
||||||
isRequiredString,
|
isRequiredString,
|
||||||
isValidTaxRate,
|
isValidTaxRate,
|
||||||
validateLineItems,
|
validateLineItems,
|
||||||
} from "@/lib/form-validation";
|
} from "@/lib/form-validation";
|
||||||
|
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||||
|
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||||
|
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
export default function NewInvoiceScreen() {
|
export default function NewInvoiceScreen() {
|
||||||
const { colors } = useAppTheme();
|
|
||||||
const styles = useThemedStyles(createNewInvoiceStyles);
|
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const scrollPadding = useTabBarScrollPadding();
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
const { blank } = useLocalSearchParams<{ blank?: string }>();
|
||||||
|
const isBlank = blank === "1" || blank === "true";
|
||||||
|
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
const clientsQuery = api.clients.getAll.useQuery();
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const [businessId, setBusinessId] = useState("");
|
||||||
const [clientId, setClientId] = useState("");
|
const [clientId, setClientId] = useState("");
|
||||||
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||||
const [issueDate, setIssueDate] = useState(() => new Date());
|
const [issueDate, setIssueDate] = useState(() => new Date());
|
||||||
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [taxRate, setTaxRate] = useState("0");
|
const [taxRate, setTaxRate] = useState("0");
|
||||||
const [items, setItems] = useState<EditableLineItem[]>([
|
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
||||||
|
isBlank
|
||||||
|
? []
|
||||||
|
: [
|
||||||
{
|
{
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
description: "",
|
description: "",
|
||||||
hours: "1",
|
hours: "1",
|
||||||
rate: "0",
|
rate: "0",
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
const [section, setSection] = useState<InvoiceEditorSection>("edit");
|
);
|
||||||
|
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (businessId || !businessesQuery.data?.length) return;
|
||||||
|
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||||
|
}, [businessId, businessesQuery.data]);
|
||||||
|
|
||||||
|
const businessOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(businessesQuery.data ?? []).map((business) => ({
|
||||||
|
label: business.name,
|
||||||
|
value: business.id,
|
||||||
|
})),
|
||||||
|
[businessesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
const clientOptions = useMemo(
|
const clientOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(clientsQuery.data ?? []).map((client) => ({
|
(clientsQuery.data ?? []).map((client) => ({
|
||||||
@@ -76,6 +96,7 @@ export default function NewInvoiceScreen() {
|
|||||||
|
|
||||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||||
const currency = selectedClient?.currency ?? "USD";
|
const currency = selectedClient?.currency ?? "USD";
|
||||||
|
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedClient?.defaultHourlyRate) return;
|
if (!selectedClient?.defaultHourlyRate) return;
|
||||||
@@ -120,6 +141,7 @@ export default function NewInvoiceScreen() {
|
|||||||
() =>
|
() =>
|
||||||
buildPreviewPdfInput({
|
buildPreviewPdfInput({
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
clientId,
|
clientId,
|
||||||
issueDate,
|
issueDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
@@ -128,9 +150,20 @@ export default function NewInvoiceScreen() {
|
|||||||
notes,
|
notes,
|
||||||
items,
|
items,
|
||||||
}),
|
}),
|
||||||
[invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
|
[
|
||||||
|
invoiceNumber,
|
||||||
|
resolvedBusinessId,
|
||||||
|
clientId,
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
parsedTaxRate,
|
||||||
|
currency,
|
||||||
|
notes,
|
||||||
|
items,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const businessError = resolvedBusinessId ? undefined : "Select a business";
|
||||||
const clientError = clientId ? undefined : "Select a client";
|
const clientError = clientId ? undefined : "Select a client";
|
||||||
const invoiceNumberError = isRequiredString(invoiceNumber)
|
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||||
? undefined
|
? undefined
|
||||||
@@ -138,13 +171,15 @@ export default function NewInvoiceScreen() {
|
|||||||
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
||||||
const lineItemsError = validateLineItems(items);
|
const lineItemsError = validateLineItems(items);
|
||||||
const canCreate =
|
const canCreate =
|
||||||
|
businessOptions.length > 0 &&
|
||||||
clientOptions.length > 0 &&
|
clientOptions.length > 0 &&
|
||||||
|
!businessError &&
|
||||||
!clientError &&
|
!clientError &&
|
||||||
!invoiceNumberError &&
|
!invoiceNumberError &&
|
||||||
!taxError &&
|
!taxError &&
|
||||||
!lineItemsError;
|
!lineItemsError;
|
||||||
|
|
||||||
if (clientsQuery.isLoading) {
|
if (businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||||
return <LoadingScreen message="Loading…" />;
|
return <LoadingScreen message="Loading…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +200,6 @@ export default function NewInvoiceScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(index: number) {
|
function removeItem(index: number) {
|
||||||
if (items.length <= 1) {
|
|
||||||
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +224,7 @@ export default function NewInvoiceScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createInvoice.mutate({
|
createInvoice.mutate({
|
||||||
|
businessId: resolvedBusinessId,
|
||||||
clientId,
|
clientId,
|
||||||
invoiceNumber: invoiceNumber.trim(),
|
invoiceNumber: invoiceNumber.trim(),
|
||||||
issueDate,
|
issueDate,
|
||||||
@@ -207,7 +239,12 @@ export default function NewInvoiceScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
title: isBlank ? "Blank invoice" : "New invoice",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
@@ -224,68 +261,63 @@ export default function NewInvoiceScreen() {
|
|||||||
<Card title="PDF preview">
|
<Card title="PDF preview">
|
||||||
<InvoicePdfPreview input={previewInput} />
|
<InvoicePdfPreview input={previewInput} />
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : section === "setup" ? (
|
||||||
<>
|
<Card title="Invoice setup">
|
||||||
<Card title="Details">
|
{clientOptions.length === 0 || businessOptions.length === 0 ? (
|
||||||
{clientOptions.length === 0 ? (
|
<View style={styles.noEntities}>
|
||||||
<View style={styles.noClients}>
|
<Text style={styles.noEntitiesText}>
|
||||||
<Text style={styles.noClientsText}>
|
{businessOptions.length === 0
|
||||||
Add a client before creating an invoice.
|
? "Add a business before creating an invoice."
|
||||||
|
: "Add a client before creating an invoice."}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
title="Add client"
|
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => router.push("/(app)/entities/clients/new")}
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
businessOptions.length === 0
|
||||||
|
? "/(app)/entities/businesses/new"
|
||||||
|
: "/(app)/entities/clients/new",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<SelectField
|
<InvoiceSetupForm
|
||||||
label="Client"
|
businessId={businessId}
|
||||||
placeholder="Select client…"
|
onBusinessIdChange={setBusinessId}
|
||||||
value={clientId}
|
businessOptions={businessOptions}
|
||||||
options={clientOptions}
|
businessError={businessError}
|
||||||
required
|
clientId={clientId}
|
||||||
error={clientError}
|
onClientIdChange={setClientId}
|
||||||
onValueChange={setClientId}
|
clientOptions={clientOptions}
|
||||||
|
clientError={clientError}
|
||||||
|
invoiceNumber={invoiceNumber}
|
||||||
|
onInvoiceNumberChange={setInvoiceNumber}
|
||||||
|
issueDate={issueDate}
|
||||||
|
onIssueDateChange={setIssueDate}
|
||||||
|
dueDate={dueDate}
|
||||||
|
onDueDateChange={setDueDate}
|
||||||
|
taxRate={taxRate}
|
||||||
|
onTaxRateChange={setTaxRate}
|
||||||
|
notes={notes}
|
||||||
|
onNotesChange={setNotes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Input
|
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
||||||
label="Invoice number"
|
{invoiceNumberError ? (
|
||||||
value={invoiceNumber}
|
<Text style={styles.error}>{invoiceNumberError}</Text>
|
||||||
onChangeText={setInvoiceNumber}
|
) : null}
|
||||||
autoCapitalize="characters"
|
|
||||||
required
|
|
||||||
error={invoiceNumberError}
|
|
||||||
/>
|
|
||||||
<DateTimeField
|
|
||||||
label="Issue date"
|
|
||||||
mode="date"
|
|
||||||
value={issueDate}
|
|
||||||
onChange={(date) => {
|
|
||||||
setIssueDate(date);
|
|
||||||
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
|
||||||
<Input
|
|
||||||
label="Tax rate (%)"
|
|
||||||
value={taxRate}
|
|
||||||
onChangeText={setTaxRate}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
error={taxError}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Notes"
|
|
||||||
value={notes}
|
|
||||||
onChangeText={setNotes}
|
|
||||||
placeholder="Optional notes for the client"
|
|
||||||
multiline
|
|
||||||
style={styles.notesInput}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Card title="Line items">
|
<Card title="Line items">
|
||||||
<LineItemsTableHeader />
|
{isBlank && items.length === 0 ? (
|
||||||
|
<Text style={styles.emptyLines}>
|
||||||
|
No line items yet. Save this draft and clock time to it from the Timer tab,
|
||||||
|
or add lines here.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<LineItemEditor
|
<LineItemEditor
|
||||||
key={`new-${index}`}
|
key={`new-${index}`}
|
||||||
@@ -299,7 +331,7 @@ export default function NewInvoiceScreen() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||||
<Text style={styles.addLineText}>+ Add line</Text>
|
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
@@ -313,16 +345,17 @@ export default function NewInvoiceScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
title="Create invoice"
|
|
||||||
loading={createInvoice.isPending}
|
|
||||||
disabled={!canCreate}
|
|
||||||
onPress={handleCreate}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<InvoiceEditorFooter
|
||||||
|
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||||
|
onPrimary={handleCreate}
|
||||||
|
primaryLoading={createInvoice.isPending}
|
||||||
|
primaryDisabled={!canCreate}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
@@ -336,21 +369,24 @@ const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
notesInput: {
|
noEntities: {
|
||||||
minHeight: 72,
|
|
||||||
textAlignVertical: "top",
|
|
||||||
},
|
|
||||||
noClients: {
|
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
noClientsText: {
|
noEntitiesText: {
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: colors.mutedForeground,
|
color: colors.mutedForeground,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
emptyLines: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
addLine: {
|
addLine: {
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.md,
|
||||||
paddingBottom: spacing.xs,
|
paddingBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
addLineText: {
|
addLineText: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { InstanceUrlField } from "@/components/InstanceUrlField";
|
|||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { PinPrompt } from "@/components/PinPrompt";
|
import { PinPrompt } from "@/components/PinPrompt";
|
||||||
|
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
@@ -294,6 +295,12 @@ export default function SettingsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<Card title="Shortcuts & Siri">
|
||||||
|
<ShortcutsSetupCard />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card title="Security">
|
<Card title="Security">
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<View style={styles.settingCopy}>
|
<View style={styles.settingCopy}>
|
||||||
|
|||||||
+38
-71
@@ -1,19 +1,13 @@
|
|||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
ScrollView,
|
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||||
StyleSheet,
|
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { FullScreen } from "@/components/Screen";
|
|
||||||
import { AuthBackground } from "@/components/AppBackground";
|
|
||||||
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||||
import { HeadingText, Logo } from "@/components/Logo";
|
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAccounts } from "@/contexts/AccountsContext";
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
@@ -21,7 +15,12 @@ import { useAuthClient } from "@/contexts/AuthContext";
|
|||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { registerAccount } from "@/lib/auth-api";
|
import { registerAccount } from "@/lib/auth-api";
|
||||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||||
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
|
import {
|
||||||
|
isRequiredString,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPassword,
|
||||||
|
useFieldVisibility,
|
||||||
|
} from "@/lib/form-validation";
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
const authClient = useAuthClient();
|
const authClient = useAuthClient();
|
||||||
@@ -96,25 +95,12 @@ export default function RegisterScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthBackground>
|
<AuthScreenLayout>
|
||||||
<FullScreen style={styles.safe}>
|
<AuthCard>
|
||||||
<KeyboardAvoidingView
|
<AuthCardHeader
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
title="Create your account"
|
||||||
style={styles.flex}
|
description="Get started with your workspace"
|
||||||
>
|
/>
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.container}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Logo size="lg" />
|
|
||||||
<HeadingText style={styles.title}>Create your account</HeadingText>
|
|
||||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
|
||||||
Get started today
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||||
|
|
||||||
@@ -123,11 +109,12 @@ export default function RegisterScreen() {
|
|||||||
<View style={styles.half}>
|
<View style={styles.half}>
|
||||||
<Input
|
<Input
|
||||||
label="First name"
|
label="First name"
|
||||||
|
leftIcon="person-outline"
|
||||||
value={firstName}
|
value={firstName}
|
||||||
onChangeText={setFirstName}
|
onChangeText={setFirstName}
|
||||||
onBlur={() => touch("firstName")}
|
onBlur={() => touch("firstName")}
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
placeholder="Jane"
|
placeholder="John"
|
||||||
required
|
required
|
||||||
error={visible("firstName") ? firstNameError : undefined}
|
error={visible("firstName") ? firstNameError : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -135,6 +122,7 @@ export default function RegisterScreen() {
|
|||||||
<View style={styles.half}>
|
<View style={styles.half}>
|
||||||
<Input
|
<Input
|
||||||
label="Last name"
|
label="Last name"
|
||||||
|
leftIcon="person-outline"
|
||||||
value={lastName}
|
value={lastName}
|
||||||
onChangeText={setLastName}
|
onChangeText={setLastName}
|
||||||
onBlur={() => touch("lastName")}
|
onBlur={() => touch("lastName")}
|
||||||
@@ -148,6 +136,7 @@ export default function RegisterScreen() {
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
|
leftIcon="mail-outline"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
@@ -160,12 +149,14 @@ export default function RegisterScreen() {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
|
leftIcon="lock-closed-outline"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
onBlur={() => touch("password")}
|
onBlur={() => touch("password")}
|
||||||
placeholder="At least 8 characters"
|
placeholder="••••••••"
|
||||||
|
hint="At least 8 characters"
|
||||||
required
|
required
|
||||||
error={visible("password") ? passwordValidationError : undefined}
|
error={visible("password") ? passwordValidationError : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -175,9 +166,10 @@ export default function RegisterScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Create Account"
|
title={loading ? "Creating account…" : "Create account"}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!canRegister}
|
disabled={!canRegister}
|
||||||
|
showArrow={!loading}
|
||||||
onPress={handleRegister}
|
onPress={handleRegister}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -188,49 +180,24 @@ export default function RegisterScreen() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
|
||||||
</View>
|
<LegalAgreementNotice action="creating an account" />
|
||||||
</ScrollView>
|
</AuthCard>
|
||||||
</KeyboardAvoidingView>
|
</AuthScreenLayout>
|
||||||
</FullScreen>
|
|
||||||
</AuthBackground>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safe: { flex: 1 },
|
form: {
|
||||||
flex: { flex: 1 },
|
gap: spacing.md,
|
||||||
container: {
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: spacing.lg,
|
|
||||||
paddingVertical: spacing.xl,
|
|
||||||
},
|
},
|
||||||
content: {
|
row: {
|
||||||
width: "100%",
|
flexDirection: "row",
|
||||||
maxWidth: 420,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
card: {
|
half: {
|
||||||
gap: spacing.lg,
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: fonts.body,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
form: { gap: spacing.md },
|
|
||||||
row: { flexDirection: "row", gap: spacing.md },
|
|
||||||
half: { flex: 1 },
|
|
||||||
error: {
|
error: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
|
|||||||
+26
-109
@@ -1,21 +1,16 @@
|
|||||||
import { Link, router } from "expo-router";
|
import { Link, router } from "expo-router";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
Pressable,
|
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||||
ScrollView,
|
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||||
StyleSheet,
|
import { AuthNotice } from "@/components/auth/AuthNotice";
|
||||||
Text,
|
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { AuthBackground } from "@/components/AppBackground";
|
|
||||||
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||||
import { HeadingText, Logo } from "@/components/Logo";
|
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
|
||||||
import { FullScreen } from "@/components/Screen";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAccounts } from "@/contexts/AccountsContext";
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
@@ -124,32 +119,14 @@ export default function SignInScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthBackground>
|
<AuthScreenLayout>
|
||||||
<FullScreen style={styles.safe}>
|
<AuthCard>
|
||||||
<KeyboardAvoidingView
|
<AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
||||||
style={styles.flex}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.container}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Logo size="lg" />
|
|
||||||
<HeadingText style={styles.title}>Welcome back</HeadingText>
|
|
||||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
|
||||||
Sign in to manage invoices on the go
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||||
|
|
||||||
{signupsDisabled ? (
|
{signupsDisabled ? (
|
||||||
<Text style={[styles.notice, { color: colors.mutedForeground }]}>
|
<AuthNotice>New account registration is currently disabled.</AuthNotice>
|
||||||
New account registration is currently disabled on this server.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{authentikEnabled ? (
|
{authentikEnabled ? (
|
||||||
@@ -161,19 +138,14 @@ export default function SignInScreen() {
|
|||||||
disabled={!serverReady}
|
disabled={!serverReady}
|
||||||
onPress={() => void handleAuthentikSignIn()}
|
onPress={() => void handleAuthentikSignIn()}
|
||||||
/>
|
/>
|
||||||
<View style={styles.dividerRow}>
|
<AuthDivider />
|
||||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
|
||||||
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}>
|
|
||||||
or
|
|
||||||
</Text>
|
|
||||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
|
leftIcon="mail-outline"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
@@ -186,6 +158,7 @@ export default function SignInScreen() {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
|
leftIcon="lock-closed-outline"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
autoComplete="password"
|
autoComplete="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -194,22 +167,24 @@ export default function SignInScreen() {
|
|||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
error={visible("password") ? passwordValidationError : undefined}
|
error={visible("password") ? passwordValidationError : undefined}
|
||||||
/>
|
labelAccessory={
|
||||||
|
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
|
||||||
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
|
|
||||||
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Sign In"
|
title={loading ? "Signing in…" : "Sign in"}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!canSignIn}
|
disabled={!canSignIn}
|
||||||
|
showArrow={!loading}
|
||||||
onPress={handleSignIn}
|
onPress={handleSignIn}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -218,83 +193,25 @@ export default function SignInScreen() {
|
|||||||
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
||||||
Create one
|
Create account
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
|
||||||
</View>
|
<LegalAgreementNotice action="signing in" />
|
||||||
</ScrollView>
|
</AuthCard>
|
||||||
</KeyboardAvoidingView>
|
</AuthScreenLayout>
|
||||||
</FullScreen>
|
|
||||||
</AuthBackground>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safe: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
flex: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: spacing.lg,
|
|
||||||
paddingVertical: spacing.xl,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 420,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
gap: spacing.lg,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: fonts.body,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
notice: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: fonts.body,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
ssoSection: {
|
ssoSection: {
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
dividerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
dividerLine: {
|
|
||||||
flex: 1,
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
},
|
|
||||||
dividerLabel: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: fonts.bodyMedium,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
},
|
|
||||||
form: {
|
form: {
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
forgot: {
|
forgot: {
|
||||||
alignSelf: "flex-end",
|
|
||||||
fontFamily: fonts.bodyMedium,
|
fontFamily: fonts.bodyMedium,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type SecondaryAction = {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
onPress: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvoiceEditorFooterProps = {
|
||||||
|
primaryTitle: string;
|
||||||
|
onPrimary: () => void;
|
||||||
|
primaryLoading?: boolean;
|
||||||
|
primaryDisabled?: boolean;
|
||||||
|
secondary?: SecondaryAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceEditorFooter({
|
||||||
|
primaryTitle,
|
||||||
|
onPrimary,
|
||||||
|
primaryLoading,
|
||||||
|
primaryDisabled,
|
||||||
|
secondary,
|
||||||
|
}: InvoiceEditorFooterProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
title={primaryTitle}
|
||||||
|
loading={primaryLoading}
|
||||||
|
disabled={primaryDisabled}
|
||||||
|
onPress={onPrimary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{secondary ? (
|
||||||
|
<>
|
||||||
|
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={secondary.disabled || secondary.loading}
|
||||||
|
onPress={secondary.onPress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.secondaryRow,
|
||||||
|
(pressed || secondary.loading) && styles.secondaryPressed,
|
||||||
|
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.secondaryCopy}>
|
||||||
|
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
|
||||||
|
{secondary.title}
|
||||||
|
</Text>
|
||||||
|
{secondary.subtitle ? (
|
||||||
|
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
|
||||||
|
{secondary.subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
marginVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
secondaryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
secondaryPressed: {
|
||||||
|
opacity: 0.75,
|
||||||
|
},
|
||||||
|
secondaryDisabled: {
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
secondaryCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
secondaryTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
secondarySubtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,21 +3,34 @@ import { ScrollView, StyleSheet, View } from "react-native";
|
|||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
import { spacing } from "@/constants/theme";
|
import { spacing } from "@/constants/theme";
|
||||||
|
|
||||||
export type InvoiceEditorSection = "edit" | "preview";
|
export type InvoiceEditorSection = "setup" | "lines" | "preview";
|
||||||
|
export type InvoiceViewSection = "details" | "preview";
|
||||||
|
|
||||||
type InvoiceEditorSectionTabsProps = {
|
type InvoiceEditorSectionTabsProps =
|
||||||
|
| {
|
||||||
|
mode?: "edit";
|
||||||
value: InvoiceEditorSection;
|
value: InvoiceEditorSection;
|
||||||
onChange: (value: InvoiceEditorSection) => void;
|
onChange: (value: InvoiceEditorSection) => void;
|
||||||
editLabel?: string;
|
}
|
||||||
previewLabel?: string;
|
| {
|
||||||
};
|
mode: "view";
|
||||||
|
value: InvoiceViewSection;
|
||||||
|
onChange: (value: InvoiceViewSection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
|
||||||
|
const tabs =
|
||||||
|
props.mode === "view"
|
||||||
|
? [
|
||||||
|
{ id: "details" as const, label: "Details" },
|
||||||
|
{ id: "preview" as const, label: "PDF" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "setup" as const, label: "Setup" },
|
||||||
|
{ id: "lines" as const, label: "Line items" },
|
||||||
|
{ id: "preview" as const, label: "PDF preview" },
|
||||||
|
];
|
||||||
|
|
||||||
export function InvoiceEditorSectionTabs({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
editLabel = "Edit",
|
|
||||||
previewLabel = "PDF preview",
|
|
||||||
}: InvoiceEditorSectionTabsProps) {
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -25,16 +38,14 @@ export function InvoiceEditorSectionTabs({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.row}
|
contentContainerStyle={styles.row}
|
||||||
>
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
<FilterChip
|
<FilterChip
|
||||||
label={editLabel}
|
key={tab.id}
|
||||||
active={value === "edit"}
|
label={tab.label}
|
||||||
onPress={() => onChange("edit")}
|
active={props.value === tab.id}
|
||||||
/>
|
onPress={() => props.onChange(tab.id as never)}
|
||||||
<FilterChip
|
|
||||||
label={previewLabel}
|
|
||||||
active={value === "preview"}
|
|
||||||
onPress={() => onChange("preview")}
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -25,28 +25,10 @@ type LineItemEditorProps = {
|
|||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LineItemsTableHeader() {
|
function FieldLabel({ children }: { children: string }) {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}>
|
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
|
||||||
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
|
|
||||||
Description
|
|
||||||
</Text>
|
|
||||||
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
|
|
||||||
Date
|
|
||||||
</Text>
|
|
||||||
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
|
|
||||||
Hrs
|
|
||||||
</Text>
|
|
||||||
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
|
|
||||||
Rate
|
|
||||||
</Text>
|
|
||||||
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
|
|
||||||
Amt
|
|
||||||
</Text>
|
|
||||||
<View style={headerStyles.spacer} />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,20 +50,20 @@ export function LineItemEditor({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.readBlock,
|
||||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
<Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
|
||||||
<View style={styles.descCol}>
|
Line {index + 1}
|
||||||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}>
|
</Text>
|
||||||
|
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
|
||||||
{item.description.trim() || "Untitled line"}
|
{item.description.trim() || "Untitled line"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||||||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
<Text style={[styles.readAmount, { color: colors.foreground }]}>
|
||||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
|
||||||
{formatCurrency(amount, currency)}
|
{formatCurrency(amount, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -92,11 +74,11 @@ export function LineItemEditor({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.editBlock,
|
styles.editBlock,
|
||||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.editTop}>
|
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
|
||||||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChangeText={(description) => onChange({ description })}
|
onChangeText={(description) => onChange({ description })}
|
||||||
@@ -111,21 +93,33 @@ export function LineItemEditor({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.metricsRow}>
|
<View style={styles.fieldsRow}>
|
||||||
|
<View style={styles.fieldCol}>
|
||||||
|
<FieldLabel>Date</FieldLabel>
|
||||||
<CompactDateField
|
<CompactDateField
|
||||||
value={item.date}
|
value={item.date}
|
||||||
onChange={(date) => onChange({ date })}
|
onChange={(date) => onChange({ date })}
|
||||||
style={styles.dateField}
|
style={styles.fieldControl}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.fieldCol}>
|
||||||
|
<FieldLabel>Hours</FieldLabel>
|
||||||
<CompactStepperInput
|
<CompactStepperInput
|
||||||
value={item.hours}
|
value={item.hours}
|
||||||
onChangeText={(hours) => onChange({ hours })}
|
onChangeText={(hours) => onChange({ hours })}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
style={styles.hoursField}
|
style={styles.fieldControl}
|
||||||
/>
|
/>
|
||||||
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}>
|
</View>
|
||||||
|
<View style={styles.fieldCol}>
|
||||||
|
<FieldLabel>Rate</FieldLabel>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.rateField,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
@@ -136,110 +130,103 @@ export function LineItemEditor({
|
|||||||
style={[styles.rateInput, { color: colors.foreground }]}
|
style={[styles.rateInput, { color: colors.foreground }]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footerRow}>
|
||||||
|
<View style={styles.amountGroup}>
|
||||||
|
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
|
||||||
|
<Text style={[styles.amountValue, { color: colors.foreground }]}>
|
||||||
{formatCurrency(amount, currency)}
|
{formatCurrency(amount, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Remove line item"
|
accessibilityLabel="Remove line item"
|
||||||
onPress={onRemove}
|
onPress={onRemove}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}
|
style={({ pressed }) => [
|
||||||
|
styles.remove,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
pressed && styles.removePressed,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
|
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerStyles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
readBlock: {
|
||||||
flexDirection: "row",
|
paddingVertical: spacing.md,
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingBottom: spacing.xs,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
},
|
},
|
||||||
cell: {
|
readIndex: {
|
||||||
fontFamily: fonts.bodySemiBold,
|
fontFamily: fonts.bodySemiBold,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
},
|
},
|
||||||
desc: { flex: 1, paddingLeft: 22 },
|
|
||||||
date: { width: 72 },
|
|
||||||
hours: { width: 88, textAlign: "center" },
|
|
||||||
rate: { width: 72, textAlign: "center" },
|
|
||||||
amt: { width: 64, textAlign: "right" },
|
|
||||||
spacer: { width: 32 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.xs,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
|
||||||
editBlock: {
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
editTop: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
index: {
|
|
||||||
width: 18,
|
|
||||||
fontFamily: fonts.bodySemiBold,
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
descCol: {
|
|
||||||
flex: 1,
|
|
||||||
gap: 2,
|
|
||||||
},
|
|
||||||
readTitle: {
|
readTitle: {
|
||||||
fontFamily: fonts.bodyMedium,
|
fontFamily: fonts.bodyMedium,
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
lineHeight: 18,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
readSub: {
|
readSub: {
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
readAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
editBlock: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
lineLabel: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.4,
|
||||||
},
|
},
|
||||||
descriptionInput: {
|
descriptionInput: {
|
||||||
flex: 1,
|
width: "100%",
|
||||||
minHeight: 36,
|
minHeight: 40,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.md,
|
borderRadius: radii.md,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
paddingVertical: 6,
|
paddingVertical: 8,
|
||||||
},
|
},
|
||||||
metricsRow: {
|
fieldsRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
gap: spacing.sm,
|
||||||
gap: spacing.xs,
|
|
||||||
paddingLeft: 22,
|
|
||||||
},
|
},
|
||||||
dateField: {
|
fieldCol: {
|
||||||
width: 72,
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
hoursField: {
|
fieldLabel: {
|
||||||
width: 88,
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
fieldControl: {
|
||||||
|
width: "100%",
|
||||||
},
|
},
|
||||||
rateField: {
|
rateField: {
|
||||||
width: 72,
|
minHeight: 36,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.md,
|
borderRadius: radii.md,
|
||||||
minHeight: 36,
|
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: spacing.xs,
|
||||||
},
|
},
|
||||||
ratePrefix: {
|
ratePrefix: {
|
||||||
@@ -248,23 +235,40 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
rateInput: {
|
rateInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.bodyMedium,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
amount: {
|
footerRow: {
|
||||||
width: 64,
|
flexDirection: "row",
|
||||||
fontFamily: fonts.bodySemiBold,
|
alignItems: "center",
|
||||||
fontSize: 13,
|
justifyContent: "space-between",
|
||||||
textAlign: "right",
|
gap: spacing.sm,
|
||||||
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
amountEdit: {
|
amountGroup: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
amountLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
amountValue: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 17,
|
||||||
},
|
},
|
||||||
remove: {
|
remove: {
|
||||||
width: 32,
|
width: 40,
|
||||||
height: 36,
|
height: 40,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
borderWidth: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -31,12 +31,12 @@ import {
|
|||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import {
|
import {
|
||||||
endTimeClockLiveActivity,
|
endTimeClockLiveActivity,
|
||||||
syncTimeClockLiveActivity,
|
|
||||||
} from "@/lib/time-clock-live-activity";
|
} from "@/lib/time-clock-live-activity";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CLOCK_DESCRIPTION,
|
DEFAULT_CLOCK_DESCRIPTION,
|
||||||
describeClockOutOutcome,
|
describeClockOutOutcome,
|
||||||
formatElapsedSeconds,
|
formatElapsedSeconds,
|
||||||
|
formatRunningTimerLabel,
|
||||||
resolveClockDescription,
|
resolveClockDescription,
|
||||||
resolveEffectiveHourlyRate,
|
resolveEffectiveHourlyRate,
|
||||||
startedAtFromMinutesAgo,
|
startedAtFromMinutesAgo,
|
||||||
@@ -90,6 +90,7 @@ export function TimeClockPanel({
|
|||||||
const [clientId, setClientId] = useState(defaultClientId);
|
const [clientId, setClientId] = useState(defaultClientId);
|
||||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const [stopNote, setStopNote] = useState("");
|
||||||
const [rateText, setRateText] = useState("");
|
const [rateText, setRateText] = useState("");
|
||||||
const [startedAt, setStartedAt] = useState(() => new Date());
|
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||||
@@ -139,18 +140,6 @@ export function TimeClockPanel({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await Promise.all([
|
|
||||||
utils.timeEntries.getRunning.invalidate(),
|
|
||||||
utils.invoices.getBillable.invalidate(),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
Alert.alert("Could not update timer", err.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await endTimeClockLiveActivity();
|
await endTimeClockLiveActivity();
|
||||||
@@ -175,6 +164,7 @@ export function TimeClockPanel({
|
|||||||
utils.dashboard.getStats.invalidate(),
|
utils.dashboard.getStats.invalidate(),
|
||||||
]);
|
]);
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setStopNote("");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +184,7 @@ export function TimeClockPanel({
|
|||||||
if (!running) return;
|
if (!running) return;
|
||||||
setClientId(running.clientId ?? "");
|
setClientId(running.clientId ?? "");
|
||||||
setInvoiceId(running.invoiceId ?? "");
|
setInvoiceId(running.invoiceId ?? "");
|
||||||
setDescription(running.description?.trim() ?? "");
|
setStopNote("");
|
||||||
setRateText(running.rate != null ? String(running.rate) : "");
|
setRateText(running.rate != null ? String(running.rate) : "");
|
||||||
}, [running]);
|
}, [running]);
|
||||||
|
|
||||||
@@ -247,24 +237,6 @@ export function TimeClockPanel({
|
|||||||
setFeaturedClientIds(ids.slice(0, 1));
|
setFeaturedClientIds(ids.slice(0, 1));
|
||||||
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
|
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!running) {
|
|
||||||
void endTimeClockLiveActivity();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sync = () => {
|
|
||||||
const seconds = Math.floor(
|
|
||||||
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
|
||||||
);
|
|
||||||
void syncTimeClockLiveActivity({ ...running, description }, seconds);
|
|
||||||
};
|
|
||||||
|
|
||||||
sync();
|
|
||||||
const interval = setInterval(sync, 15_000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [running, description]);
|
|
||||||
|
|
||||||
const selectedClient = clients.find((client) => client.id === clientId);
|
const selectedClient = clients.find((client) => client.id === clientId);
|
||||||
const rateCurrency = selectedClient?.currency ?? "USD";
|
const rateCurrency = selectedClient?.currency ?? "USD";
|
||||||
const effectiveRate = resolveEffectiveHourlyRate(
|
const effectiveRate = resolveEffectiveHourlyRate(
|
||||||
@@ -404,43 +376,19 @@ export function TimeClockPanel({
|
|||||||
async function handleClockOut() {
|
async function handleClockOut() {
|
||||||
try {
|
try {
|
||||||
await clockOut.mutateAsync({
|
await clockOut.mutateAsync({
|
||||||
description: description.trim() ? description.trim() : undefined,
|
description: stopNote.trim() ? stopNote.trim() : undefined,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
|
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRunningClientChange(nextClientId: string) {
|
|
||||||
if (!running) return;
|
|
||||||
setClientId(nextClientId);
|
|
||||||
setInvoiceId("");
|
|
||||||
try {
|
|
||||||
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
|
|
||||||
const client = clients.find((c) => c.id === nextClientId);
|
|
||||||
setRateText(clientRateText(client));
|
|
||||||
await persistClientChoice(nextClientId);
|
|
||||||
} catch {
|
|
||||||
setClientId(running.clientId ?? "");
|
|
||||||
setInvoiceId(running.invoiceId ?? "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRunningInvoiceChange(nextInvoiceId: string) {
|
|
||||||
if (!running) return;
|
|
||||||
const previous = invoiceId;
|
|
||||||
setInvoiceId(nextInvoiceId);
|
|
||||||
try {
|
|
||||||
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
|
|
||||||
} catch {
|
|
||||||
setInvoiceId(previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runningQuery.isLoading || clientsQuery.isLoading) {
|
if (runningQuery.isLoading || clientsQuery.isLoading) {
|
||||||
return <LoadingScreen message="Loading time clock…" />;
|
return <LoadingScreen message="Loading time clock…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||||
const runningMeta = [
|
const runningMeta = [
|
||||||
running?.client?.name ?? (running ? "No client" : null),
|
running?.client?.name ?? (running ? "No client" : null),
|
||||||
running?.invoice
|
running?.invoice
|
||||||
@@ -451,7 +399,6 @@ export function TimeClockPanel({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
const controlsDisabled = Boolean(running && updateRunning.isPending);
|
|
||||||
|
|
||||||
function renderClientChip(client: (typeof clients)[number]) {
|
function renderClientChip(client: (typeof clients)[number]) {
|
||||||
return (
|
return (
|
||||||
@@ -459,11 +406,7 @@ export function TimeClockPanel({
|
|||||||
key={client.id}
|
key={client.id}
|
||||||
label={client.name}
|
label={client.name}
|
||||||
active={clientId === client.id}
|
active={clientId === client.id}
|
||||||
onPress={() => {
|
onPress={() => selectClient(client.id)}
|
||||||
if (controlsDisabled) return;
|
|
||||||
if (running) void handleRunningClientChange(client.id);
|
|
||||||
else selectClient(client.id);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,18 +430,18 @@ export function TimeClockPanel({
|
|||||||
>
|
>
|
||||||
{running || !compact ? (
|
{running || !compact ? (
|
||||||
<GlassSurface style={running ? styles.runningCard : undefined}>
|
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||||
<View style={styles.hero}>
|
<View style={[styles.hero, running && styles.heroRunning]}>
|
||||||
{running ? (
|
{running ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.heroHeader}>
|
<View style={styles.heroHeader}>
|
||||||
<View style={styles.pulseDot} />
|
<View style={styles.pulseDot} />
|
||||||
<Text style={styles.heroLabel}>Running</Text>
|
<Text style={styles.heroLabelRunning}>Timer running</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
||||||
<Text style={styles.runningMeta}>
|
<Text style={styles.runningTitle}>{runningTitle}</Text>
|
||||||
Started {formatDateTime(running.startedAt)}
|
{runningMeta ? (
|
||||||
{runningMeta ? ` · ${runningMeta}` : ""}
|
<Text style={styles.runningMeta}>{runningMeta}</Text>
|
||||||
</Text>
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.idleHint}>
|
<Text style={styles.idleHint}>
|
||||||
@@ -510,13 +453,37 @@ export function TimeClockPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<GlassSurface style={styles.setupCard}>
|
<GlassSurface style={styles.setupCard}>
|
||||||
|
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<View style={styles.formSection}>
|
||||||
<Input
|
<Input
|
||||||
label="Title"
|
label="Note on stop (optional)"
|
||||||
|
value={stopNote}
|
||||||
|
onChangeText={setStopNote}
|
||||||
|
placeholder={
|
||||||
|
running.description?.trim()
|
||||||
|
? running.description
|
||||||
|
: "Update description when you stop"
|
||||||
|
}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||||
|
variant="danger"
|
||||||
|
loading={clockOut.isPending}
|
||||||
|
onPress={handleClockOut}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
value={description}
|
value={description}
|
||||||
onChangeText={setDescription}
|
onChangeText={setDescription}
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
|
style={[styles.titleField, { color: colors.foreground }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.setupSection}>
|
<View style={styles.setupSection}>
|
||||||
@@ -533,10 +500,7 @@ export function TimeClockPanel({
|
|||||||
<FilterChip
|
<FilterChip
|
||||||
label={clientsExpanded ? "Show less" : "Show more"}
|
label={clientsExpanded ? "Show less" : "Show more"}
|
||||||
active={clientsExpanded}
|
active={clientsExpanded}
|
||||||
onPress={() => {
|
onPress={() => setClientsExpanded((open) => !open)}
|
||||||
if (controlsDisabled) return;
|
|
||||||
setClientsExpanded((open) => !open);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -547,7 +511,7 @@ export function TimeClockPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{clockInErrors.clientId && !running ? (
|
{clockInErrors.clientId ? (
|
||||||
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
|
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -559,11 +523,7 @@ export function TimeClockPanel({
|
|||||||
<FilterChip
|
<FilterChip
|
||||||
label="Entry only"
|
label="Entry only"
|
||||||
active={!invoiceId}
|
active={!invoiceId}
|
||||||
onPress={() => {
|
onPress={() => setInvoiceId("")}
|
||||||
if (controlsDisabled) return;
|
|
||||||
if (running) void handleRunningInvoiceChange("");
|
|
||||||
else setInvoiceId("");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{billableInvoices.map((invoice) => {
|
{billableInvoices.map((invoice) => {
|
||||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||||
@@ -572,11 +532,7 @@ export function TimeClockPanel({
|
|||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
label={label}
|
label={label}
|
||||||
active={invoiceId === invoice.id}
|
active={invoiceId === invoice.id}
|
||||||
onPress={() => {
|
onPress={() => setInvoiceId(invoice.id)}
|
||||||
if (controlsDisabled) return;
|
|
||||||
if (running) void handleRunningInvoiceChange(invoice.id);
|
|
||||||
else setInvoiceId(invoice.id);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -584,7 +540,7 @@ export function TimeClockPanel({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!running && clientId ? (
|
{clientId ? (
|
||||||
<View style={styles.setupSection}>
|
<View style={styles.setupSection}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -690,28 +646,20 @@ export function TimeClockPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</GlassSurface>
|
|
||||||
|
|
||||||
{running ? (
|
|
||||||
<Button
|
<Button
|
||||||
title="Clock out"
|
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
||||||
variant="danger"
|
|
||||||
loading={clockOut.isPending}
|
|
||||||
onPress={handleClockOut}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
title="Clock in"
|
|
||||||
loading={clockIn.isPending}
|
loading={clockIn.isPending}
|
||||||
disabled={!canClockIn || clients.length === 0}
|
disabled={!canClockIn || clients.length === 0}
|
||||||
|
showArrow={!clockIn.isPending}
|
||||||
onPress={handleClockIn}
|
onPress={handleClockIn}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
{todayEntries.length > 0 ? (
|
{todayEntries.length > 0 ? (
|
||||||
<Card title="Today">
|
<Card title="Today's entries">
|
||||||
{todayEntries.map((entry) => {
|
{todayEntries.map((entry) => {
|
||||||
const invoiceLabel = entry.invoice
|
const invoiceLabel = entry.invoice
|
||||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
@@ -720,7 +668,7 @@ export function TimeClockPanel({
|
|||||||
const row = (
|
const row = (
|
||||||
<>
|
<>
|
||||||
<View style={styles.entryMeta}>
|
<View style={styles.entryMeta}>
|
||||||
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
|
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
||||||
<Text style={styles.entrySub}>
|
<Text style={styles.entrySub}>
|
||||||
{entry.client?.name ?? "No client"}
|
{entry.client?.name ?? "No client"}
|
||||||
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
||||||
@@ -762,41 +710,52 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
runningCard: {
|
runningCard: {
|
||||||
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
|
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
|
||||||
|
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
padding: spacing.md,
|
padding: spacing.lg,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
|
heroRunning: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
heroHeader: {
|
heroHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
pulseDot: {
|
pulseDot: {
|
||||||
width: 8,
|
width: 10,
|
||||||
height: 8,
|
height: 10,
|
||||||
borderRadius: 4,
|
borderRadius: 5,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
},
|
},
|
||||||
heroLabel: {
|
heroLabelRunning: {
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
fontFamily: fonts.bodyMedium,
|
fontFamily: fonts.bodyMedium,
|
||||||
color: colors.mutedForeground,
|
color: colors.primary,
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
},
|
},
|
||||||
timerValue: {
|
timerValue: {
|
||||||
fontSize: 52,
|
fontSize: 56,
|
||||||
lineHeight: 56,
|
lineHeight: 60,
|
||||||
fontFamily: fonts.mono,
|
fontFamily: fonts.mono,
|
||||||
color: colors.foreground,
|
color: colors.primary,
|
||||||
fontVariant: ["tabular-nums"],
|
fontVariant: ["tabular-nums"],
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
runningTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
color: colors.foreground,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
runningMeta: {
|
runningMeta: {
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
color: colors.mutedForeground,
|
color: colors.mutedForeground,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
idleHint: {
|
idleHint: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -805,20 +764,27 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
setupCard: {
|
setupCard: {
|
||||||
padding: spacing.md,
|
padding: spacing.lg,
|
||||||
gap: spacing.lg,
|
gap: spacing.lg,
|
||||||
},
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
formSection: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
titleField: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
minHeight: 48,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
setupSection: {
|
setupSection: {
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
paddingTop: spacing.lg,
|
paddingTop: spacing.lg,
|
||||||
},
|
},
|
||||||
titleInput: {
|
|
||||||
minHeight: 44,
|
|
||||||
textAlignVertical: "center",
|
|
||||||
},
|
|
||||||
titleInputPlaceholder: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
sectionLabel: {
|
sectionLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: fonts.bodySemiBold,
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
View,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
@@ -15,6 +17,7 @@ type ButtonProps = PressableProps & {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
|
showArrow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
@@ -23,6 +26,7 @@ export function Button({
|
|||||||
variant = "primary",
|
variant = "primary",
|
||||||
disabled,
|
disabled,
|
||||||
style,
|
style,
|
||||||
|
showArrow = false,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@@ -68,7 +72,17 @@ export function Button({
|
|||||||
color={variant === "primary" ? colors.primaryForeground : colors.primary}
|
color={variant === "primary" ? colors.primaryForeground : colors.primary}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<View style={styles.content}>
|
||||||
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
|
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
|
||||||
|
{showArrow ? (
|
||||||
|
<Ionicons
|
||||||
|
name="arrow-forward"
|
||||||
|
size={16}
|
||||||
|
color={labelStyles[variant].color}
|
||||||
|
style={styles.arrow}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -76,12 +90,20 @@ export function Button({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
base: {
|
base: {
|
||||||
minHeight: 40,
|
minHeight: 44,
|
||||||
borderRadius: radii.lg,
|
borderRadius: radii.lg,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
},
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
opacity: 0.92,
|
opacity: 0.92,
|
||||||
},
|
},
|
||||||
|
|||||||
+54
-2
@@ -5,6 +5,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
@@ -13,21 +14,46 @@ type InputProps = TextInputProps & {
|
|||||||
label: string;
|
label: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
leftIcon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
labelAccessory?: React.ReactNode;
|
||||||
|
hint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Input({ label, error, required, style, ...props }: InputProps) {
|
export function Input({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
leftIcon,
|
||||||
|
labelAccessory,
|
||||||
|
hint,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrapper}>
|
<View style={styles.wrapper}>
|
||||||
|
<View style={styles.labelRow}>
|
||||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||||
{label}
|
{label}
|
||||||
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||||
</Text>
|
</Text>
|
||||||
|
{labelAccessory}
|
||||||
|
</View>
|
||||||
|
<View style={styles.field}>
|
||||||
|
{leftIcon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={leftIcon}
|
||||||
|
size={16}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
style={styles.leftIcon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={colors.mutedForeground}
|
||||||
style={[
|
style={[
|
||||||
styles.input,
|
styles.input,
|
||||||
|
leftIcon && styles.inputWithIcon,
|
||||||
{
|
{
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
@@ -38,6 +64,10 @@ export function Input({ label, error, required, style, ...props }: InputProps) {
|
|||||||
]}
|
]}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
{hint && !error ? (
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
|
||||||
|
) : null}
|
||||||
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -47,18 +77,40 @@ const styles = StyleSheet.create({
|
|||||||
wrapper: {
|
wrapper: {
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: fonts.bodyMedium,
|
fontFamily: fonts.bodyMedium,
|
||||||
},
|
},
|
||||||
|
field: {
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
leftIcon: {
|
||||||
|
position: "absolute",
|
||||||
|
left: spacing.md,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
minHeight: 40,
|
minHeight: 44,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.md,
|
borderRadius: radii.md,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
},
|
},
|
||||||
|
inputWithIcon: {
|
||||||
|
paddingLeft: spacing.md + 24,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
error: {
|
error: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
setBiometricEnabled,
|
setBiometricEnabled,
|
||||||
setStoredPin,
|
setStoredPin,
|
||||||
} from "@/lib/app-lock";
|
} from "@/lib/app-lock";
|
||||||
import { hasPendingShortcut } from "@/lib/shortcut-queue";
|
import { hasPendingShortcut, subscribeShortcutQueue } from "@/lib/shortcut-queue";
|
||||||
|
|
||||||
type AppLockContextValue = {
|
type AppLockContextValue = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -120,13 +120,29 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
|
|||||||
!biometricUnlockInProgress.current
|
!biometricUnlockInProgress.current
|
||||||
) {
|
) {
|
||||||
wasBackgrounded.current = false;
|
wasBackgrounded.current = false;
|
||||||
|
void hasPendingShortcut().then((shortcutPending) => {
|
||||||
|
if (!shortcutPending) {
|
||||||
setIsLocked(true);
|
setIsLocked(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, [enabled, activeAccountId]);
|
}, [enabled, activeAccountId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
return subscribeShortcutQueue(() => {
|
||||||
|
void hasPendingShortcut().then((shortcutPending) => {
|
||||||
|
if (shortcutPending) {
|
||||||
|
setIsLocked(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
const unlockWithPin = useCallback(
|
const unlockWithPin = useCallback(
|
||||||
async (pin: string) => {
|
async (pin: string) => {
|
||||||
if (!activeAccountId) return false;
|
if (!activeAccountId) return false;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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,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,10 +1,10 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
|
||||||
@available(iOS 18.0, *)
|
|
||||||
struct OpenTimerIntent: AppIntent {
|
struct OpenTimerIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Open Time Clock"
|
static var title: LocalizedStringResource = "Open Time Clock"
|
||||||
static var description = IntentDescription("Open the beenvoice time clock.")
|
static var description = IntentDescription("Open the beenvoice time clock.")
|
||||||
static var openAppWhenRun: Bool = true
|
static var openAppWhenRun: Bool = true
|
||||||
|
static var isDiscoverable: Bool = true
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func perform() async throws -> some IntentResult {
|
func perform() async throws -> some IntentResult {
|
||||||
|
|||||||
+35
-10
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user