335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
|
import {
|
|
View,
|
|
Pressable,
|
|
Platform,
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
StyleSheet,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
X,
|
|
Zap,
|
|
Camera as CameraIcon,
|
|
ScanLine,
|
|
Check,
|
|
RefreshCw,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { useNavigation } from "expo-router";
|
|
import { api, BASE_URL } from "@/lib/api";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
import { toast } from "@/lib/toast-store";
|
|
import { setScanData } from "@/lib/scan-cache";
|
|
|
|
const NAV_BG = "#ffffff";
|
|
|
|
export default function ScanScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const [permission, requestPermission] = useCameraPermissions();
|
|
const [torch, setTorch] = useState(false);
|
|
const [scanType, setScanType] = useState<"invoice" | "receipt">("invoice");
|
|
const [scanning, setScanning] = useState(false);
|
|
const [previewUri, setPreviewUri] = useState<string | null>(null);
|
|
const cameraRef = useRef<CameraView>(null);
|
|
const navigation = useNavigation();
|
|
const token = useAuthStore((s) => s.token);
|
|
|
|
useEffect(() => {
|
|
navigation.setOptions({ tabBarStyle: { display: "none" } });
|
|
return () => {
|
|
navigation.setOptions({
|
|
tabBarStyle: {
|
|
display: "flex",
|
|
backgroundColor: NAV_BG,
|
|
borderTopWidth: 0,
|
|
elevation: 10,
|
|
height: 75,
|
|
paddingBottom: Platform.OS === "ios" ? 30 : 10,
|
|
paddingTop: 10,
|
|
marginHorizontal: 20,
|
|
position: "absolute",
|
|
bottom: 25,
|
|
left: 20,
|
|
right: 20,
|
|
borderRadius: 32,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 10 },
|
|
shadowOpacity: 0.12,
|
|
shadowRadius: 20,
|
|
},
|
|
});
|
|
};
|
|
}, [navigation]);
|
|
|
|
const handleCapture = async () => {
|
|
if (!cameraRef.current || scanning) return;
|
|
try {
|
|
const photo = await cameraRef.current.takePictureAsync({
|
|
quality: 0.8,
|
|
base64: false,
|
|
});
|
|
if (photo?.uri) {
|
|
setPreviewUri(photo.uri);
|
|
}
|
|
} catch (err) {
|
|
console.error("[Scan] Capture Error:", err);
|
|
toast.error("Capture Failed", "Could not take a photo.");
|
|
}
|
|
};
|
|
|
|
const handleProcess = async () => {
|
|
if (!previewUri || scanning) return;
|
|
|
|
setScanning(true);
|
|
try {
|
|
const label = scanType === "invoice" ? "invoice" : "receipt";
|
|
toast.info(
|
|
"Processing...",
|
|
`Uploading ${label} image for AI extraction.`,
|
|
);
|
|
|
|
// Build multipart form data with the image file (binary)
|
|
const formData = new FormData();
|
|
const fileExt = previewUri.split(".").pop() || "jpg";
|
|
const fileName = `${label}-${Date.now()}.${fileExt}`;
|
|
const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`;
|
|
|
|
// In React Native, the file object in FormData needs special treatment
|
|
formData.append("file", {
|
|
uri:
|
|
Platform.OS === "android"
|
|
? previewUri
|
|
: previewUri.replace("file://", ""),
|
|
name: fileName,
|
|
type: type,
|
|
} as any);
|
|
|
|
const endpoint =
|
|
scanType === "invoice" ? "scan/invoice" : "scan/payment-receipt";
|
|
|
|
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/json",
|
|
// Boundary is set automatically by fetch when body is FormData
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response
|
|
.json()
|
|
.catch(() => ({ message: "Scan processing failed." }));
|
|
throw new Error(err.message || "Extraction failed.");
|
|
}
|
|
|
|
const scanResult = await response.json();
|
|
console.log(`[Scan] Extracted ${label} data:`, scanResult);
|
|
|
|
if (!scanResult.success) {
|
|
throw new Error(
|
|
scanResult.message || "AI extraction was unsuccessful.",
|
|
);
|
|
}
|
|
|
|
toast.success("Success!", `Extracted data from ${label} successfully.`);
|
|
|
|
const ocr = scanResult.data || {};
|
|
setScanData(ocr);
|
|
|
|
setPreviewUri(null);
|
|
setScanning(false);
|
|
|
|
if (scanType === "invoice") {
|
|
nav.go("invoices/create");
|
|
} else {
|
|
nav.go("add-receipt");
|
|
}
|
|
return;
|
|
} catch (err: any) {
|
|
console.error("[Scan] Processing Error:", err);
|
|
toast.error(
|
|
"Processing Failed",
|
|
err.message || "Document extraction failed.",
|
|
);
|
|
} finally {
|
|
setScanning(false);
|
|
}
|
|
};
|
|
|
|
if (!permission) {
|
|
return <View className="flex-1 bg-black" />;
|
|
}
|
|
|
|
if (!permission.granted) {
|
|
return (
|
|
<ScreenWrapper className="bg-background items-center justify-center">
|
|
<Text variant="h2" className="text-center mb-2">
|
|
Camera Access
|
|
</Text>
|
|
<Text variant="muted" className="text-center mb-10 leading-6 px-10">
|
|
We need your permission to use the camera to scan invoices and
|
|
receipts automatically.
|
|
</Text>
|
|
<Button
|
|
className="w-3/4 h-12 rounded-[12px] bg-primary px-10"
|
|
onPress={requestPermission}
|
|
>
|
|
<Text className="text-white font-sans-bold tracking-widest">
|
|
Enable Camera
|
|
</Text>
|
|
</Button>
|
|
<Pressable
|
|
onPress={() => nav.back()}
|
|
className="mt-4 border border-border w-3/4 rounded-[12px] py-3 flex-row justify-center items-center "
|
|
>
|
|
<Text className="text-muted-foreground font-sans-bold">Go Back</Text>
|
|
</Pressable>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View className="flex-1 bg-black">
|
|
{previewUri ? (
|
|
<View className="flex-1">
|
|
<Image
|
|
source={{ uri: previewUri }}
|
|
style={StyleSheet.absoluteFillObject}
|
|
resizeMode="cover"
|
|
/>
|
|
<View className="flex-1 justify-between p-10 pt-16 bg-black/20">
|
|
<View className="flex-row justify-between items-center">
|
|
<Text className="text-white font-sans-black uppercase tracking-widest text-lg shadow-xl">
|
|
Preview
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => setPreviewUri(null)}
|
|
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
|
>
|
|
<X color="white" size={24} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className="flex-row gap-4 items-center justify-center pb-10">
|
|
<Button
|
|
variant="ghost"
|
|
className="flex-1 h-16 rounded-2xl bg-black/40 border border-white/20"
|
|
onPress={() => setPreviewUri(null)}
|
|
disabled={scanning}
|
|
>
|
|
<RefreshCw color="white" size={20} className="mr-2" />
|
|
<Text className="text-white font-sans-bold uppercase tracking-widest text-xs">
|
|
Retake
|
|
</Text>
|
|
</Button>
|
|
|
|
<Button
|
|
className="flex-1 h-16 rounded-2xl bg-primary shadow-2xl"
|
|
onPress={handleProcess}
|
|
disabled={scanning}
|
|
>
|
|
{scanning ? (
|
|
<ActivityIndicator color="white" />
|
|
) : (
|
|
<>
|
|
<Check color="white" size={24} className="mr-2" />
|
|
<Text className="text-white font-sans-bold uppercase tracking-widest text-xs">
|
|
Extract
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<CameraView
|
|
ref={cameraRef}
|
|
style={{ flex: 1 }}
|
|
facing="back"
|
|
enableTorch={torch}
|
|
>
|
|
<View className="flex-1 justify-between p-10 pt-16">
|
|
{/* Top bar */}
|
|
<View className="flex-row justify-between items-center">
|
|
<Pressable
|
|
onPress={() => setTorch(!torch)}
|
|
className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`}
|
|
>
|
|
<Zap
|
|
color="white"
|
|
size={20}
|
|
fill={torch ? "white" : "transparent"}
|
|
/>
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={() => nav.back()}
|
|
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
|
|
>
|
|
<X color="white" size={24} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Scan Frame */}
|
|
<View className="items-center">
|
|
<View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
|
<View className="w-[280px] h-[380px] border border-white/10 rounded-2xl" />
|
|
</View>
|
|
</View>
|
|
|
|
{/* Capture Button & Tabs */}
|
|
<View className="items-center gap-6">
|
|
{/* Tabs */}
|
|
<View className="flex-row bg-white/10 self-center rounded-2xl border border-white/10 p-1">
|
|
<Pressable
|
|
onPress={() => setScanType("invoice")}
|
|
className={`px-6 py-2 rounded-xl ${scanType === "invoice" ? "bg-primary" : "bg-transparent"}`}
|
|
>
|
|
<Text
|
|
className={`font-sans-bold text-xs uppercase tracking-widest ${scanType === "invoice" ? "text-white" : "text-white/50"}`}
|
|
>
|
|
Invoice
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => setScanType("receipt")}
|
|
className={`px-6 py-2 rounded-xl ${scanType === "receipt" ? "bg-primary" : "bg-transparent"}`}
|
|
>
|
|
<Text
|
|
className={`font-sans-bold text-xs uppercase tracking-widest ${scanType === "receipt" ? "text-white" : "text-white/50"}`}
|
|
>
|
|
Receipt
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className="items-center gap-4">
|
|
<Pressable
|
|
onPress={handleCapture}
|
|
disabled={scanning}
|
|
className="h-16 w-16 rounded-full bg-primary items-center justify-center border-4 border-white/30 shadow-2xl"
|
|
>
|
|
<ScanLine color="white" size={32} />
|
|
</Pressable>
|
|
<Text className="text-white/50 text-[10px] font-sans-black uppercase tracking-widest">
|
|
{"Tap to Capture"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</CameraView>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|