import { useRef, useState } from "react"; import { ActivityIndicator, Alert, Linking, Modal, Pressable, ScrollView, Text, TextInput, View, } from "react-native"; import { CameraView, useCameraPermissions } from "expo-camera"; import { readAsStringAsync, EncodingType } from "expo-file-system/legacy"; import { useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@/src/lib/api-client"; import { useCategories } from "@/src/hooks/useCategories"; import { TAB_COLORS } from "@/src/constants/colors"; const ACCENT = TAB_COLORS.household; type ScreenState = "camera" | "scanning" | "confirm" | "booking"; type OcrResponse = { amount: number | null; label: string | null; date: string | null; confidence: number; }; type CreateTransactionBody = { amount: number; merchant: string; description: string; date: string; type: "expense"; scope: "household" | "private"; categoryId?: string; }; const FALLBACK_CATEGORIES = [ "Lebensmittel", "Restaurant", "Transport", "Shopping", "Haushalt", "Sonstiges", ]; function todayIso(): string { return new Date().toISOString().slice(0, 10); } export default function ScannerScreen() { const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const queryClient = useQueryClient(); const [permission, requestPermission] = useCameraPermissions(); const cameraRef = useRef(null); const [screenState, setScreenState] = useState("camera"); const [cameraKey, setCameraKey] = useState(0); // Confirm sheet state const [label, setLabel] = useState(""); const [amountStr, setAmountStr] = useState(""); const [date, setDate] = useState(todayIso()); // always YYYY-MM-DD internally const [dateDisplay, setDateDisplay] = useState(() => { const d = todayIso(); return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(0, 4)}`; }); const [selectedCategoryId, setSelectedCategoryId] = useState(undefined); const [scope, setScope] = useState<"household" | "private">("household"); const { data: categories = [] } = useCategories(); const expenseCategories = categories.filter((c) => c.type === "expense"); const displayCategories = expenseCategories.length > 0 ? expenseCategories.map((c) => ({ id: c.id, name: c.name })) : FALLBACK_CATEGORIES.map((name, i) => ({ id: String(i), name })); // ── Permission not yet determined ───────────────────────────────────────── if (!permission) { return ( ); } // ── Permission denied ───────────────────────────────────────────────────── if (!permission.granted) { return ( {/* Back button */} router.back()} className="absolute top-0 left-4 p-3" style={{ top: insets.top }} > {t("scanner.permissionDenied")} void requestPermission()} style={{ backgroundColor: ACCENT }} className="px-6 py-3 rounded-xl" > {t("scanner.openSettings")} void Linking.openSettings()} className="px-6 py-3" > {t("scanner.openSettings")} ); } // ── Capture & OCR ───────────────────────────────────────────────────────── async function handleCapture() { if (!cameraRef.current) return; setScreenState("scanning"); try { const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.7 }); if (!photo?.uri) throw new Error("No photo URI"); const base64 = await readAsStringAsync(photo.uri, { encoding: EncodingType.Base64, }); const result = await apiRequest("/api/scanner/receipt", { method: "POST", body: JSON.stringify({ imageBase64: base64, mimeType: "image/jpeg" }), }); setLabel(result.label ?? ""); setAmountStr(result.amount != null ? String(result.amount) : ""); const isoDate = result.date ?? todayIso(); setDate(isoDate); setDateDisplay(`${isoDate.slice(8, 10)}.${isoDate.slice(5, 7)}.${isoDate.slice(0, 4)}`); setScreenState("confirm"); } catch { Alert.alert(t("common.error"), t("scanner.error")); setScreenState("camera"); } } // ── Book transaction ────────────────────────────────────────────────────── async function handleBook() { const amount = parseFloat(amountStr.replace(",", ".")); if (!amount || amount <= 0) { Alert.alert(t("common.error"), t("scanner.notRecognized")); return; } setScreenState("booking"); try { const body: CreateTransactionBody = { amount, merchant: label.trim() || t("scanner.title"), description: label.trim() || t("scanner.title"), date: new Date(date).toISOString(), type: "expense", scope, ...(selectedCategoryId ? { categoryId: selectedCategoryId } : {}), }; await apiRequest("/api/transactions", { method: "POST", body: JSON.stringify(body), }); await queryClient.invalidateQueries({ queryKey: ["transactions"] }); handleRetry(); // reset all state router.back(); } catch { Alert.alert(t("common.error"), t("scanner.error")); setScreenState("confirm"); } } function handleRetry() { const today = todayIso(); setScreenState("camera"); setLabel(""); setAmountStr(""); setDate(today); setDateDisplay(`${today.slice(8, 10)}.${today.slice(5, 7)}.${today.slice(0, 4)}`); setSelectedCategoryId(undefined); setScope("household"); setCameraKey((k) => k + 1); } // ── Render ───────────────────────────────────────────────────────────────── return ( {/* Camera */} {/* Header overlay */} router.back()} className="w-10 h-10 rounded-full bg-black/40 items-center justify-center" > {t("scanner.title")} {/* Hint text */} {screenState === "camera" && ( {t("scanner.hint")} )} {/* Viewfinder frame */} {screenState === "camera" && ( )} {/* Capture button */} {screenState === "camera" && ( void handleCapture()} className="w-[72px] h-[72px] rounded-full bg-white items-center justify-center active:opacity-80" style={{ shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 8, shadowOffset: { width: 0, height: 2 }, }} > {t("scanner.capture")} )} {/* Scanning overlay */} {screenState === "scanning" && ( {t("scanner.scanning")} )} {/* Booking overlay */} {screenState === "booking" && ( {t("common.loading")} )} {/* Confirmation sheet */} {/* Sheet header */} 0 ? 12 : 12 }} > {t("scanner.retry")} {t("scanner.detected")} void handleBook()} className="py-1 pl-4" style={{ opacity: !amountStr || parseFloat(amountStr) <= 0 ? 0.4 : 1 }} disabled={!amountStr || parseFloat(amountStr) <= 0} > {t("scanner.book")} {/* Merchant */} {t("scanner.merchant").toUpperCase()} {/* Amount */} {t("scanner.amount").toUpperCase()} {/* Date */} {t("scanner.date").toUpperCase()} { setDateDisplay(v); // Convert DD.MM.YYYY → YYYY-MM-DD for internal state const parts = v.split("."); if (parts.length === 3 && parts[2]?.length === 4) { setDate(`${parts[2]}-${parts[1]?.padStart(2, "0")}-${parts[0]?.padStart(2, "0")}`); } }} placeholder="TT.MM.JJJJ" placeholderTextColor="#9ca3af" keyboardType="numbers-and-punctuation" className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4" /> {/* Category */} {t("scanner.category").toUpperCase()} {displayCategories.map((cat) => { const isSelected = selectedCategoryId === cat.id; return ( setSelectedCategoryId(isSelected ? undefined : cat.id) } style={{ backgroundColor: isSelected ? ACCENT : "#f3f4f6", borderWidth: 1, borderColor: isSelected ? ACCENT : "#e5e7eb", }} className="px-4 py-2 rounded-full active:opacity-70" > {cat.name} ); })} {/* Scope */} {t("scanner.scope").toUpperCase()} setScope("household")} style={{ flex: 1, backgroundColor: scope === "household" ? ACCENT : "#f3f4f6", borderWidth: 1, borderColor: scope === "household" ? ACCENT : "#e5e7eb", }} className="py-3 rounded-xl items-center active:opacity-70" > {t("scanner.household")} setScope("private")} style={{ flex: 1, backgroundColor: scope === "private" ? "#7C3AED" : "#f3f4f6", borderWidth: 1, borderColor: scope === "private" ? "#7C3AED" : "#e5e7eb", }} className="py-3 rounded-xl items-center active:opacity-70" > {t("scanner.private")} ); }