Polish mobile app for App Store review and expand CRUD.

Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 23:14:58 -04:00
parent 14c880123c
commit 6d2711e36e
41 changed files with 2410 additions and 181 deletions
+28 -2
View File
@@ -1,5 +1,5 @@
import { router, useLocalSearchParams } from "expo-router";
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
@@ -116,6 +116,25 @@ 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,
}}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
@@ -376,6 +395,13 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
actions: {
gap: spacing.sm,
},
headerAction: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
headerPressed: {
opacity: 0.65,
},
errorBox: {
flex: 1,
justifyContent: "center",
+22 -2
View File
@@ -23,13 +23,33 @@ export default function InvoicesLayout() {
<Stack.Screen
name="index"
options={{
title: "Invoices",
headerShown: false,
statusBarTranslucent: true,
contentStyle: { flex: 1, backgroundColor: "transparent" },
}}
/>
<Stack.Screen name="[id]" options={{ title: "Invoice" }} />
<Stack.Screen name="edit/[id]" options={{ title: "Edit" }} />
<Stack.Screen
name="new"
options={{
title: "New invoice",
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="[id]"
options={{
title: "Invoice",
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="edit/[id]"
options={{
title: "Edit invoice",
headerBackTitle: "Invoice",
}}
/>
</Stack>
);
}
+102 -80
View File
@@ -1,4 +1,4 @@
import { router, useLocalSearchParams } from "expo-router";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
@@ -10,7 +10,6 @@ import {
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppBackground } from "@/components/AppBackground";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
@@ -21,8 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
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";
@@ -32,8 +32,6 @@ export default function InvoiceEditScreen() {
const styles = useThemedStyles(createInvoiceEditStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const insets = useSafeAreaInsets();
const tabBarHeight = useNativeTabBarHeight();
const scrollPadding = useTabBarScrollPadding();
const invoiceQuery = api.invoices.getById.useQuery(
@@ -46,7 +44,6 @@ export default function InvoiceEditScreen() {
const [items, setItems] = useState<EditableLineItem[]>([]);
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState(0);
useEffect(() => {
const invoice = invoiceQuery.data;
@@ -77,6 +74,16 @@ 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 subtotal = useMemo(
@@ -106,6 +113,31 @@ export default function InvoiceEditScreen() {
return <LoadingScreen message="Invoice not found" />;
}
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)));
}
@@ -180,17 +212,15 @@ export default function InvoiceEditScreen() {
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding + footerHeight },
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
@@ -245,20 +275,19 @@ export default function InvoiceEditScreen() {
</Card>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
<View
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
style={[
styles.footer,
{
bottom: tabBarHeight,
paddingBottom: Math.max(insets.bottom, spacing.sm),
},
]}
>
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
</View>
<View style={styles.actions}>
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
variant="secondary"
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
@@ -321,58 +350,51 @@ const totalStyles = StyleSheet.create({
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
invoiceNumber: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 6,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
footer: {
position: "absolute",
left: 0,
right: 0,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.cardGlass,
},
});
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
invoiceNumber: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 6,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
actions: {
gap: spacing.sm,
},
});
+6 -1
View File
@@ -12,6 +12,7 @@ import {
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
@@ -127,7 +128,7 @@ export default function InvoicesScreen() {
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No invoices found</Text>
<Text style={styles.emptyText}>
Create invoices on the web app, then view and edit them here.
Tap + to create your first invoice, or pull to refresh.
</Text>
</View>
) : (
@@ -166,6 +167,10 @@ export default function InvoicesScreen() {
})
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel="Create invoice"
onPress={() => router.push("/(app)/invoices/new")}
/>
</TabPage>
</AppBackground>
);
+411
View File
@@ -0,0 +1,411 @@
import { router, Stack } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
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 { 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 clientsQuery = api.clients.getAll.useQuery();
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 [expandedIndex, setExpandedIndex] = useState<number | null>(0);
const [error, setError] = useState<string | null>(null);
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 ?? "USD";
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
setItems((prev) =>
prev.map((item, index) =>
index === 0 && (item.rate === "0" || item.rate === "")
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
: item,
),
);
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
const createInvoice = api.invoices.create.useMutation({
onSuccess: (invoice) => {
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
Alert.alert("Invoice created", "Your draft invoice is ready.", [
{
text: "View invoice",
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
},
]);
},
onError: (err) => setError(err.message),
});
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
return sum + hours * rate;
}, 0),
[items],
);
const parsedTaxRate = Number(taxRate) || 0;
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
if (clientsQuery.isLoading) {
return <LoadingScreen message="Loading…" />;
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
const nextIndex = items.length;
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
setExpandedIndex(nextIndex);
}
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));
setExpandedIndex((current) => {
if (current === null) return null;
if (current === index) return null;
return current > index ? current - 1 : current;
});
}
function handleCreate() {
setError(null);
if (!clientId) {
setError("Select a client");
return;
}
if (!invoiceNumber.trim()) {
setError("Invoice number is required");
return;
}
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
const hours = Number(item.hours);
const rate = Number(item.rate);
if (!item.description.trim()) {
setError("Each line needs a description");
return;
}
if (Number.isNaN(hours) || hours < 0) {
setError("Hours must be a valid number");
return;
}
if (Number.isNaN(rate) || rate < 0) {
setError("Rate must be a valid number");
return;
}
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours,
rate,
});
}
const tax = Number(taxRate);
if (Number.isNaN(tax) || tax < 0 || tax > 100) {
setError("Tax rate must be between 0 and 100");
return;
}
createInvoice.mutate({
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
dueDate,
notes,
taxRate: tax,
currency,
items: parsedItems,
status: "draft",
});
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<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="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")}
/>
</View>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
onValueChange={setClientId}
/>
)}
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
autoCapitalize="characters"
/>
<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"
/>
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items">
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
item={item}
currency={currency}
expanded={expandedIndex === index}
onToggle={() =>
setExpandedIndex((current) => (current === index ? null : index))
}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
{parsedTaxRate > 0 ? (
<TotalRow
label={`Tax (${parsedTaxRate}%)`}
value={formatCurrency(taxAmount, currency)}
/>
) : null}
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
</View>
</Card>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Create invoice"
loading={createInvoice.isPending}
disabled={clientOptions.length === 0}
onPress={handleCreate}
/>
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={totalStyles.row}>
<Text
style={[
totalStyles.label,
{ color: colors.mutedForeground },
bold && totalStyles.bold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
totalStyles.value,
{ color: colors.foreground },
bold && totalStyles.bold,
]}
>
{value}
</Text>
</View>
);
}
const totalStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
bold: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
noClients: {
gap: spacing.sm,
},
noClientsText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
lineHeight: 20,
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 6,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});