- Dockerfile + deploy.sh for Hetzner server - Email verification via Better Auth + Resend - Invite code flow (6-digit OTP, generate/join) - Settlement share percent fix (payer vs debtor) - OCR scanner fixes (date display, retry, viewfinder) - app.json icon/splash/adaptive-icon configured - iOS deployment target 15.5 (ML Kit requirement) - DB migration 0014: household_invitations table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
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<CameraView>(null);
|
|
|
|
const [screenState, setScreenState] = useState<ScreenState>("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<string | undefined>(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 (
|
|
<View className="flex-1 bg-black items-center justify-center">
|
|
<ActivityIndicator color="#fff" />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── Permission denied ─────────────────────────────────────────────────────
|
|
if (!permission.granted) {
|
|
return (
|
|
<View
|
|
className="flex-1 bg-black items-center justify-center px-8 gap-4"
|
|
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
|
|
>
|
|
{/* Back button */}
|
|
<Pressable
|
|
onPress={() => router.back()}
|
|
className="absolute top-0 left-4 p-3"
|
|
style={{ top: insets.top }}
|
|
>
|
|
<Ionicons name="chevron-back" size={26} color="#fff" />
|
|
</Pressable>
|
|
|
|
<Ionicons name="camera-outline" size={64} color="#6b7280" />
|
|
<Text className="text-white text-center text-base font-medium">
|
|
{t("scanner.permissionDenied")}
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => void requestPermission()}
|
|
style={{ backgroundColor: ACCENT }}
|
|
className="px-6 py-3 rounded-xl"
|
|
>
|
|
<Text className="text-white font-semibold">{t("scanner.openSettings")}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => void Linking.openSettings()}
|
|
className="px-6 py-3"
|
|
>
|
|
<Text className="text-gray-400 text-sm">{t("scanner.openSettings")}</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── 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<OcrResponse>("/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<unknown>("/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 (
|
|
<View className="flex-1 bg-black">
|
|
{/* Camera */}
|
|
<CameraView
|
|
key={cameraKey}
|
|
ref={cameraRef}
|
|
style={{ flex: 1 }}
|
|
facing="back"
|
|
/>
|
|
|
|
{/* Header overlay */}
|
|
<View
|
|
className="absolute left-0 right-0 flex-row items-center px-4"
|
|
style={{ top: insets.top, paddingTop: 8 }}
|
|
>
|
|
<Pressable
|
|
onPress={() => router.back()}
|
|
className="w-10 h-10 rounded-full bg-black/40 items-center justify-center"
|
|
>
|
|
<Ionicons name="chevron-back" size={22} color="#fff" />
|
|
</Pressable>
|
|
<Text className="flex-1 text-center text-white font-semibold text-base mr-10">
|
|
{t("scanner.title")}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Hint text */}
|
|
{screenState === "camera" && (
|
|
<View
|
|
className="absolute left-0 right-0 items-center"
|
|
style={{ top: insets.top + 64 }}
|
|
>
|
|
<View className="bg-black/40 px-4 py-2 rounded-full">
|
|
<Text className="text-white text-sm">{t("scanner.hint")}</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Viewfinder frame */}
|
|
{screenState === "camera" && (
|
|
<View className="absolute inset-0 items-center justify-center">
|
|
<View
|
|
className="border-2 border-white/60 rounded-2xl"
|
|
style={{ width: "85%", height: "75%" }}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Capture button */}
|
|
{screenState === "camera" && (
|
|
<View
|
|
className="absolute left-0 right-0 items-center"
|
|
style={{ bottom: insets.bottom + 40 }}
|
|
>
|
|
<Pressable
|
|
onPress={() => 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 },
|
|
}}
|
|
>
|
|
<View className="w-16 h-16 rounded-full border-4 border-gray-300" />
|
|
</Pressable>
|
|
<Text className="text-white/70 text-xs mt-3">{t("scanner.capture")}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Scanning overlay */}
|
|
{screenState === "scanning" && (
|
|
<View className="absolute inset-0 bg-black/70 items-center justify-center gap-4">
|
|
<ActivityIndicator size="large" color="#fff" />
|
|
<Text className="text-white text-base font-medium">{t("scanner.scanning")}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Booking overlay */}
|
|
{screenState === "booking" && (
|
|
<View className="absolute inset-0 bg-black/70 items-center justify-center gap-4">
|
|
<ActivityIndicator size="large" color="#fff" />
|
|
<Text className="text-white text-base font-medium">{t("common.loading")}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Confirmation sheet */}
|
|
<Modal
|
|
visible={screenState === "confirm"}
|
|
animationType="slide"
|
|
presentationStyle="pageSheet"
|
|
onRequestClose={handleRetry}
|
|
>
|
|
<View className="flex-1 bg-white">
|
|
{/* Sheet header */}
|
|
<View
|
|
className="flex-row items-center px-4 py-4 border-b border-gray-100"
|
|
style={{ paddingTop: insets.top > 0 ? 12 : 12 }}
|
|
>
|
|
<Pressable onPress={handleRetry} className="py-1 pr-4">
|
|
<Text className="text-base" style={{ color: ACCENT }}>
|
|
{t("scanner.retry")}
|
|
</Text>
|
|
</Pressable>
|
|
<Text className="flex-1 text-center font-semibold text-base text-gray-900">
|
|
{t("scanner.detected")}
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => void handleBook()}
|
|
className="py-1 pl-4"
|
|
style={{ opacity: !amountStr || parseFloat(amountStr) <= 0 ? 0.4 : 1 }}
|
|
disabled={!amountStr || parseFloat(amountStr) <= 0}
|
|
>
|
|
<Text className="text-base font-semibold" style={{ color: ACCENT }}>
|
|
{t("scanner.book")}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
|
|
{/* Merchant */}
|
|
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
|
{t("scanner.merchant").toUpperCase()}
|
|
</Text>
|
|
<TextInput
|
|
value={label}
|
|
onChangeText={setLabel}
|
|
placeholder={t("scanner.merchant")}
|
|
placeholderTextColor="#9ca3af"
|
|
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
|
/>
|
|
|
|
{/* Amount */}
|
|
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
|
{t("scanner.amount").toUpperCase()}
|
|
</Text>
|
|
<View className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 flex-row items-center mb-4">
|
|
<TextInput
|
|
value={amountStr}
|
|
onChangeText={setAmountStr}
|
|
placeholder="0.00"
|
|
placeholderTextColor="#9ca3af"
|
|
keyboardType="decimal-pad"
|
|
className="flex-1 text-base text-gray-900"
|
|
/>
|
|
<Text className="text-base text-gray-400 ml-2">€</Text>
|
|
</View>
|
|
|
|
{/* Date */}
|
|
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
|
{t("scanner.date").toUpperCase()}
|
|
</Text>
|
|
<TextInput
|
|
value={dateDisplay}
|
|
onChangeText={(v) => {
|
|
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 */}
|
|
<Text className="text-xs text-gray-400 font-medium mb-2 ml-1">
|
|
{t("scanner.category").toUpperCase()}
|
|
</Text>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
className="mb-4"
|
|
contentContainerStyle={{ gap: 8, paddingRight: 16 }}
|
|
>
|
|
{displayCategories.map((cat) => {
|
|
const isSelected = selectedCategoryId === cat.id;
|
|
return (
|
|
<Pressable
|
|
key={cat.id}
|
|
onPress={() =>
|
|
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"
|
|
>
|
|
<Text
|
|
className="text-sm font-medium"
|
|
style={{ color: isSelected ? "#fff" : "#4b5563" }}
|
|
>
|
|
{cat.name}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
|
|
{/* Scope */}
|
|
<Text className="text-xs text-gray-400 font-medium mb-2 ml-1">
|
|
{t("scanner.scope").toUpperCase()}
|
|
</Text>
|
|
<View className="flex-row gap-3 mb-8">
|
|
<Pressable
|
|
onPress={() => 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"
|
|
>
|
|
<Text
|
|
className="text-sm font-semibold"
|
|
style={{ color: scope === "household" ? "#fff" : "#4b5563" }}
|
|
>
|
|
{t("scanner.household")}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => 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"
|
|
>
|
|
<Text
|
|
className="text-sm font-semibold"
|
|
style={{ color: scope === "private" ? "#fff" : "#4b5563" }}
|
|
>
|
|
{t("scanner.private")}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|