Add mobile expenses and receipt OCR

This commit is contained in:
2026-06-29 01:35:00 -04:00
parent 8a3f498874
commit 8ad4210908
18 changed files with 2460 additions and 4 deletions
+265
View File
@@ -0,0 +1,265 @@
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.map((item) => item.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 }]}>
Split receipt items
</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Select what this person owes. Tax is split proportionally.
</Text>
</View>
<Text style={[styles.total, { color: colors.foreground }]}>
{formatCurrency(calculation.owedTotal)}
</Text>
</View>
<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="Apply owed amount"
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,
},
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,
},
});