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:
René Schober
2026-03-20 11:54:22 +01:00
parent 4e34270786
commit 9ddc7c6d7a
194 changed files with 55961 additions and 305 deletions

View File

@@ -0,0 +1,208 @@
import { useShoppingList, type ShoppingItem } from "@/src/hooks/useShoppingList";
import { Ionicons } from "@expo/vector-icons";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const ACCENT = "#16a34a";
function StatusDot({ status }: { status: "connecting" | "connected" | "offline" }) {
const { t } = useTranslation();
if (status === "connected") {
return <View className="w-2 h-2 rounded-full bg-green-500" />;
}
return (
<View className="flex-row items-center gap-1">
<View className="w-2 h-2 rounded-full bg-gray-400" />
{status === "offline" && (
<Text className="text-xs text-gray-400">{t("shopping.offline")}</Text>
)}
</View>
);
}
function ShoppingItemRow({
item,
onToggle,
onDelete,
}: {
item: ShoppingItem;
onToggle: () => void;
onDelete: () => void;
}) {
const isChecked = item.checkedBy !== null;
return (
<View className="flex-row items-center px-4 py-3 bg-white">
<TouchableOpacity onPress={onToggle} className="mr-3 active:opacity-60">
<Ionicons
name={isChecked ? "checkbox" : "square-outline"}
size={24}
color={isChecked ? "#9ca3af" : ACCENT}
/>
</TouchableOpacity>
<Text
className="flex-1 text-base"
style={{
color: isChecked ? "#9ca3af" : "#111827",
textDecorationLine: isChecked ? "line-through" : "none",
}}
>
{item.label}
{item.quantity ? (
<Text style={{ color: "#9ca3af", fontSize: 13 }}> {item.quantity}</Text>
) : null}
</Text>
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
</TouchableOpacity>
</View>
);
}
export default function ShoppingListScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { items, status, addItem, toggleItem, deleteItem, deleteChecked } = useShoppingList();
const [text, setText] = useState("");
const [quantity, setQuantity] = useState("");
const inputRef = useRef<TextInput>(null);
const unchecked = items.filter((i) => i.checkedBy === null);
const checked = items.filter((i) => i.checkedBy !== null);
const sorted = [...unchecked, ...checked];
const hasChecked = checked.length > 0;
function handleAdd() {
const trimmed = text.trim();
if (!trimmed) return;
addItem(trimmed, quantity.trim() || undefined);
setText("");
setQuantity("");
inputRef.current?.focus();
}
return (
<KeyboardAvoidingView
className="flex-1 bg-gray-50"
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={0}
>
{/* Header */}
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
}}
className="px-4 pb-3 flex-row items-center justify-between"
>
<Text className="text-xl font-bold text-gray-900">{t("shopping.title")}</Text>
<View className="flex-row items-center gap-2">
{hasChecked && (
<Pressable
onPress={deleteChecked}
className="rounded-full px-3 py-1 active:opacity-70"
style={{ backgroundColor: "#f3f4f6" }}
>
<Text className="text-xs font-medium text-gray-600">
{t("shopping.deleteChecked")}
</Text>
</Pressable>
)}
<StatusDot status={status} />
</View>
</View>
{/* List */}
<FlatList
data={sorted}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => {
const isFirstChecked =
item.checkedBy !== null &&
(index === 0 || sorted[index - 1]?.checkedBy === null);
return (
<>
{isFirstChecked && unchecked.length > 0 && (
<View className="h-px bg-gray-200 mx-4 my-1" />
)}
<ShoppingItemRow
item={item}
onToggle={() => toggleItem(item)}
onDelete={() => deleteItem(item.id)}
/>
</>
);
}}
ItemSeparatorComponent={() => <View className="h-px bg-gray-100 ml-14" />}
ListEmptyComponent={
<View className="flex-1 items-center justify-center py-24">
<Ionicons
name="cart-outline"
size={48}
color="#d1d5db"
style={{ marginBottom: 12 }}
/>
<Text className="text-base font-medium text-gray-700 mb-1">
{t("shopping.empty")}
</Text>
<Text className="text-sm text-gray-400 text-center px-8">
{t("shopping.emptyHint")}
</Text>
</View>
}
keyboardShouldPersistTaps="handled"
contentContainerStyle={sorted.length === 0 ? { flex: 1 } : { paddingBottom: 8 }}
/>
{/* Input bar */}
<View
style={{ paddingBottom: insets.bottom || 16 }}
className="px-4 pt-3 bg-white border-t border-gray-100"
>
<View className="flex-row items-center gap-3 mb-2">
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
placeholder={t("shopping.placeholder")}
placeholderTextColor="#9ca3af"
returnKeyType="done"
onSubmitEditing={handleAdd}
blurOnSubmit={false}
className="flex-1 text-base text-gray-900 py-2"
/>
<Pressable
onPress={handleAdd}
disabled={!text.trim()}
style={{ backgroundColor: text.trim() ? ACCENT : "#e5e7eb" }}
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
>
<Ionicons name="arrow-up" size={18} color={text.trim() ? "#fff" : "#9ca3af"} />
</Pressable>
</View>
<TextInput
value={quantity}
onChangeText={setQuantity}
placeholder={t("shopping.quantityPlaceholder")}
placeholderTextColor="#9ca3af"
returnKeyType="done"
onSubmitEditing={handleAdd}
blurOnSubmit={false}
className="text-sm text-gray-600 py-1"
/>
</View>
</KeyboardAvoidingView>
);
}