Refresh home and more navigation

This commit is contained in:
2026-07-01 02:46:39 -04:00
parent 6497ee4dec
commit 530d0dc34d
21 changed files with 1043 additions and 594 deletions
+286 -150
View File
@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
@@ -8,6 +9,8 @@ import {
Text,
View,
} from "react-native";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
@@ -17,15 +20,16 @@ import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type ExpenseFilter = "all" | "billable" | "receipts";
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
export default function ExpensesScreen() {
const { colors } = useAppTheme();
@@ -48,24 +52,36 @@ export default function ExpensesScreen() {
}),
[expenses, filter],
);
const total = filteredExpenses.reduce(
(sum, expense) => sum + expense.amount,
0,
);
const receiptCount = filteredExpenses.reduce(
(sum, expense) => sum + (expense.receiptCount ?? 0),
0,
const summary = useMemo(() => {
const total = filteredExpenses.reduce(
(sum, expense) => sum + expense.amount,
0,
);
const billable = filteredExpenses.reduce(
(sum, expense) => sum + (expense.billable ? expense.amount : 0),
0,
);
const receiptCount = filteredExpenses.reduce(
(sum, expense) => sum + (expense.receiptCount ?? 0),
0,
);
return { total, billable, receiptCount };
}, [filteredExpenses]);
const groupedExpenses = useMemo(
() => groupExpensesByMonth(filteredExpenses),
[filteredExpenses],
);
if (expensesQuery.isLoading) {
return <LoadingScreen message="Loading expenses" />;
return <LoadingScreen message="Loading expenses..." />;
}
if (expensesQuery.error) {
return (
<AppBackground>
<TabPage>
<TabPage showMoreBack>
<View style={styles.errorBox}>
<PageHeader title="Expenses" subtitle="Expense tracking" />
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
Could not load expenses
</Text>
@@ -80,19 +96,19 @@ export default function ExpensesScreen() {
return (
<AppBackground>
<TabPage>
<TabPage showMoreBack>
<TabScrollView
header={
<>
<View style={styles.header}>
<PageHeader
title="Expenses"
subtitle={`${expenses.length} recorded`}
subtitle={`${expenses.length} recorded expense${expenses.length === 1 ? "" : "s"}`}
/>
<Button
title="Add expense"
onPress={() => router.push("/(app)/more/expenses/new" as never)}
/>
</>
</View>
}
refreshControl={
<RefreshControl
@@ -103,42 +119,32 @@ export default function ExpensesScreen() {
}
>
{expenses.length === 0 ? (
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
No expenses yet. Add one with a receipt photo or manual entry.
</Text>
<View
style={[
styles.emptyCard,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
</View>
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
No expenses yet
</Text>
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
</Text>
<Button
title="Add expense"
onPress={() => router.push("/(app)/more/expenses/new" as never)}
/>
</View>
) : (
<>
<View style={styles.summary}>
<View>
<Text
style={[
styles.summaryLabel,
{ color: colors.mutedForeground },
]}
>
Visible total
</Text>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
>
{formatCurrency(total)}
</Text>
</View>
<View style={styles.summaryRight}>
<Text
style={[
styles.summaryLabel,
{ color: colors.mutedForeground },
]}
>
Receipts
</Text>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
>
{receiptCount}
</Text>
</View>
<View style={styles.summaryGrid}>
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
</View>
<ScrollView
@@ -168,87 +174,19 @@ export default function ExpensesScreen() {
No expenses match this filter.
</Text>
) : (
filteredExpenses.map((expense) => (
<SwipeableRow
key={expense.id}
actions={[
{
key: "open",
label: "Open",
icon: "open-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () =>
router.push(
`/(app)/more/expenses/${expense.id}` as never,
),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => deleteExpense.mutate({ id: expense.id }),
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() =>
router.push(
`/(app)/more/expenses/${expense.id}` as never,
)
}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description}
</Text>
{expense.receiptCount ? (
<Text
style={[
styles.receiptPill,
{
color: colors.primary,
borderColor: colors.border,
},
]}
>
{expense.receiptCount}
</Text>
) : null}
</View>
<Text
style={[
styles.sub,
{ color: colors.mutedForeground },
]}
numberOfLines={2}
>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name
? ` · ${expense.client.name}`
: ""}
{expense.billable ? " · Billable" : ""}
</Text>
</View>
<Text
style={[styles.amount, { color: colors.foreground }]}
>
{formatCurrency(expense.amount, expense.currency)}
</Text>
</Pressable>
</SwipeableRow>
groupedExpenses.map(([monthLabel, group]) => (
<View key={monthLabel} style={styles.monthGroup}>
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
{monthLabel}
</Text>
{group.map((expense) => (
<ExpenseRow
key={expense.id}
expense={expense}
onDelete={() => deleteExpense.mutate({ id: expense.id })}
/>
))}
</View>
))
)}
</>
@@ -259,23 +197,171 @@ export default function ExpensesScreen() {
);
}
function SummaryTile({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
return (
<View
style={[
styles.summaryTile,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
{value}
</Text>
</View>
);
}
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
return (
<SwipeableRow
backgroundColor={colors.cardGlass}
actions={[
{
key: "open",
label: "Open",
icon: "open-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: onDelete,
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
style={({ pressed }) => [
styles.row,
{ borderColor: colors.border },
pressed && styles.rowPressed,
]}
>
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
</View>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
{expense.description}
</Text>
{expense.receiptCount ? (
<View
style={[
styles.receiptPill,
{ borderColor: colors.border, backgroundColor: colors.background },
]}
>
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount}
</Text>
</View>
) : null}
</View>
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.tagRow}>
{expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
Billable
</Text>
) : null}
{expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
Tax
</Text>
) : null}
</View>
</View>
<View style={styles.amountStack}>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
</View>
</Pressable>
</SwipeableRow>
);
}
function groupExpensesByMonth(expenses: Expense[]) {
const groups = new Map<string, Expense[]>();
for (const expense of expenses) {
const date = new Date(expense.date);
const key = date.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
const group = groups.get(key) ?? [];
group.push(expense);
groups.set(key, group);
}
return Array.from(groups.entries());
}
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
const normalized = category?.toLowerCase() ?? "";
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
return "receipt-outline";
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
header: {
gap: spacing.md,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderWidth: 1,
borderRadius: radii.lg,
},
rowPressed: {
opacity: 0.82,
},
categoryIcon: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
meta: {
flex: 1,
gap: 2,
minWidth: 0,
gap: spacing.xs,
},
titleRow: {
flexDirection: "row",
@@ -291,23 +377,42 @@ const createStyles = (colors: ThemeColors) =>
fontFamily: fonts.body,
fontSize: 13,
},
tagRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.xs,
},
tag: {
borderWidth: 1,
borderRadius: radii.pill,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
fontFamily: fonts.bodyMedium,
fontSize: 11,
overflow: "hidden",
},
amountStack: {
alignItems: "flex-end",
gap: spacing.xs,
},
amount: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
fontVariant: ["tabular-nums"],
},
summary: {
summaryGrid: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
padding: spacing.md,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
flexWrap: "wrap",
gap: spacing.xs,
},
summaryRight: {
alignItems: "flex-end",
summaryTile: {
flexGrow: 1,
flexBasis: "30%",
minWidth: 104,
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderRadius: radii.lg,
borderWidth: 1,
},
summaryLabel: {
fontFamily: fonts.bodyMedium,
@@ -320,25 +425,56 @@ const createStyles = (colors: ThemeColors) =>
fontSize: 20,
fontVariant: ["tabular-nums"],
},
monthGroup: {
gap: 2,
},
monthLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0,
paddingHorizontal: spacing.xs,
},
filters: {
gap: spacing.sm,
paddingRight: spacing.lg,
},
receiptPill: {
minWidth: 26,
textAlign: "center",
flexDirection: "row",
alignItems: "center",
gap: 2,
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.sm,
borderRadius: radii.pill,
paddingHorizontal: 7,
paddingVertical: 2,
},
receiptPillText: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
overflow: "hidden",
},
empty: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
textAlign: "center",
},
emptyCard: {
alignItems: "center",
gap: spacing.sm,
padding: spacing.lg,
borderWidth: 1,
borderRadius: radii.lg,
},
emptyIcon: {
width: 52,
height: 52,
borderRadius: radii.lg,
alignItems: "center",
justifyContent: "center",
},
emptyTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 17,
},
errorBox: {
padding: spacing.lg,