diff --git a/app.json b/app.json index 33bada8..318d2ec 100644 --- a/app.json +++ b/app.json @@ -10,12 +10,14 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.beenvoice.app", - "buildNumber": "11", + "buildNumber": "12", "icon": "./assets/beenvoice.icon", "infoPlist": { "ITSAppUsesNonExemptEncryption": false, "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": { @@ -28,7 +30,8 @@ "predictiveBackGestureEnabled": false, "permissions": [ "android.permission.USE_BIOMETRIC", - "android.permission.USE_FINGERPRINT" + "android.permission.USE_FINGERPRINT", + "android.permission.CAMERA" ] }, "web": { @@ -82,7 +85,20 @@ "@react-native-community/datetimepicker", "./plugins/withAppIntents.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": { "typedRoutes": true diff --git a/app/(app)/more/_layout.tsx b/app/(app)/more/_layout.tsx new file mode 100644 index 0000000..d1356b1 --- /dev/null +++ b/app/(app)/more/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from "expo-router"; + +export default function MoreLayout() { + return ( + + ); +} diff --git a/app/(app)/more/expenses/[id].tsx b/app/(app)/more/expenses/[id].tsx new file mode 100644 index 0000000..a30e5ab --- /dev/null +++ b/app/(app)/more/expenses/[id].tsx @@ -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( + null, + ); + const [form, setForm] = useState({ + 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 ; + } + + if (!expense) { + return ( + + + + Expense not found + + + + ); + } + + return ( + + + + + + {editing ? ( + <> + {receiptSplit ? ( + { + setForm((current) => ({ + ...current, + amountText: selection.owedTotal.toFixed(2), + notes: mergeNotes(selection.notes, current.notes), + })); + }} + /> + ) : null} + +