Compare commits

...
3 Commits
Author SHA1 Message Date
soconnor 21268ddd39 Refine receipt item expense selection 2026-06-29 01:38:00 -04:00
soconnor 7bc8559b44 Expose mobile More tools 2026-06-29 01:38:00 -04:00
soconnor 8ad4210908 Add mobile expenses and receipt OCR 2026-06-29 01:35:00 -04:00
21 changed files with 2678 additions and 4 deletions
+20 -4
View File
@@ -10,12 +10,14 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"buildNumber": "11", "buildNumber": "12",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.", "NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice." "NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice.",
"NSCameraUsageDescription": "beenvoice uses the camera to scan expense receipts.",
"NSPhotoLibraryUsageDescription": "beenvoice imports receipt photos for expense tracking."
} }
}, },
"android": { "android": {
@@ -28,7 +30,8 @@
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"permissions": [ "permissions": [
"android.permission.USE_BIOMETRIC", "android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT" "android.permission.USE_FINGERPRINT",
"android.permission.CAMERA"
] ]
}, },
"web": { "web": {
@@ -82,7 +85,20 @@
"@react-native-community/datetimepicker", "@react-native-community/datetimepicker",
"./plugins/withAppIntents.js", "./plugins/withAppIntents.js",
"./plugins/withAppStoreSigning.js", "./plugins/withAppStoreSigning.js",
"expo-sharing" "expo-sharing",
[
"expo-image-picker",
{
"photosPermission": "beenvoice imports receipt photos for expense tracking.",
"cameraPermission": "beenvoice uses the camera to scan expense receipts."
}
],
[
"expo-mlkit-ocr",
{
"iosEngine": "auto"
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
+10
View File
@@ -3,6 +3,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 { OnboardingGate } from "@/components/OnboardingGate";
import { ShortcutHandler } from "@/components/ShortcutHandler"; import { ShortcutHandler } from "@/components/ShortcutHandler";
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync"; import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
@@ -73,7 +74,16 @@ export default function AppLayout() {
/> />
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
md="more_horiz"
/>
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs> </NativeTabs>
<OnboardingGate />
<InvoiceReminderSync /> <InvoiceReminderSync />
<TimeClockLiveActivitySync /> <TimeClockLiveActivitySync />
<ShortcutHandler /> <ShortcutHandler />
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
export default function MoreLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
/>
);
}
+415
View File
@@ -0,0 +1,415 @@
import { useLocalSearchParams, router } from "expo-router";
import { useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
ExpenseFormFields,
type ExpenseFormState,
} from "@/components/expenses/ExpenseFormFields";
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
import { api } from "@/lib/trpc";
type ReceiptSplitDraft = Pick<
ReceiptScanResult,
"items" | "subtotal" | "tax" | "total"
>;
export default function ExpenseDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { colors } = useAppTheme();
const utils = api.useUtils();
const [scanning, setScanning] = useState(false);
const [editing, setEditing] = useState(false);
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
null,
);
const [form, setForm] = useState<ExpenseFormState>({
description: "",
amountText: "",
date: new Date(),
category: "",
businessId: "",
clientId: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
});
const expenseQuery = api.expenses.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => void expenseQuery.refetch(),
});
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
onSuccess: () => void expenseQuery.refetch(),
});
const updateExpense = api.expenses.update.useMutation({
onSuccess: async () => {
await utils.expenses.getAll.invalidate();
await expenseQuery.refetch();
setEditing(false);
},
});
const suggest = api.expenses.suggestFromReceiptText.useMutation();
const expense = expenseQuery.data;
const businesses = businessesQuery.data ?? [];
const clients = clientsQuery.data ?? [];
async function attachAndScan(fromCamera: boolean) {
if (!id || !expense) return;
setScanning(true);
try {
const result = await scanReceiptImage(
fromCamera,
{
description: expense.description,
amountText: String(expense.amount),
date: new Date(expense.date),
},
(input) => suggest.mutateAsync(input),
);
if (!result) return;
await uploadReceipt.mutateAsync({
expenseId: id,
filename: result.image.filename,
mimeType: result.image.mimeType,
data: result.image.base64,
});
setForm(
expenseToForm(expense, {
description: result.description,
amountText: result.amountText,
date: result.date,
notes: result.ocrText,
}),
);
setReceiptSplit(
result.items.length > 0
? {
items: result.items,
subtotal: result.subtotal,
tax: result.tax,
total: result.total,
}
: null,
);
setEditing(true);
Alert.alert(
"Receipt attached",
result.items.length > 0
? "Select the owed items, apply the split amount, then save the expense."
: "OCR filled the fields below. Save to update this expense.",
);
} finally {
setScanning(false);
}
}
function handleSaveEdits() {
if (!id) return;
const amount = Number(form.amountText);
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
Alert.alert("Invalid fields", "Description and amount are required.");
return;
}
updateExpense.mutate({
id,
description: form.description.trim(),
amount,
date: form.date,
category: form.category || undefined,
businessId: form.businessId || undefined,
clientId: form.clientId || undefined,
billable: form.billable,
reimbursable: form.reimbursable,
taxDeductible: form.taxDeductible,
notes: form.notes.trim() || undefined,
});
}
function startEditing() {
if (!expense) return;
setForm(expenseToForm(expense));
setReceiptSplit(null);
setEditing(true);
}
if (expenseQuery.isLoading) {
return <LoadingScreen message="Loading expense…" />;
}
if (!expense) {
return (
<AppBackground>
<TabPage>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
Expense not found
</Text>
</TabPage>
</AppBackground>
);
}
return (
<AppBackground>
<TabPage>
<ScrollView contentContainerStyle={styles.body}>
<PageHeader
title={expense.description}
subtitle={formatDate(expense.date)}
/>
{editing ? (
<>
{receiptSplit ? (
<ReceiptItemSelector
items={receiptSplit.items}
subtotal={receiptSplit.subtotal}
tax={receiptSplit.tax}
total={receiptSplit.total}
onApply={(selection) => {
setForm((current) => ({
...current,
amountText: selection.owedTotal.toFixed(2),
notes: mergeNotes(selection.notes, current.notes),
}));
}}
/>
) : null}
<ExpenseFormFields
value={form}
businesses={businesses}
clients={clients}
onChange={setForm}
/>
<Button
title="Save changes"
loading={updateExpense.isPending}
onPress={handleSaveEdits}
/>
<Button
title="Cancel edit"
variant="secondary"
onPress={() => setEditing(false)}
/>
</>
) : (
<>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<View style={styles.metaStack}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
{expense.category || "No category"}
{expense.business?.name ? ` · ${expense.business.name}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.badges}>
{expense.billable ? (
<Text
style={[
styles.badge,
{ color: colors.primary, borderColor: colors.border },
]}
>
Billable
</Text>
) : null}
{expense.reimbursable ? (
<Text
style={[
styles.badge,
{
color: colors.foreground,
borderColor: colors.border,
},
]}
>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text
style={[
styles.badge,
{ color: colors.success, borderColor: colors.border },
]}
>
Tax deductible
</Text>
) : null}
</View>
</View>
{expense.notes ? (
<Text style={{ color: colors.mutedForeground }}>
{expense.notes}
</Text>
) : null}
<Button
title="Edit expense"
variant="secondary"
onPress={startEditing}
/>
</>
)}
<Text style={[styles.section, { color: colors.foreground }]}>
Receipts ({expense.receipts.length})
</Text>
{expense.receipts.map((receipt) => (
<SwipeableRow
key={receipt.id}
actions={[
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
},
]}
>
<Text
style={[styles.receiptRow, { color: colors.mutedForeground }]}
>
{receipt.originalFilename}
</Text>
</SwipeableRow>
))}
<View style={styles.actions}>
<Button
title={scanning ? "Scanning…" : "Scan receipt"}
loading={scanning || uploadReceipt.isPending}
style={styles.actionButton}
onPress={() => void attachAndScan(true)}
/>
<Button
title="Import photo"
variant="secondary"
loading={scanning || uploadReceipt.isPending}
style={styles.actionButton}
onPress={() => void attachAndScan(false)}
/>
</View>
<Button
title="Back"
variant="secondary"
onPress={() => router.back()}
/>
</ScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
},
amount: {
fontSize: 28,
fontWeight: "600",
},
metaStack: {
gap: spacing.sm,
},
meta: {
fontFamily: fonts.body,
fontSize: 14,
},
badges: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
badge: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
fontFamily: fonts.bodyMedium,
fontSize: 12,
},
section: {
fontSize: 16,
fontWeight: "600",
marginTop: spacing.md,
},
receiptRow: {
paddingVertical: spacing.sm,
fontSize: 14,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
actionButton: {
flex: 1,
},
});
function expenseToForm(
expense: {
description: string;
amount: number;
date: Date | string;
category: string | null;
businessId: string | null;
clientId: string | null;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean | null;
notes: string | null;
},
overrides: Partial<ExpenseFormState> = {},
): ExpenseFormState {
return {
description: expense.description,
amountText: String(expense.amount),
date: new Date(expense.date),
category: expense.category ?? "",
businessId: expense.businessId ?? "",
clientId: expense.clientId ?? "",
billable: expense.billable,
reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "",
...overrides,
};
}
function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim();
if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) {
const ocrStart = trimmed.indexOf("\n\nOCR text:");
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
}
return `${prefix}\n\nOCR text:\n${trimmed}`;
}
+351
View File
@@ -0,0 +1,351 @@
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type ExpenseFilter = "all" | "billable" | "receipts";
export default function ExpensesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
const utils = api.useUtils();
const [filter, setFilter] = useState<ExpenseFilter>("all");
const expensesQuery = api.expenses.getAll.useQuery();
const deleteExpense = api.expenses.delete.useMutation({
onSuccess: () => void utils.expenses.getAll.invalidate(),
});
const expenses = expensesQuery.data ?? [];
const filteredExpenses = useMemo(
() =>
expenses.filter((expense) => {
if (filter === "billable") return expense.billable;
if (filter === "receipts") return expense.receiptCount > 0;
return true;
}),
[expenses, filter],
);
const total = filteredExpenses.reduce(
(sum, expense) => sum + expense.amount,
0,
);
const receiptCount = filteredExpenses.reduce(
(sum, expense) => sum + (expense.receiptCount ?? 0),
0,
);
if (expensesQuery.isLoading) {
return <LoadingScreen message="Loading expenses…" />;
}
if (expensesQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
Could not load expenses
</Text>
<Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(expensesQuery.error)}
</Text>
</View>
</TabPage>
</AppBackground>
);
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<>
<PageHeader
title="Expenses"
subtitle={`${expenses.length} recorded`}
/>
<Button
title="Add expense"
onPress={() => router.push("/(app)/more/expenses/new" as never)}
/>
</>
}
refreshControl={
<RefreshControl
refreshing={expensesQuery.isRefetching}
onRefresh={() => void expensesQuery.refetch()}
tintColor={colors.primary}
/>
}
>
{expenses.length === 0 ? (
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
No expenses yet. Add one with a receipt photo or manual entry.
</Text>
) : (
<>
<View style={styles.summary}>
<View>
<Text
style={[
styles.summaryLabel,
{ color: colors.mutedForeground },
]}
>
Visible total
</Text>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
>
{formatCurrency(total)}
</Text>
</View>
<View style={styles.summaryRight}>
<Text
style={[
styles.summaryLabel,
{ color: colors.mutedForeground },
]}
>
Receipts
</Text>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
>
{receiptCount}
</Text>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filters}
>
<FilterChip
label="All"
active={filter === "all"}
onPress={() => setFilter("all")}
/>
<FilterChip
label="Billable"
active={filter === "billable"}
onPress={() => setFilter("billable")}
/>
<FilterChip
label="With receipts"
active={filter === "receipts"}
onPress={() => setFilter("receipts")}
/>
</ScrollView>
{filteredExpenses.length === 0 ? (
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
No expenses match this filter.
</Text>
) : (
filteredExpenses.map((expense) => (
<SwipeableRow
key={expense.id}
actions={[
{
key: "open",
label: "Open",
icon: "open-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () =>
router.push(
`/(app)/more/expenses/${expense.id}` as never,
),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => deleteExpense.mutate({ id: expense.id }),
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() =>
router.push(
`/(app)/more/expenses/${expense.id}` as never,
)
}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description}
</Text>
{expense.receiptCount ? (
<Text
style={[
styles.receiptPill,
{
color: colors.primary,
borderColor: colors.border,
},
]}
>
{expense.receiptCount}
</Text>
) : null}
</View>
<Text
style={[
styles.sub,
{ color: colors.mutedForeground },
]}
numberOfLines={2}
>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name
? ` · ${expense.client.name}`
: ""}
{expense.billable ? " · Billable" : ""}
</Text>
</View>
<Text
style={[styles.amount, { color: colors.foreground }]}
>
{formatCurrency(expense.amount, expense.currency)}
</Text>
</Pressable>
</SwipeableRow>
))
)}
</>
)}
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
rowPressed: {
opacity: 0.82,
},
meta: {
flex: 1,
gap: 2,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
title: {
flex: 1,
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
sub: {
fontFamily: fonts.body,
fontSize: 13,
},
amount: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
fontVariant: ["tabular-nums"],
},
summary: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
padding: spacing.md,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
summaryRight: {
alignItems: "flex-end",
},
summaryLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
textTransform: "uppercase",
},
summaryValue: {
marginTop: 2,
fontFamily: fonts.bodySemiBold,
fontSize: 20,
fontVariant: ["tabular-nums"],
},
filters: {
gap: spacing.sm,
paddingRight: spacing.lg,
},
receiptPill: {
minWidth: 26,
textAlign: "center",
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
fontFamily: fonts.bodyMedium,
fontSize: 12,
overflow: "hidden",
},
empty: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
errorBox: {
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
},
});
+256
View File
@@ -0,0 +1,256 @@
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
defaultExpenseFormState,
ExpenseFormFields,
type ExpenseFormState,
} from "@/components/expenses/ExpenseFormFields";
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { Button } from "@/components/ui/Button";
import { spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { mlKitOcrAvailable } from "@/lib/receipt-ocr";
import {
scanReceiptImage,
type PickedReceiptImage,
type ReceiptScanResult,
} from "@/lib/receipt-scan";
import { api } from "@/lib/trpc";
type ReceiptSplitDraft = Pick<
ReceiptScanResult,
"items" | "subtotal" | "tax" | "total"
>;
export default function NewExpenseScreen() {
const { colors } = useAppTheme();
const utils = api.useUtils();
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const businesses = businessesQuery.data ?? [];
const clients = clientsQuery.data ?? [];
const defaultBusinessId = useMemo(
() =>
businesses.find((business) => business.isDefault)?.id ??
businesses[0]?.id ??
"",
[businesses],
);
const [form, setForm] = useState<ExpenseFormState>(() =>
defaultExpenseFormState(),
);
const [scanning, setScanning] = useState(false);
const [pendingReceipt, setPendingReceipt] =
useState<PickedReceiptImage | null>(null);
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
null,
);
const createExpense = api.expenses.create.useMutation();
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
const suggest = api.expenses.suggestFromReceiptText.useMutation();
async function runScan(fromCamera: boolean) {
setScanning(true);
try {
const result = await scanReceiptImage(
fromCamera,
{
description: form.description,
amountText: form.amountText,
date: form.date,
},
(input) => suggest.mutateAsync(input),
);
if (!result) return;
setForm((current) => ({
...current,
description: result.description,
amountText: result.amountText,
date: result.date,
notes: result.ocrText,
businessId: current.businessId || defaultBusinessId,
}));
setPendingReceipt(result.image);
setReceiptSplit(
result.items.length > 0
? {
items: result.items,
subtotal: result.subtotal,
tax: result.tax,
total: result.total,
}
: null,
);
Alert.alert(
"Receipt scanned",
result.items.length > 0
? "Select the items this person owes, then apply the split amount."
: result.amountText
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
: "Review the fields — OCR could not find a total automatically.",
);
} finally {
setScanning(false);
}
}
async function handleSave() {
const amount = Number(form.amountText);
if (!form.description.trim()) {
Alert.alert("Description required", "Enter what this expense was for.");
return;
}
if (!Number.isFinite(amount) || amount <= 0) {
Alert.alert("Amount required", "Enter a valid amount.");
return;
}
try {
const expense = await createExpense.mutateAsync({
description: form.description.trim(),
amount,
date: form.date,
currency: "USD",
category: form.category || undefined,
clientId: form.clientId || undefined,
businessId: form.businessId || defaultBusinessId || undefined,
billable: form.billable,
reimbursable: form.reimbursable,
taxDeductible: form.taxDeductible,
notes: form.notes.trim() || undefined,
});
if (pendingReceipt) {
await uploadReceipt.mutateAsync({
expenseId: expense.id,
filename: pendingReceipt.filename,
mimeType: pendingReceipt.mimeType,
data: pendingReceipt.base64,
});
}
await utils.expenses.getAll.invalidate();
router.replace(`/(app)/more/expenses/${expense.id}` as never);
} catch (err) {
Alert.alert(
"Could not save expense",
err instanceof Error ? err.message : "Try again.",
);
}
}
return (
<AppBackground>
<TabPage>
<ScrollView
contentContainerStyle={styles.body}
keyboardShouldPersistTaps="handled"
>
<PageHeader
title="New expense"
subtitle={
mlKitOcrAvailable()
? "Scan a receipt with on-device ML Kit OCR"
: "Manual entry (OCR unavailable on this device)"
}
/>
<View style={styles.actions}>
<Button
title={scanning ? "Scanning…" : "Scan receipt"}
variant="secondary"
loading={scanning}
style={styles.actionButton}
onPress={() => void runScan(true)}
/>
<Button
title="Import photo"
variant="secondary"
loading={scanning}
style={styles.actionButton}
onPress={() => void runScan(false)}
/>
</View>
{pendingReceipt ? (
<Text style={{ color: colors.success, fontSize: 13 }}>
Receipt image ready it will attach when you save.
</Text>
) : null}
{receiptSplit ? (
<ReceiptItemSelector
items={receiptSplit.items}
subtotal={receiptSplit.subtotal}
tax={receiptSplit.tax}
total={receiptSplit.total}
onApply={(selection) => {
setForm((current) => ({
...current,
amountText: selection.owedTotal.toFixed(2),
notes: mergeNotes(selection.notes, current.notes),
}));
}}
/>
) : null}
<ExpenseFormFields
value={{
...form,
businessId: form.businessId || defaultBusinessId,
}}
businesses={businesses}
clients={clients}
onChange={setForm}
notesLabel="Notes"
notesPlaceholder="Receipt OCR text or internal notes"
/>
<Button
title="Save expense"
loading={createExpense.isPending}
onPress={() => void handleSave()}
/>
<Button
title="Cancel"
variant="secondary"
onPress={() => router.back()}
/>
</ScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
actionButton: {
flex: 1,
},
});
function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim();
if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) {
const ocrStart = trimmed.indexOf("\n\nOCR text:");
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
}
return `${prefix}\n\nOCR text:\n${trimmed}`;
}
+119
View File
@@ -0,0 +1,119 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
type HubItem = {
title: string;
subtitle: string;
href: string;
icon: keyof typeof Ionicons.glyphMap;
};
const ITEMS: HubItem[] = [
{
title: "Expenses",
subtitle: "Track costs and attach receipts",
href: "/(app)/more/expenses",
icon: "receipt-outline",
},
{
title: "Reports",
subtitle: "Revenue, hours, and tax summaries",
href: "/(app)/more/reports",
icon: "bar-chart-outline",
},
{
title: "Recurring invoices",
subtitle: "Scheduled billing templates",
href: "/(app)/more/recurring",
icon: "repeat-outline",
},
{
title: "Time entries",
subtitle: "Full history with edit and delete",
href: "/(app)/more/time-entries",
icon: "time-outline",
},
];
export default function MoreScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
return (
<AppBackground>
<TabPage>
<TabScrollView header={<PageHeader title="More" subtitle="Additional tools" />}>
<View style={styles.list}>
{ITEMS.map((item) => (
<Pressable
key={item.href}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push(item.href as never)}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={item.icon} size={22} color={colors.primary} />
</View>
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{item.title}</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
{item.subtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
))}
</View>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
list: {
gap: spacing.sm,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.md,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
},
rowPressed: {
opacity: 0.85,
},
iconWrap: {
width: 44,
height: 44,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
copy: {
flex: 1,
gap: 2,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
subtitle: {
fontFamily: fonts.body,
fontSize: 13,
},
});
+124
View File
@@ -0,0 +1,124 @@
import { RefreshControl, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { api } from "@/lib/trpc";
export default function RecurringScreen() {
const { colors } = useAppTheme();
const utils = api.useUtils();
const query = api.recurringInvoices.getAll.useQuery();
const pause = api.recurringInvoices.pause.useMutation({
onSuccess: () => void query.refetch(),
});
const resume = api.recurringInvoices.resume.useMutation({
onSuccess: () => void query.refetch(),
});
const generateNow = api.recurringInvoices.generateNow.useMutation({
onSuccess: () => {
void utils.invoices.getAll.invalidate();
void query.refetch();
},
});
if (query.isLoading) {
return <LoadingScreen message="Loading recurring invoices…" />;
}
const items = query.data ?? [];
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader
title="Recurring"
subtitle={`${items.length} schedule${items.length === 1 ? "" : "s"}`}
/>
}
refreshControl={
<RefreshControl
refreshing={query.isRefetching}
onRefresh={() => void query.refetch()}
tintColor={colors.primary}
/>
}
>
{items.length === 0 ? (
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
No recurring invoices yet. Create them on the web dashboard for now.
</Text>
) : (
items.map((item) => (
<SwipeableRow
key={item.id}
actions={[
{
key: "generate",
label: "Run",
icon: "play-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => generateNow.mutate({ id: item.id }),
},
{
key: "toggle",
label: item.status === "active" ? "Pause" : "Resume",
icon: item.status === "active" ? "pause-outline" : "play-outline",
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () =>
item.status === "active"
? pause.mutate({ id: item.id })
: resume.mutate({ id: item.id }),
},
]}
>
<View style={styles.row}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.title, { color: colors.foreground }]}>{item.name}</Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
{item.client?.name ?? "Client"} · {item.schedule} · {item.status}
</Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
Next due {formatDate(item.nextDueAt)}
</Text>
</View>
<Text style={[styles.title, { color: colors.foreground }]}>
{formatCurrency(
item.items.reduce((sum, line) => sum + line.hours * line.rate, 0),
item.currency,
)}
</Text>
</View>
</SwipeableRow>
))
)}
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: "rgba(0,0,0,0.08)",
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
+108
View File
@@ -0,0 +1,108 @@
import { RefreshControl, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { StatCard } from "@/components/StatCard";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Card } from "@/components/ui/Card";
import { spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
export default function ReportsScreen() {
const { colors } = useAppTheme();
const statsQuery = api.dashboard.getStats.useQuery();
const expensesQuery = api.expenses.getAll.useQuery();
const summaryQuery = api.timeEntries.getSummary.useQuery();
if (statsQuery.isLoading || expensesQuery.isLoading || summaryQuery.isLoading) {
return <LoadingScreen message="Loading reports…" />;
}
if (statsQuery.error) {
return (
<AppBackground>
<TabPage>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
{formatTrpcErrorMessage(statsQuery.error)}
</Text>
</TabPage>
</AppBackground>
);
}
const stats = statsQuery.data!;
const expenseTotal = (expensesQuery.data ?? []).reduce((sum, e) => sum + e.amount, 0);
const summary = summaryQuery.data;
return (
<AppBackground>
<TabPage>
<TabScrollView
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void expensesQuery.refetch();
void summaryQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
<View style={styles.grid}>
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
<StatCard label="Expenses" value={formatCurrency(expenseTotal)} />
<StatCard
label="Billable hours"
value={summary ? summary.totalHours.toFixed(1) : "0"}
hint={summary ? `${summary.count} entries` : undefined}
/>
</View>
<Card title="Invoice status">
{(stats.statusChartData ?? []).map((item) => (
<View key={item.status} style={styles.statusRow}>
<Text style={{ color: colors.foreground }}>{item.name}</Text>
<Text style={{ color: colors.mutedForeground }}>
{item.count} · {formatCurrency(item.value)}
</Text>
</View>
))}
</Card>
<Card title="Revenue trend (6 mo)">
{stats.revenueChartData.map((point) => (
<View key={point.month} style={styles.statusRow}>
<Text style={{ color: colors.foreground }}>{point.monthLabel}</Text>
<Text style={{ color: colors.mutedForeground }}>
{formatCurrency(point.revenue)}
</Text>
</View>
))}
</Card>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.md,
},
statusRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: spacing.sm,
},
});
+145
View File
@@ -0,0 +1,145 @@
import { useMemo, useState } from "react";
import { RefreshControl, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatRunningTimerLabel } from "@/lib/time-clock";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
function groupByDate(entries: TimeEntry[]) {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const key = d.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
const list = groups.get(key) ?? [];
list.push(entry);
groups.set(key, list);
}
return Array.from(groups.entries());
}
export default function TimeEntriesScreen() {
const { colors } = useAppTheme();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const entriesQuery = api.timeEntries.getAll.useQuery();
const completed = useMemo(
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
[entriesQuery.data],
);
const grouped = useMemo(() => groupByDate(completed), [completed]);
if (entriesQuery.isLoading) {
return <LoadingScreen message="Loading time entries…" />;
}
if (entriesQuery.error) {
return (
<AppBackground>
<TabPage>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
{formatTrpcErrorMessage(entriesQuery.error)}
</Text>
</TabPage>
</AppBackground>
);
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
}
refreshControl={
<RefreshControl
refreshing={entriesQuery.isRefetching}
onRefresh={() => void entriesQuery.refetch()}
tintColor={colors.primary}
/>
}
>
{grouped.length === 0 ? (
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
No completed entries yet. Start the timer from the Timer tab.
</Text>
) : (
grouped.map(([label, entries]) => (
<Card key={label} title={label}>
{entries.map((entry) => (
<SwipeableRow
key={entry.id}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => setEditEntryId(entry.id),
},
]}
>
<View style={styles.row}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.title, { color: colors.foreground }]}>
{formatRunningTimerLabel(entry.description)}
</Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: " · not billed"}
</Text>
</View>
<Text style={[styles.title, { color: colors.foreground }]}>
{entry.hours ?? "—"}h
</Text>
</View>
</SwipeableRow>
))}
</Card>
))
)}
</TabScrollView>
</TabPage>
<TimeEntryEditSheet
entryId={editEntryId}
visible={editEntryId != null}
onClose={() => setEditEntryId(null)}
/>
</AppBackground>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.md,
paddingVertical: spacing.sm,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
+139
View File
@@ -0,0 +1,139 @@
import { router } from "expo-router";
import { useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { api } from "@/lib/trpc";
export default function OnboardingScreen() {
const { colors } = useAppTheme();
const utils = api.useUtils();
const statusQuery = api.settings.getOnboardingStatus.useQuery();
const [businessName, setBusinessName] = useState("");
const [clientName, setClientName] = useState("");
const [step, setStep] = useState(0);
const createBusiness = api.businesses.create.useMutation();
const createClient = api.clients.create.useMutation();
const complete = api.settings.completeOnboarding.useMutation({
onSuccess: async () => {
await utils.settings.getOnboardingStatus.invalidate();
await utils.settings.getProfile.invalidate();
router.replace("/(app)");
},
});
async function finish() {
if (businessName.trim()) {
await createBusiness.mutateAsync({
name: businessName.trim(),
isDefault: true,
});
}
if (clientName.trim()) {
await createClient.mutateAsync({
name: clientName.trim(),
currency: "USD",
});
}
await complete.mutateAsync();
}
const steps = [
{
title: "Welcome to beenvoice",
body: "Set up your workspace in a minute — business profile and first client.",
},
{
title: "Your business",
body: "This appears on invoices you send to clients.",
},
{
title: "First client",
body: "Add someone you bill. You can skip and add clients later.",
},
];
const current = steps[step]!;
return (
<AppBackground>
<ScrollView contentContainerStyle={styles.body}>
<Text style={[styles.kicker, { color: colors.mutedForeground }]}>
Step {step + 1} of {steps.length}
</Text>
<Text style={[styles.title, { color: colors.foreground }]}>{current.title}</Text>
<Text style={[styles.bodyText, { color: colors.mutedForeground }]}>{current.body}</Text>
{step === 1 ? (
<Input
label="Business name"
value={businessName}
onChangeText={setBusinessName}
placeholder="Your studio or company"
/>
) : null}
{step === 2 ? (
<Input
label="Client name"
value={clientName}
onChangeText={setClientName}
placeholder="Acme Corp"
/>
) : null}
<View style={styles.actions}>
{step > 0 ? (
<Button title="Back" variant="secondary" onPress={() => setStep((s) => s - 1)} />
) : null}
{step < steps.length - 1 ? (
<Button title="Continue" onPress={() => setStep((s) => s + 1)} />
) : (
<Button
title={complete.isPending ? "Finishing…" : "Go to dashboard"}
loading={complete.isPending}
onPress={() => void finish()}
/>
)}
</View>
{statusQuery.data && !statusQuery.data.completed ? (
<Button title="Skip for now" variant="secondary" onPress={() => void complete.mutateAsync()} />
) : null}
</ScrollView>
</AppBackground>
);
}
const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
minHeight: "100%",
justifyContent: "center",
},
kicker: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
title: {
fontFamily: fonts.heading,
fontSize: 32,
lineHeight: 36,
},
bodyText: {
fontFamily: fonts.body,
fontSize: 16,
lineHeight: 24,
},
actions: {
gap: spacing.sm,
marginTop: spacing.lg,
},
});
+8
View File
@@ -25,9 +25,11 @@
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-font": "~56.0.7", "expo-font": "~56.0.7",
"expo-image": "^56.0.11", "expo-image": "^56.0.11",
"expo-image-picker": "~56.0.18",
"expo-linear-gradient": "~56.0.4", "expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.14", "expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4", "expo-local-authentication": "~56.0.4",
"expo-mlkit-ocr": "^0.2.7",
"expo-network": "^56.0.5", "expo-network": "^56.0.5",
"expo-notifications": "^56.0.18", "expo-notifications": "^56.0.18",
"expo-router": "~56.2.11", "expo-router": "~56.2.11",
@@ -696,6 +698,10 @@
"expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="], "expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
"expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="],
"expo-image-picker": ["expo-image-picker@56.0.18", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg=="],
"expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="], "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="], "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
@@ -708,6 +714,8 @@
"expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="], "expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
"expo-mlkit-ocr": ["expo-mlkit-ocr@0.2.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-zx+6rZPwfzGc2ck+tKLFiHwkezbFJqt+Xrz3P5Q0dUAxgIIwwIXtXCjMReRQitnC/vfBGLgh26gO2slEGXSuLQ=="],
"expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="], "expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
"expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="], "expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
+19
View File
@@ -0,0 +1,19 @@
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { api } from "@/lib/trpc";
/** Redirect new users into onboarding until they complete setup. */
export function OnboardingGate() {
const statusQuery = api.settings.getOnboardingStatus.useQuery();
const redirected = useRef(false);
useEffect(() => {
if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
if (statusQuery.data.completed) return;
redirected.current = true;
router.push("/(app)/onboarding" as never);
}, [statusQuery.data, statusQuery.isLoading]);
return null;
}
+92
View File
@@ -0,0 +1,92 @@
import { Ionicons } from "@expo/vector-icons";
import { ReactNode, useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Swipeable, {
type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
export type SwipeAction = {
key: string;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
backgroundColor: string;
onPress: () => void;
};
type SwipeableRowProps = {
children: ReactNode;
actions: SwipeAction[];
enabled?: boolean;
backgroundColor?: string;
};
export function SwipeableRow({
children,
actions,
enabled = true,
backgroundColor,
}: SwipeableRowProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createSwipeableRowStyles);
const rowBackground = backgroundColor ?? colors.background;
const swipeRef = useRef<SwipeableMethods>(null);
function renderRightActions() {
return (
<View style={styles.actions}>
{actions.map((action) => (
<Pressable
key={action.key}
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
onPress={() => {
swipeRef.current?.close();
action.onPress();
}}
>
<Ionicons name={action.icon} size={20} color={action.color} />
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
</Pressable>
))}
</View>
);
}
if (!enabled || actions.length === 0) {
return <View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>;
}
return (
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
<View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>
</Swipeable>
);
}
const createSwipeableRowStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: {
backgroundColor: colors.background,
},
actions: {
flexDirection: "row",
alignItems: "stretch",
},
actionButton: {
width: 80,
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
borderRadius: radii.md,
marginLeft: spacing.xs,
},
actionLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 11,
},
});
+219
View File
@@ -0,0 +1,219 @@
import { Switch, StyleSheet, Text, View } from "react-native";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
const NONE = "__none__";
export type ExpenseFormState = {
description: string;
amountText: string;
date: Date;
category: string;
businessId: string;
clientId: string;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean;
notes: string;
};
type ExpenseFormFieldsProps = {
value: ExpenseFormState;
businesses: Array<{ id: string; name: string; isDefault?: boolean | null }>;
clients: Array<{ id: string; name: string }>;
onChange: (value: ExpenseFormState) => void;
notesLabel?: string;
notesPlaceholder?: string;
};
export function defaultExpenseFormState(
defaultBusinessId = "",
): ExpenseFormState {
return {
description: "",
amountText: "",
date: new Date(),
category: "",
businessId: defaultBusinessId,
clientId: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
};
}
export function ExpenseFormFields({
value,
businesses,
clients,
onChange,
notesLabel = "Notes",
notesPlaceholder = "Internal details or receipt OCR text",
}: ExpenseFormFieldsProps) {
const { colors } = useAppTheme();
const businessOptions: SelectOption[] = [
{ label: "Default business", value: NONE },
...businesses.map((business) => ({
label: business.isDefault ? `${business.name} (default)` : business.name,
value: business.id,
})),
];
const clientOptions: SelectOption[] = [
{ label: "No client", value: NONE },
...clients.map((client) => ({ label: client.name, value: client.id })),
];
const categoryOptions: SelectOption[] = [
{ label: "No category", value: NONE },
...EXPENSE_CATEGORIES.map((category) => ({
label: category,
value: category,
})),
];
const setField = <K extends keyof ExpenseFormState>(
field: K,
nextValue: ExpenseFormState[K],
) => onChange({ ...value, [field]: nextValue });
return (
<View style={styles.stack}>
<Input
label="Description"
required
value={value.description}
onChangeText={(text) => setField("description", text)}
placeholder="e.g. Client lunch"
/>
<Input
label="Amount"
required
value={value.amountText}
onChangeText={(text) => setField("amountText", text)}
keyboardType="decimal-pad"
placeholder="0.00"
/>
<DateTimeField
label="Date"
value={value.date}
onChange={(date) => setField("date", date)}
mode="date"
/>
<SelectField
label="Category"
placeholder="No category"
value={value.category || NONE}
options={categoryOptions}
onValueChange={(next) =>
setField("category", next === NONE ? "" : next)
}
/>
<SelectField
label="Business"
placeholder="Default business"
value={value.businessId || NONE}
options={businessOptions}
onValueChange={(next) =>
setField("businessId", next === NONE ? "" : next)
}
/>
<SelectField
label="Client"
placeholder="No client"
value={value.clientId || NONE}
options={clientOptions}
onValueChange={(next) =>
setField("clientId", next === NONE ? "" : next)
}
/>
<View style={styles.flags}>
<FlagSwitch
label="Billable"
value={value.billable}
onValueChange={(next) => setField("billable", next)}
/>
<FlagSwitch
label="Reimbursable"
value={value.reimbursable}
onValueChange={(next) => setField("reimbursable", next)}
/>
<FlagSwitch
label="Tax deductible"
value={value.taxDeductible}
onValueChange={(next) => setField("taxDeductible", next)}
/>
</View>
<Input
label={notesLabel}
value={value.notes}
onChangeText={(text) => setField("notes", text)}
placeholder={notesPlaceholder}
multiline
style={styles.notesInput}
/>
</View>
);
function FlagSwitch({
label,
value: checked,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) {
return (
<View style={[styles.flagRow, { borderColor: colors.borderGlass }]}>
<Text style={[styles.flagLabel, { color: colors.foreground }]}>
{label}
</Text>
<Switch
value={checked}
onValueChange={onValueChange}
trackColor={{
true: colors.switchTrackOn,
false: colors.switchTrackOff,
}}
thumbColor={colors.switchThumb}
ios_backgroundColor={colors.switchIosBackground}
/>
</View>
);
}
}
const styles = StyleSheet.create({
stack: {
gap: spacing.md,
},
flags: {
gap: spacing.sm,
},
flagRow: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
flagLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
notesInput: {
minHeight: 96,
textAlignVertical: "top",
paddingTop: spacing.md,
},
});
+315
View File
@@ -0,0 +1,315 @@
import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import type { ReceiptLineItem } from "@/lib/receipt-parse";
export type ReceiptSplitSelection = {
selectedItemIds: string[];
selectedSubtotal: number;
allocatedTax: number;
owedTotal: number;
notes: string;
};
type ReceiptItemSelectorProps = {
items: ReceiptLineItem[];
subtotal: number | null;
tax: number | null;
total: number | null;
onApply: (selection: ReceiptSplitSelection) => void;
};
export function ReceiptItemSelector({
items,
subtotal,
tax,
total,
onApply,
}: ReceiptItemSelectorProps) {
const { colors } = useAppTheme();
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(items.length === 1 ? [items[0]!.id] : []),
);
const calculation = useMemo(() => {
const selected = items.filter((item) => selectedIds.has(item.id));
const selectedSubtotal = roundMoney(
selected.reduce((sum, item) => sum + item.amount, 0),
);
const receiptSubtotal =
subtotal && subtotal > 0
? subtotal
: roundMoney(items.reduce((sum, item) => sum + item.amount, 0));
const knownTax =
tax ??
(total && receiptSubtotal > 0
? Math.max(0, roundMoney(total - receiptSubtotal))
: 0);
const allocatedTax =
receiptSubtotal > 0
? roundMoney(knownTax * (selectedSubtotal / receiptSubtotal))
: 0;
const owedTotal = roundMoney(selectedSubtotal + allocatedTax);
const notes = [
"Receipt split",
...selected.map(
(item) => `- ${item.name}: ${formatCurrency(item.amount)}`,
),
`Selected subtotal: ${formatCurrency(selectedSubtotal)}`,
`Allocated tax: ${formatCurrency(allocatedTax)}`,
`Owed total: ${formatCurrency(owedTotal)}`,
].join("\n");
return { selected, selectedSubtotal, allocatedTax, owedTotal, notes };
}, [items, selectedIds, subtotal, tax, total]);
if (items.length === 0) return null;
const toggle = (id: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<View style={[styles.wrap, { borderColor: colors.border }]}>
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={[styles.title, { color: colors.foreground }]}>
Select expense items
</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Pick only the receipt lines that should become this expense. Tax is
split proportionally.
</Text>
</View>
<Text style={[styles.total, { color: colors.foreground }]}>
{formatCurrency(calculation.owedTotal)}
</Text>
</View>
{items.length > 1 ? (
<View style={styles.bulkActions}>
<Pressable
accessibilityRole="button"
onPress={() =>
setSelectedIds(new Set(items.map((item) => item.id)))
}
style={({ pressed }) => [
styles.bulkAction,
pressed && styles.pressed,
]}
>
<Text style={[styles.bulkActionText, { color: colors.primary }]}>
Select all
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => setSelectedIds(new Set())}
style={({ pressed }) => [
styles.bulkAction,
pressed && styles.pressed,
]}
>
<Text
style={[styles.bulkActionText, { color: colors.mutedForeground }]}
>
Clear
</Text>
</Pressable>
</View>
) : null}
<View style={styles.itemList}>
{items.map((item) => {
const selected = selectedIds.has(item.id);
return (
<Pressable
key={item.id}
accessibilityRole="checkbox"
accessibilityState={{ checked: selected }}
onPress={() => toggle(item.id)}
style={({ pressed }) => [
styles.item,
{ borderColor: colors.borderGlass },
selected && { backgroundColor: colors.muted },
pressed && styles.pressed,
]}
>
<Ionicons
name={selected ? "checkmark-circle" : "ellipse-outline"}
size={21}
color={selected ? colors.primary : colors.mutedForeground}
/>
<Text
style={[styles.itemName, { color: colors.foreground }]}
numberOfLines={2}
>
{item.name}
</Text>
<Text style={[styles.itemAmount, { color: colors.foreground }]}>
{formatCurrency(item.amount)}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.summary}>
<SummaryRow label="Items" value={calculation.selectedSubtotal} />
<SummaryRow label="Tax" value={calculation.allocatedTax} />
<SummaryRow label="Owed" value={calculation.owedTotal} strong />
</View>
<Button
title={
calculation.selected.length === 0
? "Select an item to apply"
: "Apply selected items"
}
disabled={calculation.selected.length === 0}
onPress={() =>
onApply({
selectedItemIds: calculation.selected.map((item) => item.id),
selectedSubtotal: calculation.selectedSubtotal,
allocatedTax: calculation.allocatedTax,
owedTotal: calculation.owedTotal,
notes: calculation.notes,
})
}
/>
</View>
);
function SummaryRow({
label,
value,
strong,
}: {
label: string;
value: number;
strong?: boolean;
}) {
return (
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text
style={[
styles.summaryValue,
{ color: colors.foreground },
strong && styles.summaryValueStrong,
]}
>
{formatCurrency(value)}
</Text>
</View>
);
}
}
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
const styles = StyleSheet.create({
wrap: {
gap: spacing.md,
borderWidth: 1,
borderRadius: 16,
padding: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: spacing.md,
},
headerCopy: {
flex: 1,
gap: 2,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
subtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
total: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
fontVariant: ["tabular-nums"],
},
itemList: {
gap: spacing.sm,
},
bulkActions: {
flexDirection: "row",
gap: spacing.sm,
},
bulkAction: {
minHeight: 34,
justifyContent: "center",
},
bulkActionText: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
item: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pressed: {
opacity: 0.85,
},
itemName: {
flex: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
itemAmount: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
fontVariant: ["tabular-nums"],
},
summary: {
gap: spacing.xs,
},
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
},
summaryLabel: {
fontFamily: fonts.body,
fontSize: 13,
},
summaryValue: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
fontVariant: ["tabular-nums"],
},
summaryValueStrong: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
+11
View File
@@ -0,0 +1,11 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+50
View File
@@ -0,0 +1,50 @@
import { isSupported, recognizeText } from "expo-mlkit-ocr";
import { parseReceiptText, type ReceiptParseResult } from "@/lib/receipt-parse";
export type ReceiptOcrResult = ReceiptParseResult & {
rawText: string;
};
export function mlKitOcrAvailable(): boolean {
try {
return isSupported();
} catch {
return false;
}
}
function normalizeImageUri(uri: string): string {
if (uri.startsWith("file://") || uri.startsWith("content://")) {
return uri;
}
return `file://${uri}`;
}
/** Run on-device ML Kit OCR and parse receipt fields from recognized text. */
export async function recognizeReceiptFromImage(uri: string): Promise<ReceiptOcrResult> {
if (!mlKitOcrAvailable()) {
throw new Error("On-device OCR is not supported on this device.");
}
const result = await recognizeText(normalizeImageUri(uri));
const rawText = result.text?.trim() ?? "";
const parsed = parseReceiptText(rawText);
return {
...parsed,
rawText,
};
}
export function applyReceiptOcrToForm(
ocr: ReceiptParseResult,
current: { description: string; amountText: string; date: Date },
): { description: string; amountText: string; date: Date; ocrText: string } {
return {
description: ocr.vendor?.trim() || current.description,
amountText: ocr.amount != null ? String(ocr.amount) : current.amountText,
date: ocr.date ?? current.date,
ocrText: ocr.rawLines.join("\n"),
};
}
+128
View File
@@ -0,0 +1,128 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
subtotal: number | null;
tax: number | null;
vendor: string | null;
items: ReceiptLineItem[];
rawLines: string[];
};
export type ReceiptLineItem = {
id: string;
name: string;
amount: number;
rawLine: string;
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
const SUBTOTAL_PATTERNS = [
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const TAX_PATTERNS = [
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const NON_ITEM_LINE =
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseFirstMatchingAmount(
text: string,
patterns: RegExp[],
): number | null {
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value >= 0) return value;
}
return null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
function parseLineItems(lines: string[]): ReceiptLineItem[] {
const items: ReceiptLineItem[] = [];
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/\s+/g, " ").trim();
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
if (!match?.[1] || !match[2]) continue;
const amount = Number(match[2].replace(/,/g, ""));
const name = match[1]
.replace(/^\d+\s*[xX]\s+/, "")
.replace(/\s+\d+\s*[xX]\s*$/, "")
.trim();
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
items.push({
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
name: name.slice(0, 80),
amount,
rawLine,
});
}
return items.slice(0, 30);
}
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
vendor: parseVendor(rawLines),
items: parseLineItems(rawLines),
rawLines,
};
}
+135
View File
@@ -0,0 +1,135 @@
import * as ImagePicker from "expo-image-picker";
import { Alert } from "react-native";
import {
applyReceiptOcrToForm,
recognizeReceiptFromImage,
} from "@/lib/receipt-ocr";
import type { ReceiptLineItem } from "@/lib/receipt-parse";
export type ReceiptSuggestFn = (input: { text: string }) => Promise<{
amount: number | null;
date: Date | null;
description: string | null;
}>;
export type PickedReceiptImage = {
uri: string;
base64: string;
filename: string;
mimeType: string;
};
export type ReceiptScanResult = {
image: PickedReceiptImage;
description: string;
amountText: string;
date: Date;
ocrText: string;
items: ReceiptLineItem[];
subtotal: number | null;
tax: number | null;
total: number | null;
};
async function requestPermissions(fromCamera: boolean): Promise<boolean> {
const permission = fromCamera
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.granted) return true;
Alert.alert(
fromCamera ? "Camera access needed" : "Photos access needed",
fromCamera
? "Allow camera access to scan receipts."
: "Allow photo library access to import receipt images.",
);
return false;
}
export async function pickReceiptImage(
fromCamera: boolean,
): Promise<PickedReceiptImage | null> {
if (!(await requestPermissions(fromCamera))) return null;
const result = fromCamera
? await ImagePicker.launchCameraAsync({
mediaTypes: ["images"],
quality: 0.9,
base64: true,
})
: await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.9,
base64: true,
});
if (result.canceled || !result.assets[0]) return null;
const asset = result.assets[0];
if (!asset.uri || !asset.base64) {
Alert.alert(
"Could not read image",
"Try another photo or lower the image size.",
);
return null;
}
return {
uri: asset.uri,
base64: asset.base64,
filename:
asset.fileName ?? (fromCamera ? "receipt.jpg" : "receipt-import.jpg"),
mimeType: asset.mimeType ?? "image/jpeg",
};
}
export async function scanReceiptImage(
fromCamera: boolean,
current: { description: string; amountText: string; date: Date },
suggest?: ReceiptSuggestFn,
): Promise<ReceiptScanResult | null> {
const image = await pickReceiptImage(fromCamera);
if (!image) return null;
try {
const ocr = await recognizeReceiptFromImage(image.uri);
let next = applyReceiptOcrToForm(ocr, current);
next = { ...next, ocrText: ocr.rawText || next.ocrText };
if (ocr.rawText && suggest) {
try {
const suggestion = await suggest({ text: ocr.rawText });
next = {
description: suggestion.description?.trim() || next.description,
amountText:
suggestion.amount != null
? String(suggestion.amount)
: next.amountText,
date: suggestion.date ? new Date(suggestion.date) : next.date,
ocrText: ocr.rawText,
};
} catch {
// Local parse is enough when server suggest fails offline.
}
}
return {
image,
...next,
items: ocr.items,
subtotal: ocr.subtotal,
tax: ocr.tax,
total: ocr.amount,
};
} catch (error) {
Alert.alert(
"OCR failed",
error instanceof Error
? error.message
: "Could not read text from the receipt.",
);
return null;
}
}
+2
View File
@@ -23,9 +23,11 @@
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-font": "~56.0.7", "expo-font": "~56.0.7",
"expo-image": "^56.0.11", "expo-image": "^56.0.11",
"expo-image-picker": "~56.0.18",
"expo-linear-gradient": "~56.0.4", "expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.14", "expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4", "expo-local-authentication": "~56.0.4",
"expo-mlkit-ocr": "^0.2.7",
"expo-network": "^56.0.5", "expo-network": "^56.0.5",
"expo-notifications": "^56.0.18", "expo-notifications": "^56.0.18",
"expo-router": "~56.2.11", "expo-router": "~56.2.11",