- 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>
166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|