- 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>
443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|