Add mobile expenses and receipt OCR
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user