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,860 @@
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { TAB_COLORS } from "@/src/constants/colors";
import {
useTrip,
useTripExpenses,
useCreateTripExpense,
useDeleteTripExpense,
useCompleteTrip,
type TripExpense,
type TripSettlement,
type CreateTripExpenseInput,
} from "@/src/hooks/useTrips";
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
import { useAuthStore } from "@/src/stores/auth.store";
import { formatEur } from "@/src/utils/format";
import { todayIso } from "@/src/utils/date";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const ACCENT = TAB_COLORS.shopping; // green #16A34A
// ── Category config ───────────────────────────────────────────────────────────
type ExpenseCategory = TripExpense["category"];
const CATEGORY_ICONS: Record<ExpenseCategory, React.ComponentProps<typeof Ionicons>["name"]> = {
unterkunft: "bed-outline",
essen: "restaurant-outline",
transport: "car-outline",
aktivitaeten: "ticket-outline",
sonstiges: "cube-outline",
};
const CATEGORY_ORDER: ExpenseCategory[] = [
"unterkunft",
"essen",
"transport",
"aktivitaeten",
"sonstiges",
];
// ── 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";
if (remaining < budget * 0.1) return "#ea580c";
return ACCENT;
}
// ── Progress Bar ──────────────────────────────────────────────────────────────
function ProgressBar({
spent,
budget,
color,
height = 6,
}: {
spent: number;
budget: number;
color: string;
height?: number;
}) {
const ratio = budget > 0 ? Math.min(spent / budget, 1) : 0;
return (
<View
style={{ height, borderRadius: height / 2, backgroundColor: "#f3f4f6", overflow: "hidden" }}
>
<View
style={{
width: `${ratio * 100}%`,
height,
backgroundColor: color,
borderRadius: height / 2,
}}
/>
</View>
);
}
// ── Budget Summary Card ───────────────────────────────────────────────────────
function BudgetSummaryCard({
budget,
totalSpent,
remaining,
}: {
budget: number;
totalSpent: number;
remaining: number;
}) {
const { t } = useTranslation();
const color = getBudgetColor(remaining, budget);
const isOver = remaining <= 0;
return (
<View
className="mx-4 mt-4 bg-white rounded-2xl p-4"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<View className="flex-row justify-between mb-3">
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t("trips.budget")}</Text>
<Text className="text-lg font-bold text-gray-900">{formatEur(budget)}</Text>
</View>
<View className="w-px bg-gray-100" />
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t("trips.spent")}</Text>
<Text className="text-lg font-bold text-gray-700">{formatEur(totalSpent)}</Text>
</View>
<View className="w-px bg-gray-100" />
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t("trips.remaining")}</Text>
<Text className="text-lg font-bold" style={{ color }}>
{isOver ? `${formatEur(Math.abs(remaining))}` : formatEur(remaining)}
</Text>
</View>
</View>
<ProgressBar spent={totalSpent} budget={budget} color={color} height={8} />
</View>
);
}
// ── Category Section ──────────────────────────────────────────────────────────
function CategorySection({
byCategory,
budget,
}: {
byCategory: Record<string, number>;
budget: number;
}) {
const { t } = useTranslation();
const categories = CATEGORY_ORDER.filter((cat) => (byCategory[cat] ?? 0) > 0);
if (categories.length === 0) return null;
return (
<View className="mx-4 mt-4 bg-white rounded-2xl overflow-hidden" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-3 pb-2">
{t("trips.budget")} nach Kategorie
</Text>
{categories.map((cat, index) => {
const amount = byCategory[cat] ?? 0;
const icon = CATEGORY_ICONS[cat];
return (
<View
key={cat}
className="px-4 py-3"
style={index < categories.length - 1 ? { borderBottomWidth: 1, borderBottomColor: "#f9fafb" } : undefined}
>
<View className="flex-row items-center mb-1.5">
<View
className="w-7 h-7 rounded-lg items-center justify-center mr-3"
style={{ backgroundColor: `${ACCENT}18` }}
>
<Ionicons name={icon} size={14} color={ACCENT} />
</View>
<Text className="text-sm text-gray-700 flex-1">
{t(`trips.categories.${cat}`)}
</Text>
<Text className="text-sm font-semibold text-gray-800">{formatEur(amount)}</Text>
</View>
<View className="ml-10">
<ProgressBar spent={amount} budget={budget} color={ACCENT} height={3} />
</View>
</View>
);
})}
</View>
);
}
// ── Expense Row ───────────────────────────────────────────────────────────────
function ExpenseRow({
expense,
memberName,
onDelete,
}: {
expense: TripExpense;
memberName: string;
onDelete: () => void;
}) {
const { t } = useTranslation();
const icon = CATEGORY_ICONS[expense.category];
return (
<View className="flex-row items-center px-4 py-3 bg-white">
<View
className="w-9 h-9 rounded-xl items-center justify-center mr-3"
style={{ backgroundColor: `${ACCENT}18` }}
>
<Ionicons name={icon} size={18} color={ACCENT} />
</View>
<View className="flex-1 mr-2">
<Text className="text-sm font-medium text-gray-800">{expense.label}</Text>
<Text className="text-xs text-gray-400">
{t("trips.paidBy", { name: memberName })} · {expense.date}
</Text>
{expense.note && (
<Text className="text-xs text-gray-400 italic">{expense.note}</Text>
)}
</View>
<Text className="text-sm font-semibold text-gray-700 mr-3">{formatEur(expense.amount)}</Text>
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
</TouchableOpacity>
</View>
);
}
// ── Closed Banner ─────────────────────────────────────────────────────────────
function ClosedBanner({
settlementAmount,
settlementFromUserId,
settlementToUserId,
getMemberName,
}: {
settlementAmount: number | null;
settlementFromUserId: string | null;
settlementToUserId: string | null;
getMemberName: (userId: string) => string;
}) {
const { t } = useTranslation();
const hasSettlement =
settlementAmount !== null &&
settlementAmount > 0.01 &&
settlementFromUserId !== null &&
settlementToUserId !== null;
return (
<View
className="mx-4 mt-4 rounded-2xl px-4 py-3"
style={{ backgroundColor: "#dcfce7" }}
>
<View className="flex-row items-center gap-2 mb-1">
<Ionicons name="lock-closed" size={14} color="#15803d" />
<Text className="text-sm font-semibold" style={{ color: "#15803d" }}>
{t("trips.settlement.closedBanner")}
</Text>
</View>
{hasSettlement && settlementFromUserId !== null && settlementToUserId !== null ? (
<Text className="text-xs" style={{ color: "#166534" }}>
{t("trips.settlement.settledInfo", {
from: getMemberName(settlementFromUserId),
to: getMemberName(settlementToUserId),
amount: formatEur(settlementAmount ?? 0),
})}
</Text>
) : (
<Text className="text-xs" style={{ color: "#166534" }}>
{t("trips.settlement.balanced")}
</Text>
)}
</View>
);
}
// ── Settlement Modal ──────────────────────────────────────────────────────────
function SettlementModal({
settlement,
onConfirm,
onClose,
isPending,
}: {
settlement: TripSettlement;
onConfirm: () => void;
onClose: () => void;
isPending: boolean;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const hasSettlement = settlement.settlement !== null;
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom + 16 }}>
{/* Header */}
<View
className="flex-row items-center justify-between px-4 py-4"
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
>
<Text className="text-lg font-bold text-gray-900">
{t("trips.settlement.title")}
</Text>
<Pressable onPress={onClose} className="p-1 active:opacity-70">
<Ionicons name="close" size={22} color="#6b7280" />
</Pressable>
</View>
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 4 }}>
{/* Total row */}
<View className="flex-row justify-between py-3">
<Text className="text-sm text-gray-600">{t("trips.settlement.total")}</Text>
<Text className="text-sm font-semibold text-gray-900">
{formatEur(settlement.total)}
</Text>
</View>
{/* Fair share row */}
<View className="flex-row justify-between py-3">
<Text className="text-sm text-gray-600">{t("trips.settlement.fairShare")}</Text>
<Text className="text-sm font-semibold text-gray-900">
{formatEur(settlement.fairShare)}
</Text>
</View>
{/* Divider */}
<View style={{ height: 1, backgroundColor: "#f3f4f6", marginVertical: 4 }} />
{/* Per-person rows */}
{settlement.balances.map((balance) => (
<View key={balance.userId} className="py-3">
<View className="flex-row justify-between mb-1.5">
<Text className="text-sm font-medium text-gray-700">{balance.name}</Text>
<Text className="text-sm text-gray-500">
{formatEur(balance.paid)} {t("trips.settlement.paid")}
</Text>
</View>
<ProgressBar
spent={balance.paid}
budget={settlement.total > 0 ? settlement.total : 1}
color={balance.balance >= 0 ? ACCENT : "#f97316"}
height={4}
/>
</View>
))}
{/* Divider */}
<View style={{ height: 1, backgroundColor: "#f3f4f6", marginVertical: 4 }} />
{/* Settlement result box */}
<View
className="rounded-2xl p-4 mt-2"
style={{ backgroundColor: hasSettlement ? "#dbeafe" : "#dcfce7" }}
>
{hasSettlement && settlement.settlement !== null ? (
<>
<Text
className="text-xs font-medium mb-1"
style={{ color: hasSettlement ? "#1d4ed8" : "#15803d" }}
>
{t("trips.settlement.owes", {
from: settlement.settlement.fromName,
to: settlement.settlement.toName,
})}
</Text>
<Text
className="text-2xl font-bold"
style={{ color: hasSettlement ? "#1e40af" : "#166534" }}
>
{formatEur(settlement.settlement.amount)}
</Text>
</>
) : (
<View className="flex-row items-center gap-2">
<Ionicons name="checkmark-circle" size={18} color="#15803d" />
<Text className="text-sm font-medium" style={{ color: "#15803d" }}>
{t("trips.settlement.balanced")}
</Text>
</View>
)}
</View>
<View className="h-4" />
{/* Confirm button */}
<Pressable
onPress={onConfirm}
disabled={isPending}
className="rounded-2xl py-4 items-center active:opacity-80"
style={{ backgroundColor: ACCENT }}
>
{isPending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-base font-semibold text-white">
{t("trips.settlement.closeTrip")}
</Text>
)}
</Pressable>
{/* Cancel text button */}
<Pressable onPress={onClose} className="py-3 items-center active:opacity-70">
<Text className="text-sm text-gray-500">{t("common.cancel")}</Text>
</Pressable>
</ScrollView>
</View>
</Modal>
);
}
// ── Add Expense Modal ─────────────────────────────────────────────────────────
function AddExpenseModal({
tripId,
onClose,
}: {
tripId: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const userId = useAuthStore((s) => s.user?.id ?? "");
const { data: membersData } = useHouseholdMembers();
const members = membersData?.members ?? [];
const { mutate: createExpense, isPending } = useCreateTripExpense(tripId);
const [label, setLabel] = useState("");
const [amountStr, setAmountStr] = useState("0");
const [category, setCategory] = useState<ExpenseCategory>("sonstiges");
const [paidBy, setPaidBy] = useState(userId);
const [date, setDate] = useState(todayIso());
const [note, setNote] = useState("");
function handleNumpadKey_(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
const amount = parseAmountStr(amountStr);
const canSave = label.trim().length > 0 && amount > 0 && date.length === 10;
function handleSave() {
if (!canSave) return;
const input: CreateTripExpenseInput = {
label: label.trim(),
amount,
category,
paidBy,
date,
...(note.trim() ? { note: note.trim() } : {}),
};
createExpense(input, { onSuccess: onClose });
}
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white">
<ModalHeader
title={t("trips.newExpense")}
onClose={onClose}
closeLabel={t("common.cancel")}
onSave={handleSave}
saveLabel={t("common.save")}
saveDisabled={!canSave}
saveLoading={isPending}
saveColor={ACCENT}
/>
<ScrollView className="flex-1" keyboardShouldPersistTaps="handled">
{/* Amount display */}
<View className="items-center py-5">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
<Text className="text-xs text-gray-400 mt-1">{t("trips.spent")}</Text>
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpadKey_} />
{/* Fields */}
<View className="px-4 mt-4 gap-3">
{/* Label */}
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t("trips.label")}
placeholderTextColor="#9ca3af"
value={label}
onChangeText={setLabel}
/>
{/* Category chips */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="-mx-4 px-4">
<View className="flex-row gap-2">
{CATEGORY_ORDER.map((cat) => {
const selected = category === cat;
return (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
className="flex-row items-center gap-1.5 px-3 py-2 rounded-full"
style={{
backgroundColor: selected ? ACCENT : "#f3f4f6",
}}
>
<Ionicons
name={CATEGORY_ICONS[cat]}
size={14}
color={selected ? "#fff" : "#6b7280"}
/>
<Text
className="text-xs font-medium"
style={{ color: selected ? "#fff" : "#4b5563" }}
>
{t(`trips.categories.${cat}`)}
</Text>
</Pressable>
);
})}
</View>
</ScrollView>
{/* Paid by */}
{members.length > 1 && (
<View className="flex-row gap-2">
{members.map((m) => {
const selected = paidBy === m.userId;
return (
<Pressable
key={m.userId}
onPress={() => setPaidBy(m.userId)}
className="flex-1 py-2.5 rounded-xl items-center"
style={{
backgroundColor: selected ? ACCENT : "#f3f4f6",
}}
>
<Text
className="text-sm font-medium"
style={{ color: selected ? "#fff" : "#4b5563" }}
>
{m.name}
</Text>
</Pressable>
);
})}
</View>
)}
{/* Date */}
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder="YYYY-MM-DD"
placeholderTextColor="#9ca3af"
value={date}
onChangeText={setDate}
keyboardType="numbers-and-punctuation"
maxLength={10}
/>
{/* Note */}
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t("trips.note")}
placeholderTextColor="#9ca3af"
value={note}
onChangeText={setNote}
/>
</View>
<View className="h-8" />
</ScrollView>
</View>
</Modal>
);
}
// ── Main Detail Screen ────────────────────────────────────────────────────────
export default function TripDetailScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: summary, isLoading: summaryLoading } = useTrip(id);
const { data: expenses = [], isLoading: expensesLoading } = useTripExpenses(id);
const { data: membersData } = useHouseholdMembers();
const { mutate: deleteExpense } = useDeleteTripExpense(id);
const { mutate: completeTrip, isPending: completing } = useCompleteTrip();
const [showAddExpense, setShowAddExpense] = useState(false);
const [showSettlementModal, setShowSettlementModal] = useState(false);
const [settlementPreview, setSettlementPreview] = useState<TripSettlement | null>(null);
const [isLoadingSettlement, setIsLoadingSettlement] = useState(false);
const members = membersData?.members ?? [];
function getMemberName(userId: string): string {
return members.find((m) => m.userId === userId)?.name ?? userId;
}
function handleDeleteExpense(expenseId: string) {
Alert.alert(
t("common.delete"),
t("common.confirm") + "?",
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: () => deleteExpense(expenseId),
},
],
);
}
async function handleComplete() {
if (!summary) return;
if (expenses.length === 0) {
Alert.alert(
t("trips.settlement.title"),
t("trips.settlement.noExpenses"),
);
return;
}
setIsLoadingSettlement(true);
try {
const { apiRequest } = await import("@/src/lib/api-client");
const result = await apiRequest<{ settlement: TripSettlement }>(
`/api/trips/${id}/settlement`,
);
setSettlementPreview(result.settlement);
setShowSettlementModal(true);
} catch {
Alert.alert(t("common.error"), t("common.error"));
} finally {
setIsLoadingSettlement(false);
}
}
function handleConfirmComplete() {
completeTrip(id, {
onSuccess: () => {
setShowSettlementModal(false);
router.back();
},
});
}
if (summaryLoading) {
return (
<View className="flex-1 bg-gray-50 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
);
}
if (!summary) {
return (
<View className="flex-1 bg-gray-50 items-center justify-center">
<Text className="text-gray-400">Nicht gefunden</Text>
</View>
);
}
const { trip, totalSpent, remaining, byCategory } = summary;
const isActive = trip.status === "active";
type ListSection =
| { key: "closed-banner" }
| { key: "budget" }
| { key: "category" }
| { key: "expense-header" }
| { key: `expense-${string}`; expense: TripExpense };
const sections: ListSection[] = [];
if (!isActive) {
sections.push({ key: "closed-banner" });
}
sections.push(
{ key: "budget" },
{ key: "category" },
{ key: "expense-header" },
...expenses.map(
(e): ListSection => ({ key: `expense-${e.id}`, expense: e }),
),
);
function renderSection({ item }: { item: ListSection }) {
if (item.key === "closed-banner") {
return (
<ClosedBanner
settlementAmount={trip.settlementAmount}
settlementFromUserId={trip.settlementFromUserId}
settlementToUserId={trip.settlementToUserId}
getMemberName={getMemberName}
/>
);
}
if (item.key === "budget") {
return (
<BudgetSummaryCard
budget={trip.budget}
totalSpent={totalSpent}
remaining={remaining}
/>
);
}
if (item.key === "category") {
return <CategorySection byCategory={byCategory} budget={trip.budget} />;
}
if (item.key === "expense-header") {
return (
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
Ausgaben
</Text>
{isActive && (
<Pressable
onPress={() => setShowAddExpense(true)}
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full active:opacity-70"
style={{ backgroundColor: ACCENT }}
>
<Ionicons name="add" size={14} color="#fff" />
<Text className="text-xs font-semibold text-white">{t("trips.newExpense")}</Text>
</Pressable>
)}
</View>
);
}
if (item.key.startsWith("expense-") && "expense" in item) {
return (
<View
className="mx-4 bg-white rounded-xl mb-2"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<ExpenseRow
expense={item.expense}
memberName={getMemberName(item.expense.paidBy)}
onDelete={() => handleDeleteExpense(item.expense.id)}
/>
</View>
);
}
return null;
}
const completeDisabled = completing || isLoadingSettlement || expenses.length === 0;
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"
>
<Pressable onPress={() => router.back()} className="mr-3 p-1">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
<View className="flex-1">
<Text className="text-base font-bold text-gray-900">{trip.name}</Text>
<Text className="text-xs text-gray-400">
{trip.destination ? `${trip.destination} · ` : ""}
{formatDateRange(trip.startDate, trip.endDate)}
</Text>
</View>
{isActive && (
<Pressable
onPress={() => void handleComplete()}
disabled={completeDisabled}
className="flex-row items-center gap-1.5 px-3 py-2 rounded-xl active:opacity-70"
style={{
backgroundColor: completeDisabled ? "#e5e7eb" : "#f3f4f6",
}}
>
{completing || isLoadingSettlement ? (
<ActivityIndicator size="small" color="#6b7280" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={16}
color={expenses.length === 0 ? "#d1d5db" : "#6b7280"}
/>
<Text
className="text-xs font-semibold"
style={{ color: expenses.length === 0 ? "#d1d5db" : "#6b7280" }}
>
{t("trips.complete")}
</Text>
</>
)}
</Pressable>
)}
</View>
{expensesLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
) : (
<FlatList
data={sections}
keyExtractor={(item) => item.key}
renderItem={renderSection}
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
ListEmptyComponent={null}
/>
)}
{/* FAB */}
{isActive && (
<Pressable
onPress={() => setShowAddExpense(true)}
style={{ backgroundColor: ACCENT, bottom: insets.bottom + 20 }}
className="absolute right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
>
<Ionicons name="add" size={28} color="#fff" />
</Pressable>
)}
{showAddExpense && (
<AddExpenseModal tripId={id} onClose={() => setShowAddExpense(false)} />
)}
{showSettlementModal && settlementPreview !== null && (
<SettlementModal
settlement={settlementPreview}
onConfirm={handleConfirmComplete}
onClose={() => setShowSettlementModal(false)}
isPending={completing}
/>
)}
</View>
);
}