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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:48 -04:00
co-authored by Cursor
parent e17c4c6854
commit 6762a9bff3
60 changed files with 2544 additions and 1091 deletions
+2
View File
@@ -4,6 +4,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { ShortcutHandler } from "@/components/ShortcutHandler";
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext";
@@ -74,6 +75,7 @@ export default function AppLayout() {
</NativeTabs.Trigger>
</NativeTabs>
<InvoiceReminderSync />
<TimeClockLiveActivitySync />
<ShortcutHandler />
<AppLockOverlay />
</AppLockProvider>
+66 -118
View File
@@ -1,14 +1,12 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
@@ -24,12 +22,11 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [section, setSection] = useState<InvoiceViewSection>("details");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -45,16 +42,6 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Update failed", err.message),
});
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
Alert.alert("Invoice sent", data.message);
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
onSuccess: () => {
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
@@ -63,6 +50,12 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send reminder", err.message),
});
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
@@ -85,17 +78,12 @@ export default function InvoiceDetailScreen() {
);
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const clientEmail = invoice.client?.email?.trim() ?? "";
const previewInput = useMemo(
() => buildPreviewPdfInputFromInvoice(invoice),
[invoice],
);
function promptSendInvoice() {
function openSendScreen() {
if (!clientEmail) {
Alert.alert(
"No client email",
@@ -103,18 +91,14 @@ export default function InvoiceDetailScreen() {
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice.id }),
},
],
);
if (invoice.items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
router.push(`/(app)/invoices/send/${invoice.id}`);
}
function promptPaymentReminder() {
@@ -159,25 +143,7 @@ export default function InvoiceDetailScreen() {
return (
<AppBackground>
<Stack.Screen
options={{
headerBackTitle: "Invoices",
headerRight: () =>
status !== "paid" ? (
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={promptSendInvoice}
disabled={sendInvoice.isPending}
style={({ pressed }) => pressed && styles.headerPressed}
>
<Text style={[styles.headerAction, { color: colors.primary }]}>
{status === "draft" ? "Send" : "Resend"}
</Text>
</Pressable>
) : null,
}}
/>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
@@ -201,11 +167,12 @@ export default function InvoiceDetailScreen() {
</Text>
</Card>
<InvoiceEditorSectionTabs
value={section}
onChange={setSection}
editLabel="Details"
previewLabel="PDF"
<InvoiceViewChips
section={section}
onSectionChange={setSection}
status={status}
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
onSend={openSendScreen}
/>
{section === "preview" ? (
@@ -215,6 +182,8 @@ export default function InvoiceDetailScreen() {
) : (
<>
<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="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} />
@@ -234,20 +203,27 @@ export default function InvoiceDetailScreen() {
</Card>
<Card title="Line items">
{invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
{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 style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
))}
))
)}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
@@ -264,43 +240,19 @@ export default function InvoiceDetailScreen() {
</Card>
) : null}
<View style={styles.actions}>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
{status === "sent" || status === "overdue" ? (
<Button
title="Send payment reminder"
variant="secondary"
onPress={promptPaymentReminder}
loading={sendPaymentReminder.isPending}
/>
) : null}
<Button
title="Edit invoice"
variant="secondary"
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
/>
<Button
title="Update status"
variant="ghost"
onPress={() => promptStatusChange(status)}
loading={updateStatus.isPending}
/>
<Button
title="Track time to this invoice"
variant="ghost"
onPress={() =>
router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
}
/>
</View>
<InvoiceDetailActions
status={status}
clientEmail={clientEmail}
onPaymentReminder={
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
}
paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending}
onTrackTime={() =>
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
}
/>
</>
)}
</ScrollView>
@@ -398,22 +350,18 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
color: colors.foreground,
fontSize: 14,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
actions: {
gap: spacing.sm,
},
headerAction: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
headerPressed: {
opacity: 0.65,
},
errorBox: {
flex: 1,
justifyContent: "center",
+7
View File
@@ -43,6 +43,13 @@ export default function InvoicesLayout() {
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="send/[id]"
options={{
title: "Send invoice",
headerBackTitle: "Invoice",
}}
/>
<Stack.Screen
name="edit/[id]"
options={{
+173 -145
View File
@@ -16,20 +16,20 @@ import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
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 { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { validateLineItems } from "@/lib/form-validation";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
@@ -47,19 +47,27 @@ export default function InvoiceEditScreen() {
{ id: 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 [dueDate, setDueDate] = useState(() => new Date());
const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const invoice = invoiceQuery.data;
if (!invoice) return;
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
setClientId(invoice.clientId);
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems(
invoice.items.map((item) => ({
@@ -72,6 +80,11 @@ export default function InvoiceEditScreen() {
);
}, [invoiceQuery.data]);
useEffect(() => {
if (businessId || !businessesQuery.data?.length) return;
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
}, [businessId, businessesQuery.data]);
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
@@ -85,19 +98,31 @@ export default function InvoiceEditScreen() {
onError: (err) => setError(err.message),
});
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
Alert.alert("Invoice sent", data.message);
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const invoice = invoiceQuery.data;
const isDraft = invoice?.status === "draft";
const businessOptions = useMemo(
() =>
(businessesQuery.data ?? []).map((business) => ({
label: business.name,
value: business.id,
})),
[businessesQuery.data],
);
const clientOptions = useMemo(
() =>
(clientsQuery.data ?? []).map((client) => ({
label: client.name,
value: client.id,
})),
[clientsQuery.data],
);
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
@@ -108,35 +133,39 @@ export default function InvoiceEditScreen() {
[items],
);
const taxRate = invoice?.taxRate ?? 0;
const taxAmount = subtotal * (taxRate / 100);
const parsedTaxRate = Number(taxRate) || 0;
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
const lineItemsError = isDraft ? validateLineItems(items) : null;
const canSave = isDraft ? !lineItemsError : true;
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
const clientError = isDraft && !clientId ? "Select a client" : undefined;
const canSave = isDraft
? !lineItemsError && !taxError && !businessError && !clientError
: true;
const previewInput = useMemo(() => {
if (!invoice) return null;
return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix,
businessId: invoice.businessId,
clientId: invoice.clientId,
businessId: resolvedBusinessId,
clientId,
issueDate: new Date(invoice.issueDate),
dueDate,
status: invoice.status as "draft" | "sent" | "paid",
notes,
taxRate,
taxRate: parsedTaxRate,
currency,
items,
});
}, [invoice, dueDate, notes, taxRate, currency, items]);
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
@@ -147,28 +176,6 @@ export default function InvoiceEditScreen() {
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
function promptSendInvoice() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
},
],
);
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
@@ -186,10 +193,6 @@ export default function InvoiceEditScreen() {
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
}
@@ -230,6 +233,10 @@ export default function InvoiceEditScreen() {
sendReminderAt,
...(isDraft
? {
businessId: resolvedBusinessId,
clientId,
taxRate: parsedTaxRate,
currency,
items: parsedItems,
}
: {}),
@@ -254,7 +261,9 @@ export default function InvoiceEditScreen() {
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
<Text style={styles.clientName}>
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
</Text>
</View>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
@@ -263,96 +272,120 @@ export default function InvoiceEditScreen() {
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</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>
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
</Text>
) : items.length === 0 ? (
<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) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
</Text>
) : (
<LineItemsTableHeader />
)}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</Card>
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined}
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Button
title="Save changes"
loading={updateInvoice.isPending}
disabled={!canSave}
onPress={handleSave}
/>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
variant="secondary"
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
</View>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
</>
)}
{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>
</KeyboardAvoidingView>
</AppBackground>
@@ -380,23 +413,21 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
fontFamily: fonts.body,
color: colors.mutedForeground,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
lockedHint: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {
@@ -409,7 +440,4 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
fontFamily: fonts.body,
fontSize: 14,
},
actions: {
gap: spacing.sm,
},
});
+13 -1
View File
@@ -169,7 +169,19 @@ export default function InvoicesScreen() {
</TabScrollView>
<FloatingActionButton
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>
</AppBackground>
+161 -125
View File
@@ -1,4 +1,4 @@
import { router, Stack } from "expo-router";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
@@ -16,55 +16,75 @@ import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import {
isRequiredString,
isValidTaxRate,
validateLineItems,
} from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function NewInvoiceScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createNewInvoiceStyles);
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const { blank } = useLocalSearchParams<{ blank?: string }>();
const isBlank = blank === "1" || blank === "true";
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date());
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>([
{
date: new Date(),
description: "",
hours: "1",
rate: "0",
},
]);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [items, setItems] = useState<EditableLineItem[]>(() =>
isBlank
? []
: [
{
date: new Date(),
description: "",
hours: "1",
rate: "0",
},
],
);
const [section, setSection] = useState<InvoiceEditorSection>("setup");
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(
() =>
(clientsQuery.data ?? []).map((client) => ({
@@ -76,6 +96,7 @@ export default function NewInvoiceScreen() {
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
@@ -120,6 +141,7 @@ export default function NewInvoiceScreen() {
() =>
buildPreviewPdfInput({
invoiceNumber,
businessId: resolvedBusinessId,
clientId,
issueDate,
dueDate,
@@ -128,9 +150,20 @@ export default function NewInvoiceScreen() {
notes,
items,
}),
[invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
[
invoiceNumber,
resolvedBusinessId,
clientId,
issueDate,
dueDate,
parsedTaxRate,
currency,
notes,
items,
],
);
const businessError = resolvedBusinessId ? undefined : "Select a business";
const clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
@@ -138,13 +171,15 @@ export default function NewInvoiceScreen() {
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items);
const canCreate =
businessOptions.length > 0 &&
clientOptions.length > 0 &&
!businessError &&
!clientError &&
!invoiceNumberError &&
!taxError &&
!lineItemsError;
if (clientsQuery.isLoading) {
if (businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading…" />;
}
@@ -165,10 +200,6 @@ export default function NewInvoiceScreen() {
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
}
@@ -193,6 +224,7 @@ export default function NewInvoiceScreen() {
}
createInvoice.mutate({
businessId: resolvedBusinessId,
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
@@ -207,7 +239,12 @@ export default function NewInvoiceScreen() {
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<Stack.Screen
options={{
headerBackTitle: "Invoices",
title: isBlank ? "Blank invoice" : "New invoice",
}}
/>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
@@ -224,105 +261,101 @@ export default function NewInvoiceScreen() {
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : section === "setup" ? (
<Card title="Invoice setup">
{clientOptions.length === 0 || businessOptions.length === 0 ? (
<View style={styles.noEntities}>
<Text style={styles.noEntitiesText}>
{businessOptions.length === 0
? "Add a business before creating an invoice."
: "Add a client before creating an invoice."}
</Text>
<Button
title={businessOptions.length === 0 ? "Add business" : "Add client"}
variant="secondary"
onPress={() =>
router.push(
businessOptions.length === 0
? "/(app)/entities/businesses/new"
: "/(app)/entities/clients/new",
)
}
/>
</View>
) : (
<InvoiceSetupForm
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
clientId={clientId}
onClientIdChange={setClientId}
clientOptions={clientOptions}
clientError={clientError}
invoiceNumber={invoiceNumber}
onInvoiceNumberChange={setInvoiceNumber}
issueDate={issueDate}
onIssueDateChange={setIssueDate}
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={setTaxRate}
notes={notes}
onNotesChange={setNotes}
/>
)}
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
{invoiceNumberError ? (
<Text style={styles.error}>{invoiceNumberError}</Text>
) : null}
</Card>
) : (
<>
<Card title="Details">
{clientOptions.length === 0 ? (
<View style={styles.noClients}>
<Text style={styles.noClientsText}>
Add a client before creating an invoice.
</Text>
<Button
title="Add client"
variant="secondary"
onPress={() => router.push("/(app)/entities/clients/new")}
<Card title="Line items">
{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) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</View>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
onValueChange={setClientId}
/>
)}
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
autoCapitalize="characters"
required
error={invoiceNumberError}
/>
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
setIssueDate(date);
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
}}
/>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={setTaxRate}
keyboardType="decimal-pad"
error={taxError}
/>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
</Card>
<Card title="Line items">
<LineItemsTableHeader />
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Create invoice"
loading={createInvoice.isPending}
disabled={!canCreate}
onPress={handleCreate}
/>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
onPrimary={handleCreate}
primaryLoading={createInvoice.isPending}
primaryDisabled={!canCreate}
/>
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
@@ -336,21 +369,24 @@ const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
padding: spacing.md,
gap: spacing.md,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
noClients: {
noEntities: {
gap: spacing.sm,
},
noClientsText: {
noEntitiesText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
lineHeight: 20,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {
+237
View File
@@ -0,0 +1,237 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function InvoiceSendScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createSendStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [customMessage, setCustomMessage] = useState("");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
Alert.alert("Invoice sent", data.message, [
{ text: "OK", onPress: () => router.replace(`/(app)/invoices/${id}`) },
]);
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
return <LoadingScreen message="Invoice not found" />;
}
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
const businessName = invoice.business?.name ?? "Your business";
const sendLabel = status === "draft" ? "Send invoice" : "Resend invoice";
function handleSend() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
if (invoice.items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
sendInvoice.mutate({
invoiceId: invoice!.id,
customMessage: customMessage.trim() || undefined,
});
}
return (
<AppBackground>
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Email summary">
<SummaryRow label="From" value={businessName} />
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
<SummaryRow
label="Invoice"
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
/>
<SummaryRow label="Due" value={formatDate(invoice.dueDate)} />
<SummaryRow
label="Amount"
value={formatCurrency(invoice.totalAmount, invoice.currency)}
bold
/>
</Card>
<Card title="PDF attachment">
<InvoicePdfPreview input={previewInput} height={480} />
</Card>
<Card title="Message">
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
Optional note included in the email body.
</Text>
<Input
label="Personal message"
value={customMessage}
onChangeText={setCustomMessage}
placeholder="Thanks for your business!"
multiline
style={styles.messageInput}
/>
</Card>
<Button
title={sendLabel}
onPress={handleSend}
loading={sendInvoice.isPending}
disabled={!clientEmail || invoice.items.length === 0}
/>
{!clientEmail ? (
<Text style={[styles.warning, { color: colors.destructive }]}>
Add a client email address before sending.
</Text>
) : invoice.items.length === 0 ? (
<Text style={[styles.warning, { color: colors.destructive }]}>
Add line items before sending this invoice.
</Text>
) : null}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
function SummaryRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={summaryStyles.row}>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text
style={[
summaryStyles.value,
{ color: colors.foreground },
bold && summaryStyles.bold,
]}
>
{value}
</Text>
</View>
);
}
const summaryStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: 4,
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
flex: 1,
textAlign: "right",
},
bold: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
const createSendStyles = (colors: ThemeColors) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
messageHint: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
marginBottom: spacing.xs,
},
messageInput: {
minHeight: 96,
textAlignVertical: "top",
},
warning: {
fontFamily: fonts.body,
fontSize: 13,
textAlign: "center",
},
});
+7
View File
@@ -11,6 +11,7 @@ import { InstanceUrlField } from "@/components/InstanceUrlField";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PinPrompt } from "@/components/PinPrompt";
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
@@ -294,6 +295,12 @@ export default function SettingsScreen() {
</Text>
</Card>
{Platform.OS === "ios" ? (
<Card title="Shortcuts & Siri">
<ShortcutsSetupCard />
</Card>
) : null}
<Card title="Security">
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
+105 -138
View File
@@ -1,19 +1,13 @@
import { Link } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { StyleSheet, Text, View } from "react-native";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
@@ -21,7 +15,12 @@ import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
import {
isRequiredString,
isValidEmail,
isValidPassword,
useFieldVisibility,
} from "@/lib/form-validation";
export default function RegisterScreen() {
const authClient = useAuthClient();
@@ -96,141 +95,109 @@ export default function RegisterScreen() {
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Create your account</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Get started today
</Text>
</View>
<AuthScreenLayout>
<AuthCard>
<AuthCardHeader
title="Create your account"
description="Get started with your workspace"
/>
<AuthServerPicker onReadyChange={setServerReady} embedded />
<AuthServerPicker onReadyChange={setServerReady} embedded />
<View style={styles.form}>
<View style={styles.row}>
<View style={styles.half}>
<Input
label="First name"
value={firstName}
onChangeText={setFirstName}
onBlur={() => touch("firstName")}
autoComplete="given-name"
placeholder="Jane"
required
error={visible("firstName") ? firstNameError : undefined}
/>
</View>
<View style={styles.half}>
<Input
label="Last name"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title="Create Account"
loading={loading}
disabled={!canRegister}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Already have an account?{" "}
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
Sign in
</Link>
</Text>
</Card>
<View style={styles.form}>
<View style={styles.row}>
<View style={styles.half}>
<Input
label="First name"
leftIcon="person-outline"
value={firstName}
onChangeText={setFirstName}
onBlur={() => touch("firstName")}
autoComplete="given-name"
placeholder="John"
required
error={visible("firstName") ? firstNameError : undefined}
/>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
<View style={styles.half}>
<Input
label="Last name"
leftIcon="person-outline"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
<Input
label="Email"
leftIcon="mail-outline"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
leftIcon="lock-closed-outline"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
hint="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title={loading ? "Creating account…" : "Create account"}
loading={loading}
disabled={!canRegister}
showArrow={!loading}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Already have an account?{" "}
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
Sign in
</Link>
</Text>
<LegalAgreementNotice action="creating an account" />
</AuthCard>
</AuthScreenLayout>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingVertical: spacing.xl,
form: {
gap: spacing.md,
},
content: {
width: "100%",
maxWidth: 420,
row: {
flexDirection: "row",
gap: spacing.md,
},
card: {
gap: spacing.lg,
half: {
flex: 1,
},
header: {
alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
},
form: { gap: spacing.md },
row: { flexDirection: "row", gap: spacing.md },
half: { flex: 1 },
error: {
fontSize: 14,
fontFamily: fonts.body,
+82 -165
View File
@@ -1,21 +1,16 @@
import { Link, router } from "expo-router";
import * as Linking from "expo-linking";
import { useEffect, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
import { AuthDivider } from "@/components/auth/AuthDivider";
import { AuthNotice } from "@/components/auth/AuthNotice";
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { FullScreen } from "@/components/Screen";
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
@@ -124,177 +119,99 @@ export default function SignInScreen() {
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Welcome back</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Sign in to manage invoices on the go
</Text>
</View>
<AuthScreenLayout>
<AuthCard>
<AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
<AuthServerPicker onReadyChange={setServerReady} embedded />
<AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? (
<Text style={[styles.notice, { color: colors.mutedForeground }]}>
New account registration is currently disabled on this server.
</Text>
) : null}
{signupsDisabled ? (
<AuthNotice>New account registration is currently disabled.</AuthNotice>
) : null}
{authentikEnabled ? (
<View style={styles.ssoSection}>
<Button
title="Sign in with Authentik"
variant="secondary"
loading={loading}
disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()}
/>
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}>
or
</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
</View>
) : null}
{authentikEnabled ? (
<View style={styles.ssoSection}>
<Button
title="Sign in with Authentik"
variant="secondary"
loading={loading}
disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()}
/>
<AuthDivider />
</View>
) : null}
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
/>
<View style={styles.form}>
<Input
label="Email"
leftIcon="mail-outline"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
leftIcon="lock-closed-outline"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
labelAccessory={
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title={loading ? "Signing in…" : "Sign in"}
loading={loading}
disabled={!canSignIn}
showArrow={!loading}
onPress={handleSignIn}
/>
</View>
<Button
title="Sign In"
loading={loading}
disabled={!canSignIn}
onPress={handleSignIn}
/>
</View>
{!signupsDisabled ? (
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create account
</Link>
</Text>
) : null}
{!signupsDisabled ? (
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one
</Link>
</Text>
) : null}
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
<LegalAgreementNotice action="signing in" />
</AuthCard>
</AuthScreenLayout>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
flex: {
flex: 1,
},
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
},
card: {
gap: spacing.lg,
},
header: {
alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
},
notice: {
fontSize: 13,
fontFamily: fonts.body,
textAlign: "center",
lineHeight: 18,
},
ssoSection: {
gap: spacing.md,
},
dividerRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
dividerLine: {
flex: 1,
height: StyleSheet.hairlineWidth,
},
dividerLabel: {
fontSize: 12,
fontFamily: fonts.bodyMedium,
textTransform: "uppercase",
letterSpacing: 0.6,
},
form: {
gap: spacing.md,
},
forgot: {
alignSelf: "flex-end",
fontFamily: fonts.bodyMedium,
fontSize: 12,
},