Yaltopia-Tickets-App/app/(tabs)/scan.tsx
2026-06-05 13:39:37 +03:00

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>
);
}