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>
This commit is contained in:
459
apps/native/app/(app)/scanner.tsx
Normal file
459
apps/native/app/(app)/scanner.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user