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,233 @@
import { useCategories, useDeleteCategory, useUpdateCategory, type Category } from "@/src/hooks/useCategories";
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// ── Edit Modal ─────────────────────────────────────────────────────────────────
function EditCategoryModal({
category,
onClose,
}: {
category: Category | null;
onClose: () => void;
}) {
const [name, setName] = useState(category?.name ?? "");
const { mutate: update, isPending } = useUpdateCategory();
const { t } = useTranslation();
function handleSave() {
if (!category || !name.trim()) return;
update(
{ id: category.id, name: name.trim() },
{ onSuccess: onClose },
);
}
return (
<Modal
visible={!!category}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View className="flex-1 bg-white">
<ModalHeader
title={t('categories.editTitle')}
onClose={onClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.save')}
saveDisabled={!name.trim()}
saveLoading={isPending}
/>
<View className="px-4 mt-6">
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.nameLabel')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
value={name}
onChangeText={setName}
autoFocus
/>
{category?.isDefault && (
<Text className="text-xs text-gray-400 mt-2">
{t('categories.defaultWarning')}
</Text>
)}
</View>
</View>
</Modal>
);
}
// ── Category Row ───────────────────────────────────────────────────────────────
function CategoryRow({
category,
onEdit,
onDelete,
}: {
category: Category;
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<View className="flex-row items-center justify-between py-3 border-b border-gray-100">
<View className="flex-row items-center gap-3">
<View
className="w-9 h-9 rounded-full items-center justify-center"
style={{ backgroundColor: category.color ?? "#6b7280" }}
>
<Ionicons name={category.icon} size={18} color="#fff" />
</View>
<Text className="text-base text-gray-800">{category.name}</Text>
{category.isDefault && (
<View className="bg-gray-100 rounded px-1.5 py-0.5">
<Text className="text-xs text-gray-400">{t('categories.default')}</Text>
</View>
)}
</View>
<View className="flex-row gap-3">
<Pressable onPress={onEdit} className="p-1 active:opacity-50">
<Ionicons name="pencil-outline" size={18} color="#6b7280" />
</Pressable>
{!category.isDefault && (
<Pressable onPress={onDelete} className="p-1 active:opacity-50">
<Ionicons name="trash-outline" size={18} color="#dc2626" />
</Pressable>
)}
</View>
</View>
);
}
// ── Main Screen ────────────────────────────────────────────────────────────────
export default function CategoriesScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { data: categories = [], isLoading } = useCategories();
const { mutate: deleteCategory } = useDeleteCategory();
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [addType, setAddType] = useState<"income" | "expense">("expense");
const expenseCategories = categories.filter((c) => c.type === "expense");
const incomeCategories = categories.filter((c) => c.type === "income");
function handleDelete(category: Category) {
Alert.alert(
t('categories.deleteTitle'),
t('categories.deleteMessage', { name: category.name }),
[
{ text: t('common.cancel'), style: "cancel" },
{
text: t('common.delete'),
style: "destructive",
onPress: () =>
deleteCategory(category.id, {
onError: (err) => {
Alert.alert(t('common.error'), err.message);
},
}),
},
],
);
}
if (isLoading) {
return (
<View className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#2563EB" />
</View>
);
}
return (
<View className="flex-1 bg-gray-50" style={{ paddingTop: insets.top }}>
{/* Header */}
<View className="flex-row items-center px-4 py-4 bg-white border-b border-gray-100">
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 active:opacity-50">
<Ionicons name="chevron-back" size={24} color="#374151" />
</Pressable>
<Text className="text-lg font-semibold text-gray-900">{t('settings.categories')}</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }}>
{/* Expense Categories */}
<View className="mb-6 rounded-xl bg-white p-4">
<View className="flex-row items-center justify-between mb-3">
<Text className="text-xs font-medium uppercase text-gray-400">{t('categories.expenseSection')}</Text>
</View>
{expenseCategories.map((cat) => (
<CategoryRow
key={cat.id}
category={cat}
onEdit={() => setEditingCategory(cat)}
onDelete={() => handleDelete(cat)}
/>
))}
<Pressable
onPress={() => { setAddType("expense"); setShowAddModal(true); }}
className="mt-3 flex-row items-center gap-2 py-2 active:opacity-50"
>
<Ionicons name="add-circle-outline" size={18} color="#2563EB" />
<Text className="text-sm font-medium text-blue-600">{t('categories.addExpenseCategory')}</Text>
</Pressable>
</View>
{/* Income Categories */}
<View className="mb-6 rounded-xl bg-white p-4">
<View className="flex-row items-center justify-between mb-3">
<Text className="text-xs font-medium uppercase text-gray-400">{t('categories.incomeSection')}</Text>
</View>
{incomeCategories.map((cat) => (
<CategoryRow
key={cat.id}
category={cat}
onEdit={() => setEditingCategory(cat)}
onDelete={() => handleDelete(cat)}
/>
))}
<Pressable
onPress={() => { setAddType("income"); setShowAddModal(true); }}
className="mt-3 flex-row items-center gap-2 py-2 active:opacity-50"
>
<Ionicons name="add-circle-outline" size={18} color="#2563EB" />
<Text className="text-sm font-medium text-blue-600">{t('categories.addIncomeCategory')}</Text>
</Pressable>
</View>
</ScrollView>
<EditCategoryModal
key={editingCategory?.id}
category={editingCategory}
onClose={() => setEditingCategory(null)}
/>
<AddCategoryModal
visible={showAddModal}
onClose={() => setShowAddModal(false)}
defaultType={addType}
/>
</View>
);
}

View File

@@ -0,0 +1,317 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
SectionList,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import {
useFixedCosts,
useCreateFixedCost,
useUpdateFixedCost,
useDeleteFixedCost,
type FixedCost,
type CreateFixedCostInput,
} from "@/src/hooks/useFixedCosts";
import { useCategories } from "@/src/hooks/useCategories";
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";
const SCOPE_LABEL_KEYS: Record<string, string> = {
household: "fixedCosts.household",
private: "fixedCosts.me",
child: "fixedCosts.children",
};
// ── Add / Edit Modal ──────────────────────────────────────────────────────────
type ModalMode =
| { kind: "add"; scope: "household" | "private" | "child" }
| { kind: "edit"; item: FixedCost };
function FixedCostModal({
mode,
onClose,
}: {
mode: ModalMode;
onClose: () => void;
}) {
const isEdit = mode.kind === "edit";
const { data: categories = [] } = useCategories();
const { mutate: create, isPending: creating } = useCreateFixedCost();
const { mutate: update, isPending: updating } = useUpdateFixedCost();
const { t: tFn } = useTranslation();
const [label, setLabel] = useState(isEdit ? mode.item.label : "");
const [amountStr, setAmountStr] = useState(
isEdit ? String(mode.item.amount).replace(".", ",") : "0",
);
const [type, setType] = useState<"expense" | "income">(
isEdit ? mode.item.type : "expense",
);
const [categoryId, setCategoryId] = useState<string | null>(
isEdit ? (mode.item.categoryId ?? null) : null,
);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const filteredCategories = categories.filter((c) => c.type === type);
const selectedCategory = categories.find((c) => c.id === categoryId) ?? null;
const isPending = creating || updating;
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!label.trim() || !amount || amount <= 0) return;
if (isEdit) {
update(
{ id: mode.item.id, input: { label: label.trim(), amount, categoryId } },
{ onSuccess: onClose },
);
} else {
const input: CreateFixedCostInput = {
scope: mode.scope,
label: label.trim(),
amount,
type,
categoryId: categoryId ?? undefined,
};
create(input, { 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={isEdit ? tFn('fixedCosts.editTitle') : tFn('fixedCosts.addTitle')}
onClose={onClose}
closeLabel={tFn('common.cancel')}
onSave={handleSave}
saveLabel={tFn('common.save')}
saveDisabled={!canSave}
saveLoading={isPending}
/>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
{/* Amount */}
<View className="items-center py-6">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
</View>
<View className="px-4 gap-3 mb-4">
{/* Type toggle (only for new) */}
{!isEdit && (
<View className="flex-row p-1 bg-gray-100 rounded-xl">
{(["expense", "income"] as const).map((t) => (
<Pressable
key={t}
onPress={() => { setType(t); setCategoryId(null); }}
className={`flex-1 py-2 rounded-lg items-center ${type === t ? "bg-white shadow-sm" : ""}`}
>
<Text className={`font-medium ${type === t ? (t === "expense" ? "text-red-600" : "text-green-600") : "text-gray-500"}`}>
{t === "expense" ? tFn('fixedCosts.expenseType') : tFn('fixedCosts.incomeType')}
</Text>
</Pressable>
))}
</View>
)}
{/* Label */}
<View>
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.labelRequired')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={tFn('fixedCosts.labelPlaceholder')}
value={label}
onChangeText={setLabel}
/>
</View>
{/* Category */}
<View>
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.categoryOptional')}</Text>
<Pressable
onPress={() => setShowCategoryPicker((v) => !v)}
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3"
>
<Text className="flex-1 text-base" style={{ color: selectedCategory ? "#111827" : "#9ca3af" }}>
{selectedCategory ? selectedCategory.name : tFn('common.select')}
</Text>
{selectedCategory ? (
<Pressable onPress={(e) => { e.stopPropagation(); setCategoryId(null); }} hitSlop={8}>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
) : (
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
)}
</Pressable>
{showCategoryPicker && (
<View className="mt-1 bg-white border border-gray-200 rounded-xl overflow-hidden">
{filteredCategories.map((cat) => (
<Pressable
key={cat.id}
onPress={() => { setCategoryId(cat.id); setShowCategoryPicker(false); }}
className="flex-row items-center px-4 py-3 active:bg-gray-50"
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
>
<View className="w-6 h-6 rounded-full mr-3 items-center justify-center" style={{ backgroundColor: cat.color ?? "#6b7280" }}>
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={12} color="#fff" />
</View>
<Text className="text-sm text-gray-800">{cat.name}</Text>
{categoryId === cat.id && <Ionicons name="checkmark" size={16} color="#2563EB" style={{ marginLeft: "auto" }} />}
</Pressable>
))}
</View>
)}
</View>
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpad} />
</ScrollView>
</View>
</Modal>
);
}
// ── Row ───────────────────────────────────────────────────────────────────────
function FixedCostRow({
item,
onEdit,
onDelete,
}: {
item: FixedCost;
onEdit: (item: FixedCost) => void;
onDelete: (item: FixedCost) => void;
}) {
const { t } = useTranslation();
return (
<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">
{item.type === "expense" ? t('fixedCosts.expenseType') : t('fixedCosts.incomeType')} · {t('common.monthly')}
</Text>
</View>
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
<Pressable onPress={() => onEdit(item)} hitSlop={8} className="mr-2 p-1">
<Ionicons name="pencil-outline" size={16} color="#6b7280" />
</Pressable>
<Pressable onPress={() => onDelete(item)} hitSlop={8} className="p-1">
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
</Pressable>
</View>
);
}
// ── Screen ────────────────────────────────────────────────────────────────────
export default function FixedCostsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { data: allFixedCosts = [], isLoading } = useFixedCosts();
const { mutate: deleteCost } = useDeleteFixedCost();
const [modalMode, setModalMode] = useState<ModalMode | null>(null);
const active = allFixedCosts.filter((fc) => fc.isActive);
const sections = (["household", "private", "child"] as const)
.map((scope) => ({
scope,
title: t(SCOPE_LABEL_KEYS[scope] ?? scope),
data: active.filter((fc) => fc.scope === scope),
}))
.filter((s) => s.data.length > 0 || true); // always show all scopes
function handleDelete(item: FixedCost) {
Alert.alert(
t('fixedCosts.pauseTitle'),
t('fixedCosts.pauseMessage', { label: item.label }),
[
{ text: t('common.cancel'), style: "cancel" },
{ text: t('fixedCosts.pause'), style: "destructive", onPress: () => deleteCost(item.id) },
],
);
}
return (
<View className="flex-1 bg-gray-50">
{/* Header */}
<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('fixedCosts.title')}</Text>
</View>
</View>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#2563EB" />
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderSectionHeader={({ section }) => (
<View className="flex-row items-center justify-between px-4 py-3 bg-gray-50">
<Text className="text-xs font-semibold uppercase text-gray-500 tracking-wide">
{section.title}
</Text>
<Pressable
onPress={() => setModalMode({ kind: "add", scope: section.scope })}
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('common.new')}</Text>
</Pressable>
</View>
)}
renderItem={({ item }) => (
<FixedCostRow
item={item}
onEdit={(i) => setModalMode({ kind: "edit", item: i })}
onDelete={handleDelete}
/>
)}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View className="px-4 py-3 bg-white border-b border-gray-50">
<Text className="text-sm text-gray-400 italic">{t('fixedCosts.noItems')}</Text>
</View>
) : null
}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
/>
)}
{modalMode && (
<FixedCostModal mode={modalMode} onClose={() => setModalMode(null)} />
)}
</View>
);
}

View File

@@ -0,0 +1,269 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHouseholdSettings, useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
const ACCENT = "#2563EB";
const SHARE_PRESETS = [50, 60, 75, 100];
function SettingsRow({
label,
value,
onPress,
}: {
label: string;
value: string;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
>
<Text className="text-base text-gray-900">{label}</Text>
<View className="flex-row items-center gap-2">
<Text className="text-base text-gray-500">{value}</Text>
<Ionicons name="pencil-outline" size={14} color="#9ca3af" />
</View>
</Pressable>
);
}
function EditModal({
title,
initialValue,
keyboardType,
onSave,
onClose,
}: {
title: string;
initialValue: string;
keyboardType?: "default" | "decimal-pad";
onSave: (value: string) => void;
onClose: () => void;
}) {
const [value, setValue] = useState(initialValue);
const { t } = useTranslation();
return (
<View
style={{
position: "absolute", inset: 0, backgroundColor: "rgba(0,0,0,0.4)",
alignItems: "center", justifyContent: "center", zIndex: 100,
}}
>
<View className="bg-white rounded-2xl mx-6 p-5 w-full" style={{ maxWidth: 340 }}>
<Text className="text-base font-semibold text-gray-900 mb-3">{title}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
value={value}
onChangeText={setValue}
keyboardType={keyboardType ?? "default"}
autoFocus
autoCapitalize="words"
/>
<View className="flex-row gap-3">
<Pressable
onPress={onClose}
className="flex-1 py-3 rounded-xl items-center bg-gray-100 active:opacity-70"
>
<Text className="text-sm font-semibold text-gray-700">{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={() => { onSave(value); onClose(); }}
className="flex-1 py-3 rounded-xl items-center active:opacity-70"
style={{ backgroundColor: ACCENT }}
>
<Text className="text-sm font-semibold text-white">{t('common.save')}</Text>
</Pressable>
</View>
</View>
</View>
);
}
type EditingField = "ownerName" | "partnerName" | "monthlyBudget" | "userSharePercent" | null;
export default function HouseholdSettingsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { data: settings, isLoading } = useHouseholdSettings();
const { data: membersData } = useHouseholdMembers();
const { mutate: update, isPending } = useUpdateHouseholdSettings();
const [editing, setEditing] = useState<EditingField>(null);
const members = membersData?.members ?? [];
function save(input: Parameters<typeof update>[0]) {
update(input, {
onError: () => Alert.alert(t('common.error'), t('settings.saveError')),
});
}
if (isLoading || !settings) {
return (
<View className="flex-1 bg-gray-50 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
);
}
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('settings.household.title')}</Text>
{isPending && <ActivityIndicator size="small" color={ACCENT} />}
</View>
</View>
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 32 }}>
{/* Namen */}
<View className="bg-white rounded-2xl px-4 mb-4">
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.namesSection')}</Text>
<SettingsRow
label={t('settings.household.yourName')}
value={settings.ownerName}
onPress={() => setEditing("ownerName")}
/>
<SettingsRow
label={t('settings.household.partnerName')}
value={settings.partnerName}
onPress={() => setEditing("partnerName")}
/>
</View>
{/* Wer zahlt die Ausgaben vor? */}
{members.length > 1 && (
<View className="bg-white rounded-2xl px-4 mb-4">
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.payerSection')}</Text>
<Text className="text-xs text-gray-400 mb-3">{t('settings.household.payerHint')}</Text>
<View className="flex-row gap-2 mb-3">
{members.map((m) => {
const isSelected = settings.payerUserId === m.userId;
return (
<Pressable
key={m.userId}
onPress={() => save({ payerUserId: m.userId })}
className="flex-1 py-2.5 rounded-xl items-center"
style={{ backgroundColor: isSelected ? ACCENT : "#f3f4f6" }}
>
<Text className="text-sm font-semibold" style={{ color: isSelected ? "#fff" : "#374151" }}>
{m.name}
</Text>
</Pressable>
);
})}
</View>
</View>
)}
{/* Kostenaufteilung */}
<View className="bg-white rounded-2xl px-4 mb-4">
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-2">{t('settings.household.costSplitSection')}</Text>
<Text className="text-xs text-gray-400 mb-3">{t('settings.household.costSplitHint')}</Text>
<View className="flex-row gap-2 mb-3">
{SHARE_PRESETS.map((p) => (
<Pressable
key={p}
onPress={() => save({ userSharePercent: p })}
className="flex-1 py-2.5 rounded-xl items-center"
style={{ backgroundColor: settings.userSharePercent === p ? ACCENT : "#f3f4f6" }}
>
<Text
className="text-sm font-semibold"
style={{ color: settings.userSharePercent === p ? "#fff" : "#374151" }}
>
{p}%
</Text>
</Pressable>
))}
</View>
<View
className="rounded-xl px-3 py-2 mb-3"
style={{ backgroundColor: "#eff6ff" }}
>
<Text className="text-xs text-blue-700">
{t('settings.household.sharePreview', { own: settings.userSharePercent, partner: settings.partnerName, rest: 100 - settings.userSharePercent })}
</Text>
</View>
<SettingsRow
label={t('settings.household.monthlyBudget')}
value={`${settings.monthlyBudget.toFixed(0)}`}
onPress={() => setEditing("monthlyBudget")}
/>
<View className="flex-row items-center justify-between py-3">
<Text className="text-base text-gray-900">{t('settings.household.splitChildren')}</Text>
<Switch
value={settings.splitChildCosts}
onValueChange={(v) => save({ splitChildCosts: v })}
trackColor={{ false: "#d1d5db", true: ACCENT }}
thumbColor="#fff"
/>
</View>
</View>
{/* Währung */}
<View className="bg-white rounded-2xl px-4 mb-4">
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.settingsSection')}</Text>
<SettingsRow
label={t('settings.household.currency')}
value={settings.currency}
onPress={() =>
Alert.alert(t('settings.household.currency'), t('settings.household.currencyOnlyEur'))
}
/>
</View>
</ScrollView>
{/* Inline Edit Modals */}
{editing === "ownerName" && (
<EditModal
title={t('settings.household.yourName')}
initialValue={settings.ownerName}
onSave={(v) => save({ ownerName: v.trim() || "Ich" })}
onClose={() => setEditing(null)}
/>
)}
{editing === "partnerName" && (
<EditModal
title={t('settings.household.partnerName')}
initialValue={settings.partnerName}
onSave={(v) => save({ partnerName: v.trim() || "Partner" })}
onClose={() => setEditing(null)}
/>
)}
{editing === "monthlyBudget" && (
<EditModal
title={t('settings.household.monthlyBudget')}
initialValue={String(settings.monthlyBudget)}
keyboardType="decimal-pad"
onSave={(v) => save({ monthlyBudget: parseFloat(v.replace(",", ".")) || 400 })}
onClose={() => setEditing(null)}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,442 @@
import { useAuthStore } from "@/src/stores/auth.store";
import { signOut } from "@/src/lib/auth-client";
import {
useHouseholdMembers,
useRevokeInvitation,
type PendingInvitation,
type HouseholdMember,
} from "@/src/hooks/useHouseholdMembers";
import { useHouseholdSettings, useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { useGenerateInviteCode } from "@/src/hooks/useInvite";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useState, useEffect } from "react";
import {
View,
Text,
Pressable,
ScrollView,
ToastAndroid,
Platform,
Alert,
Modal,
Share,
ActivityIndicator,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
import i18n from "@/src/i18n";
import * as Localization from "expo-localization";
function showToast(message: string) {
if (Platform.OS === "android") {
ToastAndroid.show(message, ToastAndroid.SHORT);
} else {
Alert.alert("", message, [{ text: "OK" }], { cancelable: true });
}
}
// ── Invite Code Modal ──────────────────────────────────────────────────────────
function InviteCodeModal({
visible,
onClose,
}: {
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { mutate: generate, data, isPending, reset } = useGenerateInviteCode();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (visible) {
reset();
setCopied(false);
generate();
}
}, [visible]);
const code = data?.code ?? "";
async function handleShare() {
if (!code) return;
await Share.share({ message: t('invite.shareText', { code }) });
}
async function handleCopy() {
if (!code) return;
await Share.share({ message: code });
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleClose() {
reset();
setCopied(false);
onClose();
}
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleClose}
>
<View className="flex-1 bg-white">
<ModalHeader
title={t('invite.title')}
onClose={handleClose}
closeLabel={t('common.cancel')}
/>
<View className="flex-1 items-center justify-center px-6">
{isPending ? (
<View className="items-center gap-3">
<ActivityIndicator size="large" color="#2563EB" />
<Text className="text-sm text-gray-400">{t('invite.generating')}</Text>
</View>
) : (
<>
{/* Code display */}
<View className="items-center mb-2 rounded-2xl bg-gray-50 px-8 py-6">
<Text
style={{
fontSize: 40,
fontWeight: "700",
letterSpacing: 10,
color: "#111827",
}}
>
{code || "------"}
</Text>
</View>
<Text className="text-sm text-gray-400 mb-10">{t('invite.validFor')}</Text>
{/* Copy button */}
<Pressable
onPress={handleCopy}
className="w-full mb-3 flex-row items-center justify-center gap-2 rounded-xl bg-blue-600 py-4 active:opacity-80"
>
<Ionicons name="copy-outline" size={18} color="white" />
<Text className="text-base font-semibold text-white">
{copied ? t('invite.copied') : t('invite.copyCode')}
</Text>
</Pressable>
{/* Share button */}
<Pressable
onPress={handleShare}
className="w-full mb-8 flex-row items-center justify-center gap-2 rounded-xl border border-blue-200 py-4 active:opacity-80"
>
<Ionicons name="share-outline" size={18} color="#2563EB" />
<Text className="text-base font-semibold text-blue-600">{t('invite.share')}</Text>
</Pressable>
{/* Regenerate link */}
<Pressable onPress={() => { setCopied(false); generate(); }} className="active:opacity-60">
<Text className="text-sm text-gray-400 underline">{t('invite.newCode')}</Text>
</Pressable>
</>
)}
</View>
</View>
</Modal>
);
}
// ── Members Section ────────────────────────────────────────────────────────────
function MembersSection() {
const [showInviteModal, setShowInviteModal] = useState(false);
const { data, isLoading } = useHouseholdMembers();
const { mutate: revoke } = useRevokeInvitation();
const currentUserId = useAuthStore((s) => s.user?.id);
const { t } = useTranslation();
function handleRevoke(inv: PendingInvitation) {
Alert.alert(
t('settings.revokeTitle'),
t('settings.revokeMessage', { email: inv.email }),
[
{ text: t('common.cancel'), style: "cancel" },
{
text: t('settings.revoke'),
style: "destructive",
onPress: () => revoke(inv.id, { onSuccess: () => showToast(t('settings.revokeSuccess')) }),
},
],
);
}
return (
<>
<View className="mb-6 rounded-xl bg-white p-4">
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.members')}</Text>
{isLoading && (
<ActivityIndicator size="small" color="#2563EB" style={{ marginVertical: 8 }} />
)}
{/* Active members */}
{data?.members.map((m: HouseholdMember) => (
<View
key={m.userId}
className="flex-row items-center justify-between py-3 border-b border-gray-100"
>
<View className="flex-row items-center gap-3">
<View
className="w-8 h-8 rounded-full items-center justify-center"
style={{ backgroundColor: m.userId === currentUserId ? "#2563EB" : "#e5e7eb" }}
>
<Text
className="text-xs font-bold"
style={{ color: m.userId === currentUserId ? "#fff" : "#6b7280" }}
>
{m.name.charAt(0).toUpperCase()}
</Text>
</View>
<View>
<Text className="text-base text-gray-900">
{m.name}{m.userId === currentUserId ? ` ${t('settings.youSuffix')}` : ""}
</Text>
<Text className="text-xs text-gray-400">{m.email}</Text>
</View>
</View>
<Text className="text-xs text-gray-400 capitalize">{m.role}</Text>
</View>
))}
{/* Pending invitations */}
{(data?.pendingInvitations ?? []).length > 0 && (
<View className="mt-2">
<Text className="text-xs text-gray-400 mb-1">{t('settings.pending')}</Text>
{data!.pendingInvitations.map((inv: PendingInvitation) => (
<View
key={inv.id}
className="flex-row items-center justify-between py-3 border-b border-gray-100"
>
<View className="flex-row items-center gap-3">
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-100">
<Ionicons name="mail-outline" size={16} color="#9ca3af" />
</View>
<Text className="text-base text-gray-500">{inv.email}</Text>
</View>
<Pressable onPress={() => handleRevoke(inv)} className="p-1 active:opacity-50">
<Ionicons name="close-circle-outline" size={20} color="#dc2626" />
</Pressable>
</View>
))}
</View>
)}
{/* Invite button */}
<Pressable
onPress={() => setShowInviteModal(true)}
className="mt-3 flex-row items-center justify-center gap-1.5 rounded-lg border border-blue-200 py-3 active:opacity-70"
>
<Ionicons name="person-add-outline" size={16} color="#2563EB" />
<Text className="text-sm font-medium text-blue-600">{t('settings.invitePerson')}</Text>
</Pressable>
</View>
<InviteCodeModal visible={showInviteModal} onClose={() => setShowInviteModal(false)} />
</>
);
}
// ── Main Screen ────────────────────────────────────────────────────────────────
export default function SettingsScreen() {
const { user, households, activeHouseholdId, setActiveHousehold } = useAuthStore();
const queryClient = useQueryClient();
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { data: hhSettings } = useHouseholdSettings();
const { mutate: updateSettings } = useUpdateHouseholdSettings();
// Apply saved language preference when settings load
useEffect(() => {
if (hhSettings?.language && hhSettings.language !== "auto") {
void i18n.changeLanguage(hhSettings.language);
}
}, [hhSettings?.language]);
function handleLanguageChange() {
const deviceLanguage = Localization.getLocales()[0]?.languageCode ?? "de";
Alert.alert(t('settings.language'), undefined, [
{
text: t('settings.languageAuto'),
onPress: () => {
void i18n.changeLanguage(deviceLanguage);
updateSettings({ language: "auto" });
},
},
{
text: t('settings.languageDe'),
onPress: () => {
void i18n.changeLanguage("de");
updateSettings({ language: "de" });
},
},
{
text: t('settings.languageEn'),
onPress: () => {
void i18n.changeLanguage("en");
updateSettings({ language: "en" });
},
},
{ text: t('common.cancel'), style: "cancel" },
]);
}
async function handleSwitch(household: { id: string; name: string }) {
if (household.id === activeHouseholdId) return;
setActiveHousehold(household.id);
await queryClient.invalidateQueries();
showToast(t('settings.switchedTo', { name: household.name }));
}
async function handleSignOut() {
await signOut();
useAuthStore.getState().clearAuth();
router.replace("/(auth)/login");
}
return (
<ScrollView
className="flex-1 bg-gray-50"
contentContainerStyle={{ padding: 16, paddingTop: insets.top + 8 }}
>
{/* Back + Title */}
<View className="flex-row items-center mb-5">
<Pressable onPress={() => router.push("/(app)/mehr")} className="mr-3 p-1">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
<Text className="text-xl font-bold text-gray-900">{t('settings.title')}</Text>
</View>
{/* User Info */}
<View className="mb-6 rounded-xl bg-white p-4">
<Text className="text-xs font-medium uppercase text-gray-400 mb-2">{t('settings.account')}</Text>
<Text className="text-base font-semibold text-gray-900">{user?.name}</Text>
<Text className="text-sm text-gray-500">{user?.email}</Text>
</View>
{/* Household Switcher */}
<View className="mb-6 rounded-xl bg-white p-4">
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.households')}</Text>
{households.map((h) => (
<Pressable
key={h.id}
onPress={() => handleSwitch(h)}
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70 last:border-b-0"
>
<View>
<Text className="text-base text-gray-900">{h.name}</Text>
<Text className="text-xs text-gray-400 capitalize">{h.role}</Text>
</View>
{activeHouseholdId === h.id && (
<Ionicons name="checkmark-circle" size={20} color="#2563EB" />
)}
</Pressable>
))}
<Pressable
onPress={() => router.push("/(auth)/onboarding")}
className="mt-3 flex-row items-center justify-center gap-1.5 rounded-lg border border-blue-200 py-3 active:opacity-70"
>
<Ionicons name="add-circle-outline" size={16} color="#2563EB" />
<Text className="text-sm font-medium text-blue-600">{t('onboarding.createHousehold')}</Text>
</Pressable>
</View>
{/* Members + Invite */}
<MembersSection />
{/* Household Settings */}
<View className="mb-6 rounded-xl bg-white p-4">
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('tabs.household')}</Text>
<Pressable
onPress={() => router.push("/(app)/settings/household")}
className="flex-row items-center justify-between py-3 active:opacity-70"
>
<View className="flex-row items-center gap-3">
<Ionicons name="people-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-900">{t('settings.householdPartner')}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</Pressable>
</View>
{/* App Settings */}
<View className="mb-6 rounded-xl bg-white p-4">
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.appSection')}</Text>
<Pressable
onPress={() => router.push("/(app)/settings/categories")}
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
>
<View className="flex-row items-center gap-3">
<Ionicons name="pricetags-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-900">{t('settings.categories')}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</Pressable>
<Pressable
onPress={() => router.push("/(app)/settings/fixed-costs")}
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
>
<View className="flex-row items-center gap-3">
<Ionicons name="repeat-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-900">{t('settings.fixedCosts')}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</Pressable>
<Pressable
onPress={() => router.push("/(app)/settings/transfer-line-items")}
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
>
<View className="flex-row items-center gap-3">
<Ionicons name="swap-horizontal-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-900">{t('settings.transferItems')}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</Pressable>
<Pressable
onPress={handleLanguageChange}
className="flex-row items-center justify-between py-3 active:opacity-70"
>
<View className="flex-row items-center gap-3">
<Ionicons name="language-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-900">{t('settings.language')}</Text>
</View>
<View className="flex-row items-center gap-1">
<Text className="text-sm text-gray-400">
{(() => {
switch (hhSettings?.language) {
case "de": return t('settings.languageDe');
case "en": return t('settings.languageEn');
default: return t('settings.languageAuto');
}
})()}
</Text>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</View>
</Pressable>
</View>
{/* Sign Out */}
<Pressable
onPress={handleSignOut}
className="rounded-xl bg-red-50 p-4 flex-row items-center justify-center gap-2 active:opacity-70"
>
<Ionicons name="log-out-outline" size={18} color="#dc2626" />
<Text className="text-base font-semibold text-red-600">{t('settings.logout')}</Text>
</Pressable>
</ScrollView>
);
}

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>
);
}