Yaltopia-Tickets-App/app/(tabs)/scan.tsx

209 lines
6.7 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import {
View,
Pressable,
Platform,
ActivityIndicator,
Alert,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { X, Zap, Camera as CameraIcon, ScanLine } 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 { BASE_URL } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
import { toast } from "@/lib/toast-store";
const NAV_BG = "#ffffff";
export default function ScanScreen() {
const nav = useSirouRouter<AppRoutes>();
const [permission, requestPermission] = useCameraPermissions();
const [torch, setTorch] = useState(false);
const [scanning, setScanning] = useState(false);
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 handleScan = async () => {
if (!cameraRef.current || scanning) return;
setScanning(true);
try {
// 1. Capture the photo
const photo = await cameraRef.current.takePictureAsync({
quality: 0.85,
base64: false,
});
if (!photo?.uri) throw new Error("Failed to capture photo.");
toast.info("Scanning...", "Uploading invoice image for AI extraction.");
// 2. Build multipart form data with the image file
const formData = new FormData();
formData.append("file", {
uri: photo.uri,
name: "invoice.jpg",
type: "image/jpeg",
} as any);
// 3. POST to /api/v1/scan/invoice
const response = await fetch(`${BASE_URL}scan/invoice`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
// Do NOT set Content-Type here — fetch sets it automatically with the boundary for multipart
},
body: formData,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || "Scan failed.");
}
const data = await response.json();
console.log("[Scan] Extracted invoice data:", data);
toast.success("Scan Complete!", "Invoice data extracted successfully.");
// Navigate to create invoice screen
nav.go("proforma/create");
} catch (err: any) {
console.error("[Scan] Error:", err);
toast.error(
"Scan Failed",
err.message || "Could not process the invoice.",
);
} finally {
setScanning(false);
}
};
if (!permission) {
return <View className="flex-1 bg-black" />;
}
if (!permission.granted) {
return (
<ScreenWrapper className="bg-background items-center justify-center p-10 px-16">
<View className="bg-primary/10 p-6 rounded-[24px] mb-6">
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
</View>
<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-14 rounded-[12px] bg-primary px-10"
onPress={requestPermission}
>
<Text className="text-white font-bold uppercase tracking-widest">
Enable Camera
</Text>
</Button>
<Pressable onPress={() => nav.back()} className="mt-6">
<Text className="text-muted-foreground font-bold">Go Back</Text>
</Pressable>
</ScreenWrapper>
);
}
return (
<View className="flex-1 bg-black">
<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-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
<View className="w-64 h-64 border border-white/10 rounded-2xl" />
</View>
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
Align Invoice Within Frame
</Text>
</View>
{/* Capture Button */}
<View className="items-center pb-10 gap-4">
<Pressable
onPress={handleScan}
disabled={scanning}
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30"
>
{scanning ? (
<ActivityIndicator color="white" size="large" />
) : (
<ScanLine color="white" size={32} />
)}
</Pressable>
<Text className="text-white/50 text-[10px] font-black uppercase tracking-widest">
{scanning ? "Extracting Data..." : "Tap to Scan"}
</Text>
</View>
</View>
</CameraView>
</View>
);
}