Files
HausApp/apps/native/app/(app)/scanner.tsx
René Schober 9ddc7c6d7a Production deployment setup + feature complete
- 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>
2026-03-20 11:54:22 +01:00

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