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,165 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
Text,
TextInput,
View,
FlatList,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import {
useTransferLineItems,
useCreateTransferLineItem,
useDeleteTransferLineItem,
type TransferLineItem,
} from "@/src/hooks/useFixedCosts";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { formatEur } from "@/src/utils/format";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
function AddModal({ onClose }: { onClose: () => void }) {
const [label, setLabel] = useState("");
const [amountStr, setAmountStr] = useState("0");
const { mutate: create, isPending } = useCreateTransferLineItem();
const { t } = useTranslation();
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!label.trim() || !amount || amount <= 0) return;
create({ label: label.trim(), amount }, { onSuccess: onClose });
}
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white">
<ModalHeader
title={t('transferItems.addTitle')}
onClose={onClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.save')}
saveDisabled={!canSave}
saveLoading={isPending}
/>
<View className="items-center py-6">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
<Text className="text-sm text-gray-400 mt-1">{t('transferItems.monthlyFixedAmount')}</Text>
</View>
<View className="px-4 mb-4">
<Text className="text-sm font-medium text-gray-700 mb-1">{t('transferItems.labelRequired')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('transferItems.labelPlaceholder')}
value={label}
onChangeText={setLabel}
/>
</View>
<Numpad onKeyPress={handleNumpad} />
</View>
</Modal>
);
}
export default function TransferLineItemsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { data: items = [], isLoading } = useTransferLineItems();
const { mutate: deleteItem } = useDeleteTransferLineItem();
const [showAdd, setShowAdd] = useState(false);
const total = items.reduce((sum, i) => sum + i.amount, 0);
function handleDelete(item: TransferLineItem) {
Alert.alert(
t('transferItems.removeTitle'),
t('transferItems.removeMessage', { label: item.label }),
[
{ text: t('common.cancel'), style: "cancel" },
{ text: t('transferItems.remove'), style: "destructive", onPress: () => deleteItem(item.id) },
],
);
}
return (
<View className="flex-1 bg-gray-50">
<View className="bg-white border-b border-gray-100" style={{ paddingTop: insets.top }}>
<View className="flex-row items-center px-4 py-3">
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
<Text className="text-base font-semibold text-gray-900 flex-1">{t('transferItems.title')}</Text>
<Pressable
onPress={() => setShowAdd(true)}
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
style={{ backgroundColor: "#dbeafe" }}
>
<Ionicons name="add" size={14} color="#2563EB" />
<Text className="text-xs font-semibold text-blue-600">{t('transferItems.new')}</Text>
</Pressable>
</View>
</View>
<Text className="text-xs text-gray-400 px-4 py-3">
{t('transferItems.hint')}
</Text>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#2563EB" />
</View>
) : (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-50">
<View className="flex-1">
<Text className="text-sm font-medium text-gray-900">{item.label}</Text>
<Text className="text-xs text-gray-400 mt-0.5">{t('common.monthly')}</Text>
</View>
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
<Pressable onPress={() => handleDelete(item)} hitSlop={8} className="p-1">
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
</Pressable>
</View>
)}
ListEmptyComponent={
<View className="px-4 py-8 items-center">
<Text className="text-sm text-gray-400 text-center">
{t('transferItems.empty')}
</Text>
</View>
}
ListFooterComponent={
items.length > 0 ? (
<View className="flex-row items-center justify-between px-4 py-3 bg-white mt-3">
<Text className="text-sm font-semibold text-gray-700">{t('transferItems.totalMonthly')}</Text>
<Text className="text-sm font-bold text-blue-600">{formatEur(total, false)}</Text>
</View>
) : null
}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
/>
)}
{showAdd && <AddModal onClose={() => setShowAdd(false)} />}
</View>
);
}