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
+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,
});
}