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:
269
apps/native/app/(app)/settings/household.tsx
Normal file
269
apps/native/app/(app)/settings/household.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user