Yaltopia-Tickets-App/app/(tabs)/scan.tsx
2026-05-14 22:29:28 +03:00

358 lines
12 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";
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.`);
// 4. Map OCR data to structure
const ocr = scanResult.data || {};
const invoicePayload = {
invoiceNumber: ocr.invoiceNumber || `DOC-${Date.now()}`,
customerName: ocr.customerName?.trim() || "Unknown Entity",
customerEmail: ocr.customerEmail || "",
customerPhone: ocr.customerPhone || "",
amount: ocr.totalAmount || ocr.subtotalAmount || 0,
currency: ocr.currency || "ETB",
type: scanType === "invoice" ? "SALES" : "EXPENSE",
status: "DRAFT",
issueDate: ocr.issueDate
? new Date(ocr.issueDate).toISOString()
: new Date().toISOString(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
description: `Scanned ${scanType === "invoice" ? "Invoice" : "Receipt"} #${ocr.invoiceNumber || ""}`,
notes: scanResult.message || "Automatically generated from scan.",
taxAmount: ocr.taxAmount || 0,
discountAmount: 0,
isScanned: true,
scannedData: {
sellerTIN: ocr.sellerTIN || "",
items: ocr.items || [],
},
items: (ocr.items || []).map((item: any) => ({
description:
typeof item === "string" ? item : item.description || "Item",
quantity: item.quantity || 1,
unitPrice: item.unitPrice || item.total || 0,
total: item.total || 0,
})),
};
// 5. Create the record in the backend
const createResponse = await api.invoices.create({
body: invoicePayload,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
console.log("[Scan] Record created successfully:", createResponse);
if (createResponse?.id) {
nav.go(`invoices/${createResponse.id}`);
} else {
nav.go("(tabs)/payments");
}
} 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">
<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">
{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-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-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-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-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-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-black uppercase tracking-widest">
{"Tap to Capture"}
</Text>
</View>
</View>
</View>
</CameraView>
)}
</View>
);
}