Stabilize mobile auth session handling

This commit is contained in:
2026-06-29 17:58:50 -04:00
parent 477459edd4
commit 57e1aa658a
25 changed files with 1126 additions and 192 deletions
+94 -32
View File
@@ -1,6 +1,7 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
RefreshControl,
ScrollView,
@@ -15,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
@@ -38,6 +40,12 @@ export default function EntitiesScreen() {
const clientsQuery = api.clients.getAll.useQuery();
const businessesQuery = api.businesses.getAll.useQuery();
const deleteClient = api.clients.delete.useMutation({
onSuccess: () => void clientsQuery.refetch(),
});
const deleteBusiness = api.businesses.delete.useMutation({
onSuccess: () => void businessesQuery.refetch(),
});
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading =
@@ -68,6 +76,20 @@ export default function EntitiesScreen() {
else void businessesQuery.refetch();
}
function confirmDelete(id: string, name: string) {
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
if (tab === "clients") deleteClient.mutate({ id });
else deleteBusiness.mutate({ id });
},
},
]);
}
return (
<AppBackground>
<TabPage>
@@ -112,25 +134,45 @@ export default function EntitiesScreen() {
</View>
) : (
clients.map((client) => (
<Pressable
<SwipeableRow
key={client.id}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
backgroundColor={colors.cardGlass}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(client.id, client.name),
},
]}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</Pressable>
<Pressable onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</Pressable>
</SwipeableRow>
))
)
) : businesses.length === 0 ? (
@@ -142,25 +184,45 @@ export default function EntitiesScreen() {
</View>
) : (
businesses.map((business) => (
<Pressable
<SwipeableRow
key={business.id}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
backgroundColor={colors.cardGlass}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(business.id, business.name),
},
]}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
<Pressable onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</Pressable>
</GlassSurface>
</Pressable>
</SwipeableRow>
))
)}
</TabScrollView>
+37 -2
View File
@@ -20,6 +20,7 @@ import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
export default function DashboardScreen() {
@@ -41,7 +42,7 @@ export default function DashboardScreen() {
<Screen>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load dashboard</Text>
<Text style={styles.errorText}>{statsQuery.error.message}</Text>
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
</View>
</Screen>
</AppBackground>
@@ -62,7 +63,7 @@ export default function DashboardScreen() {
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.sendReminderDue ?? [];
const sendReminderDue = stats.recentInvoices.filter((inv) => inv.status === "draft");
return (
<AppBackground>
@@ -146,8 +147,31 @@ export default function DashboardScreen() {
variant="secondary"
onPress={() => router.push("/(app)/invoices")}
/>
<Button
title="Reports"
variant="secondary"
onPress={() => router.push("/(app)/more/reports" as never)}
/>
</View>
{stats.currentDraft ? (
<GlassSurface style={styles.alertGlass}>
<Pressable
style={styles.alertBanner}
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
>
<Text style={styles.alertTitle}>
Draft {stats.currentDraft.invoiceNumber}
</Text>
<Text style={styles.alertText}>
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
{stats.currentDraft.totalHours.toFixed(1)}h logged
</Text>
</Pressable>
</GlassSurface>
) : null}
<View style={styles.statsGrid}>
<View style={styles.statCell}>
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
@@ -190,6 +214,17 @@ export default function DashboardScreen() {
</View>
</Card>
<Card title="Invoice status">
{(stats.statusChartData ?? []).map((item) => (
<View key={item.status} style={styles.invoiceRow}>
<Text style={styles.invoiceClient}>{item.name}</Text>
<Text style={styles.invoiceDate}>
{item.count} · {formatCurrency(item.value)}
</Text>
</View>
))}
</Card>
<Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
+42 -15
View File
@@ -8,6 +8,7 @@ import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -23,6 +24,7 @@ import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() {
const styles = useThemedStyles(createInvoiceDetailStyles);
const { colors } = useAppTheme();
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
@@ -50,10 +52,9 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send reminder", err.message),
});
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
[invoiceQuery.data],
);
if (!id) {
@@ -78,6 +79,7 @@ export default function InvoiceDetailScreen() {
);
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
@@ -209,20 +211,45 @@ export default function InvoiceDetailScreen() {
add lines manually.
</Text>
) : (
invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
invoice.items.map((item) => {
const line = (
<View style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
))
);
if (invoice.status !== "draft") {
return <View key={item.id}>{line}</View>;
}
return (
<SwipeableRow
key={item.id}
backgroundColor={colors.card}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
]}
>
{line}
</SwipeableRow>
);
})
)}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
+10
View File
@@ -196,6 +196,15 @@ export default function InvoiceEditScreen() {
setItems((prev) => prev.filter((_, i) => i !== index));
}
function duplicateItem(index: number) {
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
const copy = { ...source, id: undefined };
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
});
}
async function handleSave() {
if (!canSave) return;
setError(null);
@@ -325,6 +334,7 @@ export default function InvoiceEditScreen() {
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
readOnly={!isDraft}
/>
))}
+94 -26
View File
@@ -16,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -25,6 +26,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
@@ -49,6 +51,14 @@ export default function InvoicesScreen() {
onError: (err) => Alert.alert("Update failed", err.message),
});
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
utils.invoices.getAll.invalidate();
utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Delete failed", err.message),
});
if (invoicesQuery.isLoading) {
return <LoadingScreen message="Loading invoices…" />;
}
@@ -59,7 +69,7 @@ export default function InvoicesScreen() {
<TabPage>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load invoices</Text>
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text>
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
</View>
</TabPage>
</AppBackground>
@@ -93,6 +103,17 @@ export default function InvoicesScreen() {
]);
}
function confirmDelete(invoiceId: string, label: string) {
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
},
]);
}
return (
<AppBackground>
<TabPage>
@@ -134,35 +155,82 @@ export default function InvoicesScreen() {
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
const actions = [
{
key: "open",
label: "Open",
icon: "open-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
},
...(status === "draft"
? [
{
key: "edit",
label: "Edit",
icon: "create-outline" as const,
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
{
key: "send",
label: "Send",
icon: "send-outline" as const,
color: "#fff",
backgroundColor: colors.success,
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(invoice.id, label),
},
]
: [
{
key: "status",
label: "Status",
icon: "flag-outline" as const,
color: "#fff",
backgroundColor: colors.warning,
onPress: () => promptStatusChange(invoice.id, status),
},
]),
];
return (
<Pressable
key={invoice.id}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}>
<Pressable
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}>{label}</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View>
<Text style={styles.amount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
</View>
<Text style={styles.amount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<View style={styles.cardBottom}>
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
<StatusBadge status={status} />
</View>
</View>
<View style={styles.cardBottom}>
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
<StatusBadge status={status} />
</View>
</View>
</GlassSurface>
</Pressable>
</GlassSurface>
</Pressable>
</SwipeableRow>
);
})
)}
+10
View File
@@ -203,6 +203,15 @@ export default function NewInvoiceScreen() {
setItems((prev) => prev.filter((_, i) => i !== index));
}
function duplicateItem(index: number) {
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
const copy = { ...source, id: undefined };
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
});
}
function handleCreate() {
if (!canCreate) return;
setError(null);
@@ -327,6 +336,7 @@ export default function NewInvoiceScreen() {
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
onDuplicate={() => duplicateItem(index)}
/>
))}
+6 -5
View File
@@ -51,10 +51,10 @@ export default function InvoiceSendScreen() {
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
() =>
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
[invoiceQuery.data],
);
if (!id) {
@@ -65,10 +65,11 @@ export default function InvoiceSendScreen() {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
if (!invoiceQuery.data) {
return <LoadingScreen message="Invoice not found" />;
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
const businessName = invoice.business?.name ?? "Your business";
@@ -91,7 +92,7 @@ export default function InvoiceSendScreen() {
}
sendInvoice.mutate({
invoiceId: invoice!.id,
invoiceId: invoice.id,
customMessage: customMessage.trim() || undefined,
});
}
+8 -3
View File
@@ -21,6 +21,7 @@ import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -89,16 +90,20 @@ export default function SettingsScreen() {
async (result) => {
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
signOut: () => authClient.signOut(),
activeAccountId,
});
},
);
}
async function handleSignOut() {
await authClient.signOut();
await clearActiveAccount();
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in");
}