136 lines
3.4 KiB
TypeScript
136 lines
3.4 KiB
TypeScript
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;
|
|
}
|
|
}
|