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,427 @@
import { EmptyState } from "@/src/components/ui/EmptyState";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { TAB_COLORS } from "@/src/constants/colors";
import {
useTrips,
useCreateTrip,
type Trip,
type CreateTripInput,
} from "@/src/hooks/useTrips";
import { formatEur } from "@/src/utils/format";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const ACCENT = TAB_COLORS.shopping; // green #16A34A
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDateRange(startDate: string, endDate: string): string {
const fmt = (d: string) => {
const parts = d.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]?.slice(2)}`;
};
return `${fmt(startDate)} ${fmt(endDate)}`;
}
function getBudgetColor(remaining: number, budget: number): string {
if (remaining <= 0) return "#dc2626"; // red
if (remaining < budget * 0.1) return "#ea580c"; // orange
return ACCENT; // green
}
// ── Progress Bar ──────────────────────────────────────────────────────────────
function ProgressBar({
spent,
budget,
color,
}: {
spent: number;
budget: number;
color: string;
}) {
const ratio = budget > 0 ? Math.min(spent / budget, 1) : 0;
return (
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden mt-2">
<View
style={{ width: `${ratio * 100}%`, backgroundColor: color }}
className="h-full rounded-full"
/>
</View>
);
}
// ── Active Trip Card ──────────────────────────────────────────────────────────
function ActiveTripCard({ trip, onPress }: { trip: Trip; onPress: () => void }) {
const { t } = useTranslation();
const remaining = trip.budget - trip.spent;
const color = getBudgetColor(remaining, trip.budget);
const isOver = remaining <= 0;
return (
<Pressable
onPress={onPress}
className="mx-4 mb-3 bg-white rounded-2xl p-4 active:opacity-80"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<View className="flex-row items-start justify-between mb-1">
<View className="flex-1 mr-2">
<Text className="text-base font-semibold text-gray-900">{trip.name}</Text>
{trip.destination && (
<Text className="text-xs text-gray-400 mt-0.5">{trip.destination}</Text>
)}
</View>
<View
className="px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${ACCENT}18` }}
>
<Text className="text-xs font-medium" style={{ color: ACCENT }}>
{t("trips.active")}
</Text>
</View>
</View>
<Text className="text-xs text-gray-400 mb-2">
{formatDateRange(trip.startDate, trip.endDate)}
</Text>
<ProgressBar spent={trip.spent} budget={trip.budget} color={color} />
<View className="flex-row justify-between mt-2">
<Text className="text-xs text-gray-400">
{t("trips.spent")}: {formatEur(trip.spent)}
</Text>
{isOver ? (
<Text className="text-xs font-semibold" style={{ color: "#dc2626" }}>
{t("trips.overBudget", { amount: formatEur(Math.abs(remaining)) })}
</Text>
) : (
<Text className="text-xs font-semibold" style={{ color }}>
{t("trips.remaining")}: {formatEur(remaining)}
</Text>
)}
</View>
<View className="flex-row justify-between mt-1">
<Text className="text-xs text-gray-300">{t("trips.budget")}: {formatEur(trip.budget)}</Text>
</View>
</Pressable>
);
}
// ── Past Trip Row ─────────────────────────────────────────────────────────────
function PastTripRow({ trip, onPress }: { trip: Trip; onPress: () => void }) {
const { t } = useTranslation();
const hasSettlement =
trip.settlementAmount !== null && trip.settlementAmount > 0.01;
const isBalanced =
trip.settlementAmount !== null && trip.settlementAmount <= 0.01;
return (
<Pressable
onPress={onPress}
className="flex-row items-center px-4 py-3 bg-white active:opacity-80"
style={{ borderBottomWidth: 1, borderBottomColor: "#f9fafb" }}
>
<View className="flex-1">
<Text className="text-sm font-medium text-gray-700">{trip.name}</Text>
{trip.destination && (
<Text className="text-xs text-gray-400">{trip.destination}</Text>
)}
{hasSettlement && (
<Text className="text-xs text-gray-400 mt-0.5">
{t("trips.settlement.closedBanner")} · {formatEur(trip.settlementAmount ?? 0)}
</Text>
)}
{isBalanced && (
<Text className="text-xs mt-0.5" style={{ color: "#16a34a" }}>
{t("trips.settlement.balanced")}
</Text>
)}
</View>
<View className="items-end mr-3">
<Text className="text-sm font-semibold text-gray-600">{formatEur(trip.spent)}</Text>
<Text className="text-xs text-gray-400">{formatDateRange(trip.startDate, trip.endDate)}</Text>
</View>
<View
className="px-2 py-0.5 rounded-full"
style={{ backgroundColor: "#f3f4f6" }}
>
<Text className="text-xs text-gray-500">{t("trips.completed")}</Text>
</View>
</Pressable>
);
}
// ── Create Trip Modal ─────────────────────────────────────────────────────────
function CreateTripModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const { mutate: createTrip, isPending } = useCreateTrip();
const [name, setName] = useState("");
const [destination, setDestination] = useState("");
const [budgetStr, setBudgetStr] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const canSave =
name.trim().length > 0 &&
parseFloat(budgetStr.replace(",", ".")) > 0 &&
startDate.length === 10 &&
endDate.length === 10;
function handleSave() {
const budget = parseFloat(budgetStr.replace(",", "."));
if (!canSave || isNaN(budget)) return;
const input: CreateTripInput = {
name: name.trim(),
budget,
startDate,
endDate,
...(destination.trim() ? { destination: destination.trim() } : {}),
};
createTrip(input, { onSuccess: onClose });
}
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white">
<ModalHeader
title={t("trips.new")}
onClose={onClose}
closeLabel={t("common.cancel")}
onSave={handleSave}
saveLabel={t("common.create")}
saveDisabled={!canSave}
saveLoading={isPending}
saveColor={ACCENT}
/>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Name */}
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
{t("trips.name")} *
</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder="z.B. Sommerurlaub Italien"
placeholderTextColor="#9ca3af"
value={name}
onChangeText={setName}
autoFocus
/>
{/* Destination */}
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
{t("trips.destination")}
</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder="z.B. Rom, Italien"
placeholderTextColor="#9ca3af"
value={destination}
onChangeText={setDestination}
/>
{/* Budget */}
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
{t("trips.budget")} () *
</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder="z.B. 2000"
placeholderTextColor="#9ca3af"
value={budgetStr}
onChangeText={setBudgetStr}
keyboardType="decimal-pad"
/>
{/* Start Date */}
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
{t("trips.startDate")} *
</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder="YYYY-MM-DD"
placeholderTextColor="#9ca3af"
value={startDate}
onChangeText={setStartDate}
keyboardType="numbers-and-punctuation"
maxLength={10}
/>
{/* End Date */}
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
{t("trips.endDate")} *
</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder="YYYY-MM-DD"
placeholderTextColor="#9ca3af"
value={endDate}
onChangeText={setEndDate}
keyboardType="numbers-and-punctuation"
maxLength={10}
/>
</ScrollView>
</View>
</Modal>
);
}
// ── Main Screen ───────────────────────────────────────────────────────────────
type SectionItem =
| { type: "header"; label: string }
| { type: "active-trip"; trip: Trip }
| { type: "past-trip"; trip: Trip }
| { type: "empty" };
export default function UrlaubScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { data: trips = [], isLoading, refetch, isRefetching } = useTrips();
const [showCreate, setShowCreate] = useState(false);
const activeTrips = trips.filter((t) => t.status === "active");
const pastTrips = trips.filter((t) => t.status === "completed");
// Build flat list items for sectioned rendering
const listItems: SectionItem[] = [];
if (activeTrips.length > 0) {
listItems.push({ type: "header", label: t("trips.active") });
activeTrips.forEach((trip) => listItems.push({ type: "active-trip", trip }));
}
if (pastTrips.length > 0) {
listItems.push({ type: "header", label: t("trips.past") });
pastTrips.forEach((trip) => listItems.push({ type: "past-trip", trip }));
}
if (trips.length === 0 && !isLoading) {
listItems.push({ type: "empty" });
}
function renderItem({ item }: { item: SectionItem }) {
if (item.type === "header") {
return (
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-4 pb-2">
{item.label}
</Text>
);
}
if (item.type === "active-trip") {
return (
<ActiveTripCard
trip={item.trip}
onPress={() =>
router.push({
pathname: "/(app)/urlaub/[id]",
params: { id: item.trip.id },
})
}
/>
);
}
if (item.type === "past-trip") {
return (
<PastTripRow
trip={item.trip}
onPress={() =>
router.push({
pathname: "/(app)/urlaub/[id]",
params: { id: item.trip.id },
})
}
/>
);
}
// empty state
return (
<EmptyState
icon="airplane-outline"
title={t("trips.noTrips")}
subtitle={t("trips.noTripsHint")}
/>
);
}
return (
<View className="flex-1 bg-gray-50">
{/* Header */}
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top + 12,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
}}
className="px-4 pb-3 flex-row items-center justify-between"
>
<View className="flex-row items-center gap-2">
<Pressable onPress={() => router.push("/(app)/mehr")} className="mr-1 p-1">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
<Text className="text-xl font-bold text-gray-900">{t("trips.title")}</Text>
</View>
<Pressable
onPress={() => setShowCreate(true)}
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
style={{ backgroundColor: ACCENT }}
>
<Ionicons name="add" size={22} color="#fff" />
</Pressable>
</View>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
) : (
<FlatList
data={listItems}
keyExtractor={(item, index) => {
if (item.type === "header") return `header-${item.label}`;
if (item.type === "active-trip" || item.type === "past-trip")
return item.trip.id;
return `empty-${index}`;
}}
renderItem={renderItem}
contentContainerStyle={
trips.length === 0
? { flex: 1, paddingBottom: insets.bottom + 16 }
: { paddingBottom: insets.bottom + 16, paddingTop: 4 }
}
refreshing={isRefetching}
onRefresh={() => void refetch()}
/>
)}
{showCreate && <CreateTripModal onClose={() => setShowCreate(false)} />}
</View>
);
}