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,234 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TransactionScreen } from "@/src/components/features/transactions/TransactionScreen";
import { useChildren, useCreateChild, type Child } from "@/src/hooks/useChildren";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
const CHILD_COLORS = [
"#ec4899",
"#f59e0b",
"#10b981",
"#2563EB",
"#7c3aed",
"#ef4444",
"#0ea5e9",
"#378ADD",
];
function AddChildModal({
visible,
onClose,
onCreated,
}: {
visible: boolean;
onClose: () => void;
onCreated: (child: Child) => void;
}) {
const [name, setName] = useState("");
const [color, setColor] = useState(CHILD_COLORS[0]!);
const { t } = useTranslation();
const { mutate: createChild, isPending } = useCreateChild();
function handleSave() {
const trimmed = name.trim();
if (!trimmed) return;
createChild(
{ name: trimmed, color },
{
onSuccess: (data) => {
onCreated(data.child);
setName("");
setColor(CHILD_COLORS[0]!);
onClose();
},
},
);
}
function handleClose() {
setName("");
setColor(CHILD_COLORS[0]!);
onClose();
}
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleClose}>
<View className="flex-1 bg-white">
{/* Header */}
<ModalHeader
title={t('children.addChild')}
onClose={handleClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.save')}
saveDisabled={!name.trim()}
saveLoading={isPending}
saveColor="#ec4899"
/>
<View className="px-4 mt-6">
{/* Name Input */}
<Text className="text-sm font-medium text-gray-700 mb-2">Name</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-6"
placeholder="z.B. Emma"
value={name}
onChangeText={setName}
autoFocus
/>
{/* Color Picker */}
<Text className="text-sm font-medium text-gray-700 mb-3">Farbe</Text>
<View className="flex-row flex-wrap gap-3">
{CHILD_COLORS.map((c) => (
<Pressable
key={c}
onPress={() => setColor(c)}
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: c,
alignItems: "center",
justifyContent: "center",
borderWidth: color === c ? 3 : 0,
borderColor: "#fff",
shadowColor: color === c ? c : "transparent",
shadowOpacity: color === c ? 0.5 : 0,
shadowRadius: 4,
elevation: color === c ? 4 : 0,
}}
>
{color === c && (
<Ionicons name="checkmark" size={20} color="#fff" />
)}
</Pressable>
))}
</View>
</View>
</View>
</Modal>
);
}
export default function KinderScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { data: children = [], isLoading } = useChildren();
const [activeChildId, setActiveChildId] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
// Determine active child — fall back to first child when list loads
const activeChild =
children.find((c) => c.id === activeChildId) ?? children[0] ?? null;
if (isLoading) {
return (
<View className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#ec4899" />
</View>
);
}
return (
<View className="flex-1 bg-white">
{/* Empty State */}
{children.length === 0 && (
<View className="flex-1 items-center justify-center px-8">
<Ionicons name="happy-outline" size={72} color="#d1d5db" style={{ marginBottom: 16 }} />
<Text className="text-lg font-semibold text-gray-700 mb-2 text-center">
{t('children.noChildren')}
</Text>
<Text className="text-sm text-gray-400 text-center mb-8">
{t('children.noChildrenHint')}
</Text>
<Pressable
onPress={() => setShowAddModal(true)}
className="px-6 py-3 rounded-full items-center justify-center"
style={{ backgroundColor: "#ec4899" }}
>
<Text className="text-white font-semibold text-base">+ {t('children.addChild')}</Text>
</Pressable>
</View>
)}
{/* Children Tab Switcher + Content */}
{children.length > 0 && activeChild && (
<TransactionScreen
scope="child"
childId={activeChild.id}
accentColor={activeChild.color}
emptyTitle={t('children.noTransactions', { name: activeChild.name })}
emptySubtitle={t('children.noTransactionsHint')}
headerExtra={
<View className="bg-white border-b border-gray-100">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8, flexDirection: "row", alignItems: "center" }}
>
{children.map((child) => {
const isActive = child.id === activeChild.id;
return (
<Pressable
key={child.id}
onPress={() => setActiveChildId(child.id)}
style={{
paddingHorizontal: 16,
paddingVertical: 7,
borderRadius: 20,
backgroundColor: isActive ? child.color : "#f3f4f6",
}}
>
<Text
style={{
fontSize: 14,
fontWeight: "600",
color: isActive ? "#fff" : "#4b5563",
}}
>
{child.name}
</Text>
</Pressable>
);
})}
{/* Add Child Button */}
<Pressable
onPress={() => setShowAddModal(true)}
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "#fce7f3",
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="add" size={20} color="#ec4899" />
</Pressable>
</ScrollView>
</View>
}
/>
)}
<AddChildModal
visible={showAddModal}
onClose={() => setShowAddModal(false)}
onCreated={(child) => setActiveChildId(child.id)}
/>
</View>
);
}