Compare commits

..
4 Commits
Author SHA1 Message Date
soconnor fa3b9bf6a0 update app.json 2026-07-06 21:12:22 -04:00
soconnor ae7350dae6 Prevent swipe rows from opening items 2026-07-02 00:52:50 -04:00
soconnor 530d0dc34d Refresh home and more navigation 2026-07-01 02:46:39 -04:00
soconnor 6497ee4dec Wire live activity widget patches 2026-07-01 02:45:30 -04:00
28 changed files with 1513 additions and 655 deletions
+3 -1
View File
@@ -10,7 +10,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"buildNumber": "20", "buildNumber": "23",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
@@ -68,6 +68,8 @@
} }
], ],
"./plugins/withSyncWidgetVersions.js", "./plugins/withSyncWidgetVersions.js",
"./plugins/withLiveActivityBannerFrame.js",
"./plugins/withStableWidgetsChildIdentity.js",
[ [
"expo-local-authentication", "expo-local-authentication",
{ {
+4 -12
View File
@@ -37,10 +37,10 @@ export default function AppLayout() {
> >
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets> <NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }} sf={{ default: "house", selected: "house.fill" }}
md="grid_view" md="home"
/> />
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets> <NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
@@ -67,15 +67,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="settings" contentStyle={tabContentStyle} disableAutomaticContentInsets> <NativeTabs.Trigger name="more" role="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "gearshape", selected: "gearshape.fill" }}
md="settings"
/>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }} sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
md="more_horiz" md="more_horiz"
+2 -5
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Alert, Alert,
Pressable,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@@ -155,8 +154,8 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(client.id, client.name), onPress: () => confirmDelete(client.id, client.name),
}, },
]} ]}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
> >
<Pressable onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}>
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text> <Text style={styles.name}>{client.name}</Text>
@@ -171,7 +170,6 @@ export default function EntitiesScreen() {
) : null} ) : null}
</View> </View>
</GlassSurface> </GlassSurface>
</Pressable>
</SwipeableRow> </SwipeableRow>
)) ))
) )
@@ -205,8 +203,8 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(business.id, business.name), onPress: () => confirmDelete(business.id, business.name),
}, },
]} ]}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
> >
<Pressable onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}>
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
<View style={styles.nameRow}> <View style={styles.nameRow}>
@@ -221,7 +219,6 @@ export default function EntitiesScreen() {
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null} {business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View> </View>
</GlassSurface> </GlassSurface>
</Pressable>
</SwipeableRow> </SwipeableRow>
)) ))
)} )}
+303 -120
View File
@@ -1,31 +1,43 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { Screen } from "@/components/Screen";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { GlassSurface } from "@/components/GlassSurface"; import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { Screen } from "@/components/Screen";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useSession } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format"; import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus } from "@/lib/invoice-status"; import { getInvoiceStatus } from "@/lib/invoice-status";
import type { ThemeColors } from "@/lib/theme-palette";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock"; import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors"; import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
type ActionItem = {
key: string;
title: string;
detail: string;
icon: keyof typeof Ionicons.glyphMap;
tone: "warning" | "primary" | "success";
onPress: () => void;
};
export default function DashboardScreen() { export default function DashboardScreen() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = useThemedStyles(createDashboardStyles); const styles = useThemedStyles(createDashboardStyles);
const { data: session } = useSession();
const statsQuery = api.dashboard.getStats.useQuery(); const statsQuery = api.dashboard.getStats.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, { const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000, refetchInterval: 30_000,
@@ -33,7 +45,7 @@ export default function DashboardScreen() {
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt); const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
if (statsQuery.isLoading) { if (statsQuery.isLoading) {
return <LoadingScreen message="Loading dashboard…" />; return <LoadingScreen message="Loading home…" />;
} }
if (statsQuery.error) { if (statsQuery.error) {
@@ -41,7 +53,7 @@ export default function DashboardScreen() {
<AppBackground> <AppBackground>
<Screen> <Screen>
<View style={styles.errorBox}> <View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load dashboard</Text> <Text style={styles.errorTitle}>Could not load home</Text>
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text> <Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
</View> </View>
</Screen> </Screen>
@@ -51,27 +63,94 @@ export default function DashboardScreen() {
const stats = statsQuery.data; const stats = statsQuery.data;
if (!stats) { if (!stats) {
return <LoadingScreen message="Loading dashboard…" />; return <LoadingScreen message="Loading home…" />;
} }
const now = new Date();
const running = runningQuery.data; const running = runningQuery.data;
const revenueChange = const runningClient = running?.client?.name ?? "No client";
stats.revenueChange > 0 const monthInvoices = stats.monthInvoices ?? [];
? `+${stats.revenueChange.toFixed(0)}% vs last month` const monthTotal = monthInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
: stats.revenueChange < 0
? `${stats.revenueChange.toFixed(0)}% vs last month`
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1); const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.recentInvoices.filter((inv) => inv.status === "draft"); const drafts = stats.recentInvoices.filter((invoice) => invoice.status === "draft");
const pendingInvoices = monthInvoices.filter((invoice) => {
const status = getInvoiceStatus(invoice);
return status === "sent" || status === "overdue";
});
const overdueInvoices = monthInvoices.filter((invoice) => getInvoiceStatus(invoice) === "overdue");
const displayName = session?.user.name?.trim();
const firstName =
(displayName ? displayName.split(/\s+/)[0] : undefined) ??
session?.user.email?.split("@")[0] ??
"there";
const actionItems: ActionItem[] = [
...(running
? [
{
key: "running",
title: "Timer running",
detail: `${formatElapsedHoursMinutes(runningElapsed)} on ${runningClient}`,
icon: "timer-outline" as const,
tone: "success" as const,
onPress: () => router.push("/(app)/timer"),
},
]
: []),
...(overdueInvoices.length > 0
? [
{
key: "overdue",
title: `${overdueInvoices.length} overdue ${overdueInvoices.length === 1 ? "invoice" : "invoices"}`,
detail: `${formatCurrency(overdueInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} needs follow-up`,
icon: "alert-circle-outline" as const,
tone: "warning" as const,
onPress: () => router.push("/(app)/invoices"),
},
]
: []),
...(drafts.length > 0
? [
{
key: "drafts",
title: `${drafts.length} draft ${drafts.length === 1 ? "invoice" : "invoices"}`,
detail: "Review and send when ready",
icon: "document-text-outline" as const,
tone: "primary" as const,
onPress: () => router.push("/(app)/invoices"),
},
]
: []),
...(pendingInvoices.length > 0
? [
{
key: "pending",
title: `${pendingInvoices.length} awaiting payment`,
detail: `${formatCurrency(pendingInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} outstanding this month`,
icon: "card-outline" as const,
tone: "primary" as const,
onPress: () => router.push("/(app)/invoices"),
},
]
: []),
];
if (actionItems.length === 0) {
actionItems.push({
key: "clear",
title: "No urgent action items",
detail: "You are clear for the moment",
icon: "checkmark-circle-outline",
tone: "success",
onPress: () => router.push("/(app)/invoices"),
});
}
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage>
<TabScrollView <TabScrollView
header={ header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
<PageHeader title="Overview" subtitle="Your invoicing at a glance" />
}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={statsQuery.isRefetching || runningQuery.isRefetching} refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
@@ -83,6 +162,64 @@ export default function DashboardScreen() {
/> />
} }
> >
<View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button
title="Invoices"
variant="secondary"
onPress={() => router.push("/(app)/invoices")}
/>
<Button
title="Reports"
variant="secondary"
onPress={() => router.push("/(app)/more/reports" as never)}
/>
</View>
<Card title="Action items">
<View style={styles.actionList}>
{actionItems.map((item) => (
<Pressable
accessibilityRole="button"
key={item.key}
onPress={item.onPress}
style={({ pressed }) => [styles.actionRow, pressed && styles.pressed]}
>
<View
style={[
styles.actionIcon,
{
backgroundColor:
item.tone === "warning"
? colors.warningBg
: item.tone === "success"
? colors.successBg
: colors.muted,
},
]}
>
<Ionicons
name={item.icon}
size={20}
color={
item.tone === "warning"
? colors.warning
: item.tone === "success"
? colors.success
: colors.primary
}
/>
</View>
<View style={styles.actionCopy}>
<Text style={styles.actionTitle}>{item.title}</Text>
<Text style={styles.actionDetail}>{item.detail}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
))}
</View>
</Card>
{running ? ( {running ? (
<Pressable onPress={() => router.push("/(app)/timer")}> <Pressable onPress={() => router.push("/(app)/timer")}>
<GlassSurface style={styles.runningGlass}> <GlassSurface style={styles.runningGlass}>
@@ -93,7 +230,7 @@ export default function DashboardScreen() {
{resolveClockDescription(running.description)} {resolveClockDescription(running.description)}
</Text> </Text>
<Text style={styles.runningSub}> <Text style={styles.runningSub}>
{running.client?.name ?? "No client"} {runningClient}
{running.invoice {running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` ? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""} : ""}
@@ -107,95 +244,93 @@ export default function DashboardScreen() {
</Pressable> </Pressable>
) : null} ) : null}
{stats.overdueCount > 0 ? ( <Card
<GlassSurface style={styles.alertGlass}> title={now.toLocaleDateString("en-US", {
<View style={styles.alertBanner}> month: "long",
<Text style={styles.alertTitle}> year: "numeric",
{stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"} })}
</Text> >
<Text style={styles.alertText}> <View style={styles.monthSummary}>
Follow up on outstanding payments from the Invoices tab. <View>
<Text style={styles.monthValue}>{formatCurrency(monthTotal)}</Text>
<Text style={styles.monthLabel}>
{monthInvoices.length} {monthInvoices.length === 1 ? "invoice" : "invoices"} this month
</Text> </Text>
</View> </View>
</GlassSurface>
) : null}
{sendReminderDue.length > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{sendReminderDue.length} draft{" "}
{sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
</Text>
<Text style={styles.alertText}>
{sendReminderDue
.slice(0, 2)
.map(
(inv) =>
`${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
)
.join(" · ")}
</Text>
</View>
</GlassSurface>
) : null}
<View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button <Button
title="View invoices" title="New invoice"
variant="secondary" variant="secondary"
onPress={() => router.push("/(app)/invoices")} onPress={() => router.push("/(app)/invoices/new")}
/>
<Button
title="Reports"
variant="secondary"
onPress={() => router.push("/(app)/more/reports" as never)}
/> />
</View> </View>
<View style={styles.monthList}>
{monthInvoices.slice(0, 4).map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
accessibilityRole="button"
key={invoice.id}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
style={({ pressed }) => [styles.monthInvoiceRow, pressed && styles.pressed]}
>
<View style={styles.invoiceMeta}>
<Text style={styles.invoiceTitle}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
</View>
<View style={styles.invoiceRight}>
<Text style={styles.invoiceAmount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<StatusBadge status={status} />
</View>
</Pressable>
);
})}
{monthInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices in this month yet.</Text>
) : null}
</View>
</Card>
{stats.currentDraft ? ( {stats.currentDraft ? (
<GlassSurface style={styles.alertGlass}> <GlassSurface style={styles.draftGlass}>
<Pressable <Pressable
style={styles.alertBanner} style={styles.draftBanner}
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)} onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
> >
<Text style={styles.alertTitle}> <View style={styles.draftCopy}>
Draft {stats.currentDraft.invoiceNumber} <Text style={styles.draftTitle}>Current draft</Text>
</Text> <Text style={styles.draftText}>
<Text style={styles.alertText}>
{stats.currentDraft.client?.name ?? "Client"} ·{" "} {stats.currentDraft.client?.name ?? "Client"} ·{" "}
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "} {formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
{stats.currentDraft.totalHours.toFixed(1)}h logged {stats.currentDraft.totalHours.toFixed(1)}h logged
</Text> </Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable> </Pressable>
</GlassSurface> </GlassSurface>
) : null} ) : null}
<View style={styles.statsGrid}> <View style={styles.statsGrid}>
<View style={styles.statCell}> <View style={styles.statCell}>
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} /> <StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
</View> </View>
<View style={styles.statCell}> <View style={styles.statCell}>
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} /> <StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
</View> </View>
<View style={styles.statCell}> <View style={styles.statCell}>
<StatCard <StatCard label="Overdue" value={String(stats.overdueCount)} />
label="Overdue"
value={String(stats.overdueCount)}
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
/>
</View> </View>
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}> <Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
<StatCard <StatCard label="Clients" value={String(stats.totalClients)} />
label="Clients"
value={String(stats.totalClients)}
hint={revenueChange}
/>
</Pressable> </Pressable>
</View> </View>
<Card title="Revenue (6 months)"> <Card title="Revenue trend">
<View style={styles.chart}> <View style={styles.chart}>
{stats.revenueChartData.map((point) => { {stats.revenueChartData.map((point) => {
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80); const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
@@ -205,26 +340,12 @@ export default function DashboardScreen() {
<View style={[styles.chartBar, { height: barHeight }]} /> <View style={[styles.chartBar, { height: barHeight }]} />
</View> </View>
<Text style={styles.chartLabel}>{point.monthLabel}</Text> <Text style={styles.chartLabel}>{point.monthLabel}</Text>
<Text style={styles.chartValue}>
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
</Text>
</View> </View>
); );
})} })}
</View> </View>
</Card> </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"> <Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? ( {stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text> <Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
@@ -234,7 +355,7 @@ export default function DashboardScreen() {
return ( return (
<Pressable <Pressable
key={invoice.id} key={invoice.id}
style={styles.invoiceRow} style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)} onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
> >
<View style={styles.invoiceMeta}> <View style={styles.invoiceMeta}>
@@ -242,9 +363,7 @@ export default function DashboardScreen() {
{invoice.invoicePrefix} {invoice.invoicePrefix}
{invoice.invoiceNumber} {invoice.invoiceNumber}
</Text> </Text>
<Text style={styles.invoiceClient}> <Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
{invoice.client?.name ?? "Client"}
</Text>
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text> <Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
</View> </View>
<View style={styles.invoiceRight}> <View style={styles.invoiceRight}>
@@ -266,8 +385,38 @@ export default function DashboardScreen() {
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) => const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({ StyleSheet.create({
safe: { actionList: {
gap: spacing.xs,
},
actionRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
minHeight: 58,
paddingVertical: spacing.xs,
},
actionIcon: {
width: 38,
height: 38,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
actionCopy: {
flex: 1, flex: 1,
gap: 2,
},
actionTitle: {
color: colors.foreground,
fontFamily: fonts.bodySemiBold,
fontSize: 15,
lineHeight: 20,
},
actionDetail: {
color: colors.mutedForeground,
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
}, },
runningGlass: { runningGlass: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0", borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
@@ -303,27 +452,64 @@ const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
fontSize: 18, fontSize: 18,
color: colors.success, color: colors.success,
}, },
alertBanner: { monthSummary: {
padding: spacing.md, flexDirection: "row",
gap: 4, alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
}, },
alertGlass: { monthValue: {
borderColor: isDark ? "rgba(251, 191, 36, 0.4)" : "#FDE68A", color: colors.foreground,
fontFamily: fonts.heading,
fontSize: 24,
lineHeight: 30,
}, },
alertTitle: { monthLabel: {
fontFamily: fonts.bodySemiBold,
color: colors.warning,
fontSize: 14,
},
alertText: {
fontFamily: fonts.body,
color: colors.mutedForeground, color: colors.mutedForeground,
fontSize: 13, fontFamily: fonts.bodyMedium,
fontSize: 12,
lineHeight: 16,
},
monthList: {
gap: spacing.xs,
},
monthInvoiceRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
}, },
quickActions: { quickActions: {
flexDirection: "row", flexDirection: "row",
gap: spacing.sm, gap: spacing.sm,
}, },
draftGlass: {
borderColor: isDark ? "rgba(59, 130, 246, 0.32)" : "#BFDBFE",
},
draftBanner: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.md,
},
draftCopy: {
flex: 1,
gap: 3,
},
draftTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.primary,
fontSize: 14,
},
draftText: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
lineHeight: 18,
},
statsGrid: { statsGrid: {
flexDirection: "row", flexDirection: "row",
flexWrap: "wrap", flexWrap: "wrap",
@@ -362,19 +548,13 @@ const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
color: colors.mutedForeground, color: colors.mutedForeground,
}, },
chartValue: {
fontSize: 9,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
empty: { empty: {
color: colors.mutedForeground, color: colors.mutedForeground,
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
lineHeight: 20, lineHeight: 20,
}, },
invoiceRow: { recentRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
gap: spacing.md, gap: spacing.md,
@@ -410,6 +590,9 @@ const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
color: colors.foreground, color: colors.foreground,
fontSize: 15, fontSize: 15,
}, },
pressed: {
opacity: 0.85,
},
errorBox: { errorBox: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",
@@ -426,4 +609,4 @@ const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
fontFamily: fonts.body, fontFamily: fonts.body,
lineHeight: 20, lineHeight: 20,
}, },
}); });
+4 -4
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Alert, Alert,
Pressable,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@@ -205,8 +204,10 @@ export default function InvoicesScreen() {
]; ];
return ( return (
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}> <SwipeableRow
<Pressable key={invoice.id}
actions={actions}
backgroundColor={colors.cardGlass}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)} onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)} onLongPress={() => promptStatusChange(invoice.id, status)}
> >
@@ -229,7 +230,6 @@ export default function InvoicesScreen() {
</View> </View>
</View> </View>
</GlassSurface> </GlassSurface>
</Pressable>
</SwipeableRow> </SwipeableRow>
); );
}) })
+20 -21
View File
@@ -1,6 +1,6 @@
import { useLocalSearchParams, router } from "expo-router"; import { useLocalSearchParams, router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native"; import { Alert, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { import {
@@ -12,6 +12,7 @@ import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow"; import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage"; import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
@@ -119,7 +120,7 @@ export default function ExpenseDetailScreen() {
"Receipt attached", "Receipt attached",
result.items.length > 0 result.items.length > 0
? "Select the owed items, apply the split amount, then save the expense." ? "Select the owed items, apply the split amount, then save the expense."
: "OCR filled the fields below. Save to update this expense.", : "We filled in what we could. Review and save to update this expense.",
); );
} finally { } finally {
setScanning(false); setScanning(false);
@@ -163,10 +164,12 @@ export default function ExpenseDetailScreen() {
if (!expense) { if (!expense) {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}> <TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
<Text style={{ color: colors.mutedForeground }}>
Expense not found Expense not found
</Text> </Text>
</TabScrollView>
</TabPage> </TabPage>
</AppBackground> </AppBackground>
); );
@@ -174,13 +177,16 @@ export default function ExpenseDetailScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<ScrollView contentContainerStyle={styles.body}> <TabScrollView
header={
<PageHeader <PageHeader
title={expense.description} title={expense.description}
subtitle={formatDate(expense.date)} subtitle={formatDate(expense.date)}
/> />
}
keyboardShouldPersistTaps="handled"
>
{editing ? ( {editing ? (
<> <>
{receiptSplit ? ( {receiptSplit ? (
@@ -316,22 +322,13 @@ export default function ExpenseDetailScreen() {
onPress={() => void attachAndScan(false)} onPress={() => void attachAndScan(false)}
/> />
</View> </View>
<Button </TabScrollView>
title="Back"
variant="secondary"
onPress={() => router.back()}
/>
</ScrollView>
</TabPage> </TabPage>
</AppBackground> </AppBackground>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
},
amount: { amount: {
fontSize: 28, fontSize: 28,
fontWeight: "600", fontWeight: "600",
@@ -362,7 +359,7 @@ const styles = StyleSheet.create({
marginTop: spacing.md, marginTop: spacing.md,
}, },
receiptRow: { receiptRow: {
paddingVertical: spacing.sm, padding: spacing.md,
fontSize: 14, fontSize: 14,
}, },
actions: { actions: {
@@ -408,8 +405,10 @@ function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim(); const trimmed = existing.trim();
if (!trimmed) return prefix; if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) { if (trimmed.startsWith("Receipt split")) {
const ocrStart = trimmed.indexOf("\n\nOCR text:"); const detailsStart = trimmed.indexOf("\n\nReceipt details:");
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix; const legacyStart = trimmed.indexOf("\n\nOCR text:");
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
} }
return `${prefix}\n\nOCR text:\n${trimmed}`; return `${prefix}\n\nReceipt details:\n${trimmed}`;
} }
+272 -141
View File
@@ -1,13 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
Pressable,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
View, View,
} from "react-native"; } from "react-native";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip"; import { FilterChip } from "@/components/FilterChip";
@@ -17,15 +19,16 @@ import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage"; import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button"; 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 { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format"; import { formatCurrency, formatDate } from "@/lib/format";
import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors"; import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type ExpenseFilter = "all" | "billable" | "receipts"; type ExpenseFilter = "all" | "billable" | "receipts";
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
export default function ExpensesScreen() { export default function ExpensesScreen() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@@ -48,24 +51,36 @@ export default function ExpensesScreen() {
}), }),
[expenses, filter], [expenses, filter],
); );
const summary = useMemo(() => {
const total = filteredExpenses.reduce( const total = filteredExpenses.reduce(
(sum, expense) => sum + expense.amount, (sum, expense) => sum + expense.amount,
0, 0,
); );
const billable = filteredExpenses.reduce(
(sum, expense) => sum + (expense.billable ? expense.amount : 0),
0,
);
const receiptCount = filteredExpenses.reduce( const receiptCount = filteredExpenses.reduce(
(sum, expense) => sum + (expense.receiptCount ?? 0), (sum, expense) => sum + (expense.receiptCount ?? 0),
0, 0,
); );
return { total, billable, receiptCount };
}, [filteredExpenses]);
const groupedExpenses = useMemo(
() => groupExpensesByMonth(filteredExpenses),
[filteredExpenses],
);
if (expensesQuery.isLoading) { if (expensesQuery.isLoading) {
return <LoadingScreen message="Loading expenses" />; return <LoadingScreen message="Loading expenses..." />;
} }
if (expensesQuery.error) { if (expensesQuery.error) {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<View style={styles.errorBox}> <View style={styles.errorBox}>
<PageHeader title="Expenses" subtitle="Expense tracking" />
<Text style={[styles.errorTitle, { color: colors.foreground }]}> <Text style={[styles.errorTitle, { color: colors.foreground }]}>
Could not load expenses Could not load expenses
</Text> </Text>
@@ -80,19 +95,19 @@ export default function ExpensesScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<TabScrollView <TabScrollView
header={ header={
<> <View style={styles.header}>
<PageHeader <PageHeader
title="Expenses" title="Expenses"
subtitle={`${expenses.length} recorded`} subtitle={`${expenses.length} recorded expense${expenses.length === 1 ? "" : "s"}`}
/> />
<Button <Button
title="Add expense" title="Add expense"
onPress={() => router.push("/(app)/more/expenses/new" as never)} onPress={() => router.push("/(app)/more/expenses/new" as never)}
/> />
</> </View>
} }
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -103,42 +118,32 @@ export default function ExpensesScreen() {
} }
> >
{expenses.length === 0 ? ( {expenses.length === 0 ? (
<Text style={[styles.empty, { color: colors.mutedForeground }]}> <View
No expenses yet. Add one with a receipt photo or manual entry. 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>
<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 style={styles.summaryGrid}>
<View> <SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
<Text <SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
style={[ <SummaryTile label="Receipts" value={String(summary.receiptCount)} />
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> </View>
<ScrollView <ScrollView
@@ -168,87 +173,19 @@ export default function ExpensesScreen() {
No expenses match this filter. No expenses match this filter.
</Text> </Text>
) : ( ) : (
filteredExpenses.map((expense) => ( groupedExpenses.map(([monthLabel, group]) => (
<SwipeableRow <View key={monthLabel} style={styles.monthGroup}>
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
{monthLabel}
</Text>
{group.map((expense) => (
<ExpenseRow
key={expense.id} key={expense.id}
actions={[ expense={expense}
{ onDelete={() => deleteExpense.mutate({ id: expense.id })}
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> </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>
)) ))
)} )}
</> </>
@@ -259,23 +196,167 @@ 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}
contentStyle={({ pressed }) => [
styles.row,
{ borderColor: colors.border },
pressed && styles.rowPressed,
]}
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
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,
},
]}
>
<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>
</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) => const createStyles = (colors: ThemeColors) =>
StyleSheet.create({ StyleSheet.create({
header: {
gap: spacing.md,
},
row: { row: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between",
gap: spacing.md, gap: spacing.md,
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
borderBottomWidth: 1, paddingVertical: 12,
borderBottomColor: colors.border, borderWidth: 1,
borderRadius: radii.lg,
}, },
rowPressed: { rowPressed: {
opacity: 0.82, opacity: 0.82,
}, },
categoryIcon: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
meta: { meta: {
flex: 1, flex: 1,
gap: 2, minWidth: 0,
gap: spacing.xs,
}, },
titleRow: { titleRow: {
flexDirection: "row", flexDirection: "row",
@@ -291,23 +372,42 @@ const createStyles = (colors: ThemeColors) =>
fontFamily: fonts.body, fontFamily: fonts.body,
fontSize: 13, 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: { amount: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 15, fontSize: 15,
fontVariant: ["tabular-nums"], fontVariant: ["tabular-nums"],
}, },
summary: { summaryGrid: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", flexWrap: "wrap",
gap: spacing.md, gap: spacing.xs,
padding: spacing.md,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
}, },
summaryRight: { summaryTile: {
alignItems: "flex-end", flexGrow: 1,
flexBasis: "30%",
minWidth: 104,
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderRadius: radii.lg,
borderWidth: 1,
}, },
summaryLabel: { summaryLabel: {
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
@@ -320,25 +420,56 @@ const createStyles = (colors: ThemeColors) =>
fontSize: 20, fontSize: 20,
fontVariant: ["tabular-nums"], fontVariant: ["tabular-nums"],
}, },
monthGroup: {
gap: 2,
},
monthLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0,
paddingHorizontal: spacing.xs,
},
filters: { filters: {
gap: spacing.sm, gap: spacing.sm,
paddingRight: spacing.lg, paddingRight: spacing.lg,
}, },
receiptPill: { receiptPill: {
minWidth: 26, flexDirection: "row",
textAlign: "center", alignItems: "center",
gap: 2,
borderWidth: 1, borderWidth: 1,
borderRadius: 999, borderRadius: radii.pill,
paddingHorizontal: spacing.sm, paddingHorizontal: 7,
paddingVertical: 2, paddingVertical: 2,
},
receiptPillText: {
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
fontSize: 12, fontSize: 12,
overflow: "hidden",
}, },
empty: { empty: {
fontFamily: fonts.body, fontFamily: fonts.body,
fontSize: 14, fontSize: 14,
lineHeight: 20, 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: { errorBox: {
padding: spacing.lg, padding: spacing.lg,
+48 -28
View File
@@ -1,6 +1,6 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native"; import { Alert, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { import {
@@ -11,10 +11,11 @@ import {
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector"; import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage"; import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { spacing } from "@/constants/theme"; import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { mlKitOcrAvailable } from "@/lib/receipt-ocr";
import { import {
scanReceiptImage, scanReceiptImage,
type PickedReceiptImage, type PickedReceiptImage,
@@ -96,7 +97,7 @@ export default function NewExpenseScreen() {
? "Select the items this person owes, then apply the split amount." ? "Select the items this person owes, then apply the split amount."
: result.amountText : result.amountText
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.` ? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
: "Review the fields — OCR could not find a total automatically.", : "Review the fields and enter the total before saving.",
); );
} finally { } finally {
setScanning(false); setScanning(false);
@@ -150,31 +151,30 @@ export default function NewExpenseScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<ScrollView <TabScrollView
contentContainerStyle={styles.body} header={
keyboardShouldPersistTaps="handled"
>
<PageHeader <PageHeader
title="New expense" title="New expense"
subtitle={ subtitle="Add a receipt, fill the details, and save it"
mlKitOcrAvailable()
? "Scan a receipt with on-device ML Kit OCR"
: "Manual entry (OCR unavailable on this device)"
}
/> />
}
keyboardShouldPersistTaps="handled"
>
<Card title="Receipt">
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
title={scanning ? "Scanning" : "Scan receipt"} title={scanning ? "Scanning..." : "Take photo"}
variant="secondary" variant="secondary"
leftIcon="camera-outline"
loading={scanning} loading={scanning}
style={styles.actionButton} style={styles.actionButton}
onPress={() => void runScan(true)} onPress={() => void runScan(true)}
/> />
<Button <Button
title="Import photo" title="Choose photo"
variant="secondary" variant="secondary"
leftIcon="image-outline"
loading={scanning} loading={scanning}
style={styles.actionButton} style={styles.actionButton}
onPress={() => void runScan(false)} onPress={() => void runScan(false)}
@@ -182,10 +182,13 @@ export default function NewExpenseScreen() {
</View> </View>
{pendingReceipt ? ( {pendingReceipt ? (
<Text style={{ color: colors.success, fontSize: 13 }}> <View style={[styles.notice, { backgroundColor: colors.successBg }]}>
Receipt image ready it will attach when you save. <Text style={[styles.noticeText, { color: colors.success }]}>
Receipt attached. Review the details below before saving.
</Text> </Text>
</View>
) : null} ) : null}
</Card>
{receiptSplit ? ( {receiptSplit ? (
<ReceiptItemSelector <ReceiptItemSelector
@@ -203,6 +206,7 @@ export default function NewExpenseScreen() {
/> />
) : null} ) : null}
<Card title="Details">
<ExpenseFormFields <ExpenseFormFields
value={{ value={{
...form, ...form,
@@ -212,30 +216,31 @@ export default function NewExpenseScreen() {
clients={clients} clients={clients}
onChange={setForm} onChange={setForm}
notesLabel="Notes" notesLabel="Notes"
notesPlaceholder="Receipt OCR text or internal notes" notesPlaceholder="Internal details"
/> />
</Card>
<View style={styles.saveActions}>
<Button <Button
title="Save expense" title="Save expense"
leftIcon="checkmark-circle-outline"
loading={createExpense.isPending} loading={createExpense.isPending}
onPress={() => void handleSave()} onPress={() => void handleSave()}
/> />
<Button <Button
title="Cancel" title="Cancel"
leftIcon="close-circle-outline"
variant="secondary" variant="secondary"
onPress={() => router.back()} onPress={() => router.back()}
/> />
</ScrollView> </View>
</TabScrollView>
</TabPage> </TabPage>
</AppBackground> </AppBackground>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
},
actions: { actions: {
flexDirection: "row", flexDirection: "row",
gap: spacing.sm, gap: spacing.sm,
@@ -243,14 +248,29 @@ const styles = StyleSheet.create({
actionButton: { actionButton: {
flex: 1, flex: 1,
}, },
saveActions: {
gap: spacing.sm,
},
notice: {
borderRadius: 12,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
noticeText: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
lineHeight: 18,
},
}); });
function mergeNotes(prefix: string, existing: string) { function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim(); const trimmed = existing.trim();
if (!trimmed) return prefix; if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) { if (trimmed.startsWith("Receipt split")) {
const ocrStart = trimmed.indexOf("\n\nOCR text:"); const detailsStart = trimmed.indexOf("\n\nReceipt details:");
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix; const legacyStart = trimmed.indexOf("\n\nOCR text:");
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
} }
return `${prefix}\n\nOCR text:\n${trimmed}`; return `${prefix}\n\nReceipt details:\n${trimmed}`;
} }
+6
View File
@@ -43,6 +43,12 @@ const ITEMS: HubItem[] = [
href: "/(app)/more/time-entries", href: "/(app)/more/time-entries",
icon: "time-outline", icon: "time-outline",
}, },
{
title: "Settings",
subtitle: "Account, security, and app preferences",
href: "/(app)/more/settings",
icon: "settings-outline",
},
]; ];
export default function MoreScreen() { export default function MoreScreen() {
+2 -4
View File
@@ -37,7 +37,7 @@ export default function RecurringScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<TabScrollView <TabScrollView
header={ header={
<PageHeader <PageHeader
@@ -113,9 +113,7 @@ const styles = StyleSheet.create({
row: { row: {
flexDirection: "row", flexDirection: "row",
gap: spacing.md, gap: spacing.md,
paddingVertical: spacing.md, padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: "rgba(0,0,0,0.08)",
}, },
title: { title: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
+10 -3
View File
@@ -26,10 +26,13 @@ export default function ReportsScreen() {
if (statsQuery.error) { if (statsQuery.error) {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}> <View style={styles.errorBox}>
<PageHeader title="Reports" subtitle="Business performance snapshot" />
<Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(statsQuery.error)} {formatTrpcErrorMessage(statsQuery.error)}
</Text> </Text>
</View>
</TabPage> </TabPage>
</AppBackground> </AppBackground>
); );
@@ -41,7 +44,7 @@ export default function ReportsScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<TabScrollView <TabScrollView
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />} header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
refreshControl={ refreshControl={
@@ -105,4 +108,8 @@ const styles = StyleSheet.create({
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
}, },
errorBox: {
padding: spacing.lg,
gap: spacing.md,
},
}); });
@@ -187,7 +187,7 @@ export default function SettingsScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<PinPrompt <PinPrompt
visible={pinPrompt !== null} visible={pinPrompt !== null}
title={ title={
+11 -4
View File
@@ -54,10 +54,13 @@ export default function TimeEntriesScreen() {
if (entriesQuery.error) { if (entriesQuery.error) {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}> <View style={styles.errorBox}>
<PageHeader title="Time entries" subtitle="Completed work history" />
<Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(entriesQuery.error)} {formatTrpcErrorMessage(entriesQuery.error)}
</Text> </Text>
</View>
</TabPage> </TabPage>
</AppBackground> </AppBackground>
); );
@@ -65,7 +68,7 @@ export default function TimeEntriesScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage> <TabPage showMoreBack>
<TabScrollView <TabScrollView
header={ header={
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} /> <PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
@@ -136,10 +139,14 @@ const styles = StyleSheet.create({
row: { row: {
flexDirection: "row", flexDirection: "row",
gap: spacing.md, gap: spacing.md,
paddingVertical: spacing.sm, padding: spacing.md,
}, },
title: { title: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 14, fontSize: 14,
}, },
errorBox: {
padding: spacing.lg,
gap: spacing.md,
},
}); });
+35
View File
@@ -1,4 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -82,6 +83,11 @@ export function AccountSwitcher() {
} }
} }
function handleOpenSettings() {
setOpen(false);
router.push("/(app)/more/settings" as never);
}
function handleRemove(accountId: string, label: string) { function handleRemove(accountId: string, label: string) {
confirmRemoveAccount( confirmRemoveAccount(
label, label,
@@ -219,6 +225,22 @@ export function AccountSwitcher() {
<Ionicons name="add-circle-outline" size={22} color={colors.primary} /> <Ionicons name="add-circle-outline" size={22} color={colors.primary} />
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text> <Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
</Pressable> </Pressable>
<Pressable
accessibilityRole="button"
onPress={handleOpenSettings}
style={({ pressed }) => [
styles.settingsRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="settings-outline" size={21} color={colors.mutedForeground} />
<Text style={[styles.settingsLabel, { color: colors.foreground }]}>
Settings
</Text>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</ScrollView> </ScrollView>
</Pressable> </Pressable>
</Pressable> </Pressable>
@@ -333,6 +355,19 @@ const styles = StyleSheet.create({
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 15, fontSize: 15,
}, },
settingsRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
},
settingsLabel: {
flex: 1,
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
pressed: { pressed: {
opacity: 0.75, opacity: 0.75,
}, },
+1 -13
View File
@@ -105,23 +105,11 @@ export function ShortcutHandler() {
const clientId = const clientId =
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || ""; pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
if (!clientId) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
Alert.alert(
"Choose a client",
"Open the time clock and pick a client once — shortcuts will use it next time.",
);
return;
}
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId); const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate); const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
await clockIn.mutateAsync({ await clockIn.mutateAsync({
clientId, clientId: clientId || "",
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION), description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
rate: rate ?? undefined, rate: rate ?? undefined,
}); });
+78 -4
View File
@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { ReactNode, useRef } from "react"; import { ReactNode, useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
import Swipeable, { import Swipeable, {
type SwipeableMethods, type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable"; } from "react-native-gesture-handler/ReanimatedSwipeable";
@@ -24,6 +24,9 @@ type SwipeableRowProps = {
actions: SwipeAction[]; actions: SwipeAction[];
enabled?: boolean; enabled?: boolean;
backgroundColor?: string; backgroundColor?: string;
onPress?: () => void;
onLongPress?: () => void;
contentStyle?: PressableProps["style"];
}; };
export function SwipeableRow({ export function SwipeableRow({
@@ -31,11 +34,63 @@ export function SwipeableRow({
actions, actions,
enabled = true, enabled = true,
backgroundColor, backgroundColor,
onPress,
onLongPress,
contentStyle,
}: SwipeableRowProps) { }: SwipeableRowProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = useThemedStyles(createSwipeableRowStyles); const styles = useThemedStyles(createSwipeableRowStyles);
const rowBackground = backgroundColor ?? colors.background; const rowBackground = backgroundColor ?? colors.background;
const swipeRef = useRef<SwipeableMethods>(null); const swipeRef = useRef<SwipeableMethods>(null);
const rowOpenRef = useRef(false);
const suppressPressUntilRef = useRef(0);
function suppressContentPress() {
suppressPressUntilRef.current = Date.now() + 350;
}
function handleContentPress() {
if (!onPress) return;
if (rowOpenRef.current || Date.now() < suppressPressUntilRef.current) {
swipeRef.current?.close();
rowOpenRef.current = false;
return;
}
onPress();
}
function renderContent() {
if (!onPress && !onLongPress) {
return (
<View
style={[
styles.row,
{ backgroundColor: rowBackground },
typeof contentStyle === "function" ? undefined : contentStyle,
]}
>
{children}
</View>
);
}
return (
<Pressable
accessibilityRole="button"
onPress={handleContentPress}
onLongPress={onLongPress}
style={(state) => [
styles.row,
{ backgroundColor: rowBackground },
typeof contentStyle === "function" ? contentStyle(state) : contentStyle,
]}
>
{children}
</Pressable>
);
}
function renderRightActions() { function renderRightActions() {
return ( return (
@@ -45,7 +100,9 @@ export function SwipeableRow({
key={action.key} key={action.key}
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]} style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
onPress={() => { onPress={() => {
suppressContentPress();
swipeRef.current?.close(); swipeRef.current?.close();
rowOpenRef.current = false;
action.onPress(); action.onPress();
}} }}
> >
@@ -58,12 +115,26 @@ export function SwipeableRow({
} }
if (!enabled || actions.length === 0) { if (!enabled || actions.length === 0) {
return <View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>; return renderContent();
} }
return ( return (
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}> <Swipeable
<View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View> ref={swipeRef}
renderRightActions={renderRightActions}
overshootRight={false}
onSwipeableOpenStartDrag={suppressContentPress}
onSwipeableCloseStartDrag={suppressContentPress}
onSwipeableWillOpen={() => {
suppressContentPress();
rowOpenRef.current = true;
}}
onSwipeableWillClose={suppressContentPress}
onSwipeableClose={() => {
rowOpenRef.current = false;
}}
>
{renderContent()}
</Swipeable> </Swipeable>
); );
} }
@@ -72,6 +143,9 @@ const createSwipeableRowStyles = (colors: ThemeColors) =>
StyleSheet.create({ StyleSheet.create({
row: { row: {
backgroundColor: colors.background, backgroundColor: colors.background,
borderRadius: radii.lg,
overflow: "hidden",
marginBottom: spacing.xs,
}, },
actions: { actions: {
flexDirection: "row", flexDirection: "row",
+3 -2
View File
@@ -7,16 +7,17 @@ import { useAppTheme } from "@/contexts/ThemeContext";
type TabPageProps = { type TabPageProps = {
children: ReactNode; children: ReactNode;
showMoreBack?: boolean;
}; };
/** Tab root — pinned top chrome, scrollable body below. */ /** Tab root — pinned top chrome, scrollable body below. */
export function TabPage({ children }: TabPageProps) { export function TabPage({ children, showMoreBack = false }: TabPageProps) {
const { isDark } = useAppTheme(); const { isDark } = useAppTheme();
return ( return (
<View style={styles.root}> <View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} /> <StatusBar style={isDark ? "light" : "dark"} />
<TopChromeBar /> <TopChromeBar showMoreBack={showMoreBack} />
<View style={styles.content}>{children}</View> <View style={styles.content}>{children}</View>
</View> </View>
); );
+10
View File
@@ -19,11 +19,15 @@ export function TabScrollView({
header, header,
children, children,
contentContainerStyle, contentContainerStyle,
refreshControl,
style, style,
bounces,
alwaysBounceVertical,
...props ...props
}: TabScrollViewProps) { }: TabScrollViewProps) {
const scrollRef = useRef<ScrollView>(null); const scrollRef = useRef<ScrollView>(null);
const bottomPadding = useTabScreenScrollPadding(); const bottomPadding = useTabScreenScrollPadding();
const canRefresh = Boolean(refreshControl);
useScrollToTop(scrollRef); useScrollToTop(scrollRef);
@@ -37,6 +41,12 @@ export function TabScrollView({
contentContainerStyle, contentContainerStyle,
]} ]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
automaticallyAdjustContentInsets={false}
automaticallyAdjustKeyboardInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
bounces={bounces ?? canRefresh}
alwaysBounceVertical={alwaysBounceVertical ?? canRefresh}
refreshControl={refreshControl}
scrollIndicatorInsets={{ bottom: bottomPadding }} scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props} {...props}
> >
+50 -3
View File
@@ -1,18 +1,49 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { Pressable, Text } from "react-native";
import { AccountSwitcher } from "@/components/AccountSwitcher"; import { AccountSwitcher } from "@/components/AccountSwitcher";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets"; import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
type TopChromeProps = {
showMoreBack?: boolean;
};
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */ /** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome() { export function TopChrome({ showMoreBack = false }: TopChromeProps) {
const { isDark } = useAppTheme(); const { colors, isDark } = useAppTheme();
function handleBack() {
if (router.canGoBack()) {
router.back();
return;
}
router.replace("/(app)/more" as never);
}
return ( return (
<View style={styles.row}> <View style={styles.row}>
{showMoreBack ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Back to More"
onPress={handleBack}
style={({ pressed }) => [
styles.backButton,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
pressed && styles.pressed,
]}
>
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text>
</Pressable>
) : (
<Logo size="xs" onDark={isDark} /> <Logo size="xs" onDark={isDark} />
)}
<AccountSwitcher /> <AccountSwitcher />
</View> </View>
); );
@@ -26,4 +57,20 @@ const styles = StyleSheet.create({
height: TOP_CHROME_ROW_HEIGHT, height: TOP_CHROME_ROW_HEIGHT,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
}, },
backButton: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderRadius: radii.pill,
},
backLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
},
pressed: {
opacity: 0.82,
},
}); });
+6 -2
View File
@@ -10,8 +10,12 @@ import {
TOP_CHROME_ROW_HEIGHT, TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets"; } from "@/lib/top-chrome-insets";
type TopChromeBarProps = {
showMoreBack?: boolean;
};
/** Blurred status-bar chrome with logo + account switcher. */ /** Blurred status-bar chrome with logo + account switcher. */
export function TopChromeBar() { export function TopChromeBar({ showMoreBack = false }: TopChromeBarProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isDark } = useAppTheme(); const { isDark } = useAppTheme();
const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)"; const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)";
@@ -38,7 +42,7 @@ export function TopChromeBar() {
pointerEvents="none" pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tint }]} style={[StyleSheet.absoluteFill, { backgroundColor: tint }]}
/> />
<TopChrome /> <TopChrome showMoreBack={showMoreBack} />
</View> </View>
); );
} }
+10 -2
View File
@@ -54,7 +54,7 @@ export function ExpenseFormFields({
clients, clients,
onChange, onChange,
notesLabel = "Notes", notesLabel = "Notes",
notesPlaceholder = "Internal details or receipt OCR text", notesPlaceholder = "Internal details",
}: ExpenseFormFieldsProps) { }: ExpenseFormFieldsProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@@ -172,7 +172,15 @@ export function ExpenseFormFields({
onValueChange: (value: boolean) => void; onValueChange: (value: boolean) => void;
}) { }) {
return ( return (
<View style={[styles.flagRow, { borderColor: colors.borderGlass }]}> <View
style={[
styles.flagRow,
{
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
]}
>
<Text style={[styles.flagLabel, { color: colors.foreground }]}> <Text style={[styles.flagLabel, { color: colors.foreground }]}>
{label} {label}
</Text> </Text>
+7 -2
View File
@@ -80,7 +80,12 @@ export function ReceiptItemSelector({
}; };
return ( return (
<View style={[styles.wrap, { borderColor: colors.border }]}> <View
style={[
styles.wrap,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerCopy}> <View style={styles.headerCopy}>
<Text style={[styles.title, { color: colors.foreground }]}> <Text style={[styles.title, { color: colors.foreground }]}>
@@ -140,7 +145,7 @@ export function ReceiptItemSelector({
onPress={() => toggle(item.id)} onPress={() => toggle(item.id)}
style={({ pressed }) => [ style={({ pressed }) => [
styles.item, styles.item,
{ borderColor: colors.borderGlass }, { borderColor: colors.borderGlass, backgroundColor: colors.background },
selected && { backgroundColor: colors.muted }, selected && { backgroundColor: colors.muted },
pressed && styles.pressed, pressed && styles.pressed,
]} ]}
+11 -29
View File
@@ -105,7 +105,6 @@ export function TimeClockPanel({
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]); const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null); const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false); const [prefsLoaded, setPrefsLoaded] = useState(false);
const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data; const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt); const elapsed = useRunningElapsed(running?.startedAt);
@@ -210,32 +209,6 @@ export function TimeClockPanel({
setRateText((current) => current.trim() || clientRateText(client)); setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]); }, [clientId, clients, running]);
useEffect(() => {
if (running || defaultClientId || initialClientResolved) return;
if (!prefsLoaded || clients.length === 0) return;
const preferredId =
storedLastClientId && clients.some((client) => client.id === storedLastClientId)
? storedLastClientId
: recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
if (preferredId) {
const client = clients.find((c) => c.id === preferredId);
setClientId(preferredId);
setRateText(clientRateText(client));
}
setInitialClientResolved(true);
}, [
clients,
defaultClientId,
initialClientResolved,
prefsLoaded,
recentClientIds,
running,
storedLastClientId,
]);
useEffect(() => { useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return; if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
@@ -332,11 +305,13 @@ export function TimeClockPanel({
setClientId(nextClientId); setClientId(nextClientId);
setInvoiceId(""); setInvoiceId("");
setRateText(clientRateText(client)); setRateText(clientRateText(client));
if (!featuredClientIds.includes(nextClientId)) { if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true); setClientsExpanded(true);
} }
if (nextClientId) {
void persistClientChoice(nextClientId); void persistClientChoice(nextClientId);
} }
}
function selectInvoice(nextInvoiceId: string) { function selectInvoice(nextInvoiceId: string) {
if (running) { if (running) {
@@ -596,6 +571,11 @@ export function TimeClockPanel({
) : ( ) : (
<> <>
<View style={styles.chipWrap}> <View style={styles.chipWrap}>
<FilterChip
label="No client"
active={!clientId}
onPress={() => selectClient("")}
/>
{featuredClients.map((client) => renderClientChip(client))} {featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? ( {moreClients.length > 0 ? (
<FilterChip <FilterChip
@@ -617,7 +597,9 @@ export function TimeClockPanel({
<View style={styles.setupSection}> <View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice (optional)</Text> <Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? ( {!clientId ? (
<Text style={styles.emptyClients}>Pick a client to attach a draft invoice.</Text> <Text style={styles.emptyClients}>
No invoice for now. Add a client and invoice later if this becomes billable.
</Text>
) : ( ) : (
<View style={styles.chipWrap}> <View style={styles.chipWrap}>
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} /> <FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
+20 -2
View File
@@ -17,6 +17,7 @@ type ButtonProps = PressableProps & {
loading?: boolean; loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost"; variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle; style?: ViewStyle;
leftIcon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean; showArrow?: boolean;
}; };
@@ -26,6 +27,7 @@ export function Button({
variant = "primary", variant = "primary",
disabled, disabled,
style, style,
leftIcon,
showArrow = false, showArrow = false,
...props ...props
}: ButtonProps) { }: ButtonProps) {
@@ -44,7 +46,11 @@ export function Button({
borderWidth: 1, borderWidth: 1,
borderColor: colors.destructive, borderColor: colors.destructive,
}, },
ghost: { backgroundColor: "transparent" }, ghost: {
backgroundColor: colors.cardGlass,
borderWidth: 1,
borderColor: colors.borderGlass,
},
} as const; } as const;
const labelStyles = { const labelStyles = {
@@ -73,7 +79,16 @@ export function Button({
/> />
) : ( ) : (
<View style={styles.content}> <View style={styles.content}>
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text> {leftIcon ? (
<Ionicons
name={leftIcon}
size={16}
color={labelStyles[variant].color}
/>
) : null}
<Text style={[styles.label, labelStyles[variant]]} numberOfLines={1}>
{title}
</Text>
{showArrow ? ( {showArrow ? (
<Ionicons <Ionicons
name="arrow-forward" name="arrow-forward"
@@ -99,7 +114,9 @@ const styles = StyleSheet.create({
content: { content: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center",
gap: spacing.sm, gap: spacing.sm,
minWidth: 0,
}, },
arrow: { arrow: {
marginTop: 1, marginTop: 1,
@@ -113,5 +130,6 @@ const styles = StyleSheet.create({
label: { label: {
fontSize: 14, fontSize: 14,
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
flexShrink: 1,
}, },
}); });
+6 -1
View File
@@ -7,7 +7,11 @@ type RunningEntry = {
description: string; description: string;
startedAt: Date | string; startedAt: Date | string;
client?: { name: string } | null; client?: { name: string } | null;
invoice?: { invoicePrefix: string | null; invoiceNumber: string } | null; invoice?: {
invoicePrefix: string | null;
invoiceNumber: string;
business?: { name: string } | null;
} | null;
}; };
type LiveActivityHandle = { type LiveActivityHandle = {
@@ -64,6 +68,7 @@ export function buildTimeClockActivityProps(
}), }),
description: resolveClockDescription(running.description), description: resolveClockDescription(running.description),
clientName: running.client?.name ?? "", clientName: running.client?.name ?? "",
businessName: invoice?.business?.name ?? "",
invoiceLabel: invoice invoiceLabel: invoice
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}` ? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
: "", : "",
+1
View File
@@ -9,5 +9,6 @@ export type TimeClockActivityProps = {
clockTime: string; clockTime: string;
description: string; description: string;
clientName: string; clientName: string;
businessName: string;
invoiceLabel: string; invoiceLabel: string;
}; };
+82
View File
@@ -0,0 +1,82 @@
// @ts-check
const fs = require("fs");
const path = require("path");
const { withDangerousMod } = require("@expo/config-plugins");
const widgetsPath = path.join("node_modules", "expo-widgets", "ios", "Widgets");
const liveActivityBannerSource = `import SwiftUI
import WidgetKit
import ActivityKit
@available(iOS 18.0, *)
struct LiveActivityBanner: View {
@Environment(\\.activityFamily) var activityFamily
var context: ActivityViewContext<LiveActivityAttributes>
var nodes: [String: Any]?
var body: some View {
if let nodes, let node = nodes["banner"] as? [String: Any] {
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
} else {
EmptyView()
}
}
}
`;
function patchWidgetLiveActivitySwift(source) {
const target = ` if #available(iOS 18.0, *) {
LiveActivityBanner(context: context, nodes: nodes)
} else if let node = nodes["banner"] as? [String: Any] {
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
} else {
EmptyView()
}`;
const framedTarget = ` if #available(iOS 18.0, *) {
LiveActivityBanner(context: context, nodes: nodes)
} else if let node = nodes["banner"] as? [String: Any] {
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
EmptyView()
}`;
const hardcodedBanner = ` if #available(iOS 18.0, *) {
LiveActivityBanner(context: context, nodes: nodes)
} else {
TimeClockNativeActivityBanner(context: context)
}`;
if (source.includes(target)) {
return source;
}
return source.replace(framedTarget, target).replace(hardcodedBanner, target);
}
/** @type {import('@expo/config-plugins').ConfigPlugin} */
function withLiveActivityBannerFrame(config) {
return withDangerousMod(config, [
"ios",
async (config) => {
const sourceDir = path.join(config.modRequest.projectRoot, widgetsPath);
const bannerPath = path.join(sourceDir, "LiveActivityBanner.swift");
const activityPath = path.join(sourceDir, "WidgetLiveActivity.swift");
if (!fs.existsSync(bannerPath) || !fs.existsSync(activityPath)) {
throw new Error(`Could not find expo-widgets live activity sources in ${sourceDir}`);
}
fs.writeFileSync(bannerPath, liveActivityBannerSource);
const activitySource = fs.readFileSync(activityPath, "utf8");
const patchedActivitySource = patchWidgetLiveActivitySwift(activitySource);
if (patchedActivitySource !== activitySource) {
fs.writeFileSync(activityPath, patchedActivitySource);
}
return config;
},
]);
}
module.exports = withLiveActivityBannerFrame;
+256
View File
@@ -0,0 +1,256 @@
// @ts-check
const fs = require("fs");
const path = require("path");
const { withDangerousMod } = require("@expo/config-plugins");
const dynamicViewPath = path.join(
"node_modules",
"expo-widgets",
"ios",
"Widgets",
"DynamicView.swift",
);
// expo-widgets rebuilds every WidgetsDynamicView child with a brand-new random UUID
// on every single render (see the original "Hack to satisfy ExpoSwiftUI.AnyChild"
// comment), so the `ForEach(props.children, id: \.id)` that HStack/VStack use to
// render their children sees a totally different identity set on each re-render.
// SwiftUI then treats every child as removed-and-reinserted instead of updated in
// place, which can make children silently fail to render whenever the parent's
// layout is recomputed (e.g. after a `.frame(maxWidth:)` change). This patch gives
// each child a stable identity derived from its structural position, cached across
// renders, so ForEach can correctly diff instead of thrashing.
const patchedSource = `import SwiftUI
import ExpoModulesCore
import ExpoUI
// TODO(@jakex7): Hack to satisfy ExpoSwiftUI.AnyChild with random UUID value
class NodeIdentityWrapper {
let id: UUID
init(id: UUID) {
self.id = id
}
}
// Reuses the same NodeIdentityWrapper for a given structural key across re-renders,
// so ForEach(id: \\.id) sees a stable identity instead of a fresh UUID every render.
private final class NodeIdentityCache {
static let shared = NodeIdentityCache()
private var wrappers: [String: NodeIdentityWrapper] = [:]
func wrapper(for key: String) -> NodeIdentityWrapper {
if let existing = wrappers[key] {
return existing
}
let created = NodeIdentityWrapper(id: UUID())
wrappers[key] = created
return created
}
}
extension ObjectIdentifier: @retroactive Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(String(describing: self))
}
}
public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild {
let node: [String: Any]
let name: String
let kind: WidgetsKind
let entryIndex: Int?
let environmentString: String?
let childKey: String?
private var uuid: NodeIdentityWrapper {
guard let childKey else {
return NodeIdentityWrapper(id: UUID())
}
return NodeIdentityCache.shared.wrapper(for: childKey)
}
public var id: ObjectIdentifier {
ObjectIdentifier(uuid)
}
public init(name: String, kind: WidgetsKind, node: [String: Any]) {
self.name = name
self.kind = kind
self.node = node
self.entryIndex = nil
self.environmentString = nil
self.childKey = node["type"] as? String
}
public init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?) {
self.name = name
self.kind = kind
self.node = node
self.entryIndex = entryIndex
self.environmentString = environmentString
self.childKey = node["type"] as? String
}
private init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?, childKey: String) {
self.name = name
self.kind = kind
self.node = node
self.entryIndex = entryIndex
self.environmentString = environmentString
self.childKey = childKey
}
@ViewBuilder
public var body: some View {
switch node["type"] as? String {
case "TextView":
render(TextView.self, TextViewProps.self, updateProps: updateChildren)
case "HStackView":
render(HStackView.self, HStackViewProps.self, updateProps: updateChildren)
case "VStackView":
render(VStackView.self, VStackViewProps.self, updateProps: updateChildren)
case "ZStackView":
render(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren)
case "RectangleView":
render(RectangleView.self, RectangleViewProps.self)
case "RoundedRectangleView":
render(RoundedRectangleView.self, RoundedRectangleViewProps.self)
case "CapsuleView":
render(CapsuleView.self, CapsuleViewProps.self)
case "CircleView":
render(CircleView.self, CircleViewProps.self)
case "ImageView":
render(ImageView.self, ImageViewProps.self)
case "AccessoryWidgetBackgroundView":
render(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self)
case "DividerView":
render(DividerView.self, DividerProps.self)
case "EllipseView":
render(EllipseView.self, EllipseViewProps.self)
case "LabelView":
render(LabelView.self, LabelViewProps.self)
case "ProgressView":
render(ProgressView.self, ProgressViewProps.self)
case "SpacerView":
render(SpacerView.self, SpacerViewProps.self)
case "UnevenRoundedRectangleView":
render(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self)
case "GaugeView":
render(GaugeView.self, GaugeProps.self)
case "ChartView":
render(ChartView.self, ChartProps.self)
case "Button":
if #available(iOS 17.0, *) {
switch kind {
case .widget:
render(WidgetButtonView.self, ButtonProps.self) { buttonProps in
try updateChildren(buttonProps)
buttonProps.source = name
buttonProps.entryIndex = entryIndex
buttonProps.environmentString = environmentString
}
case .liveActivity:
render(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in
try updateChildren(buttonProps)
buttonProps.source = name
}
}
} else {
render(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren)
}
case "react.fragment":
render(FragmentView.self, FragmentProps.self, updateProps: updateChildren)
case "LinkView":
render(LinkView.self, LinkViewProps.self, updateProps: updateChildren)
#if DEBUG
case "RedBoxView":
render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in
redBoxProps.source = name
redBoxProps.kind = kind
}
default:
ZStack {
Color.red.opacity(0.5)
Text("Unable to get the view for: \\(node["type"] as? String ?? "undefined")")
}
#else
default:
EmptyView()
#endif
}
}
// MARK: - Render Method
@ViewBuilder
private func render<P, V>(_ viewType: V.Type, _ propsType: P.Type, updateProps: ((_ initialProps: P) throws -> Void)? = nil) -> some View
where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P {
// immediately invoked closure {}() here because we can't use 'do-catch' inside @ViewBuilder
{
do {
if let rawProps = node["props"] as? [String: Any] {
let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context)
try updateProps?(props)
return AnyView(UIBaseView<P, V>(props: props).transition(.identity))
}
return AnyView(EmptyView())
} catch {
return AnyView(EmptyView())
}
}()
}
// MARK: - Function that sets children as DynamicView
private func updateChildren<P>(_ initialProps: P) throws
where P: UIBaseViewProps {
let baseKey = childKey ?? (node["type"] as? String ?? "root")
if let props = node["props"] as? [String: Any] {
if let children = props["children"] as? [Any] {
let validChildren = children.compactMap { $0 as? [String: Any] }
initialProps.children = validChildren.enumerated().map { index, childNode in
let childType = childNode["type"] as? String ?? "unknown"
return WidgetsDynamicView(
name: name,
kind: kind,
node: childNode,
entryIndex: entryIndex,
environmentString: environmentString,
childKey: "\\(baseKey).\\(index).\\(childType)"
)
}
} else if let child = props["children"] as? [String: Any] {
let childType = child["type"] as? String ?? "unknown"
initialProps.children = [WidgetsDynamicView(
name: name,
kind: kind,
node: child,
entryIndex: entryIndex,
environmentString: environmentString,
childKey: "\\(baseKey).0.\\(childType)"
)]
}
}
}
}
`;
/** @type {import('@expo/config-plugins').ConfigPlugin} */
function withStableWidgetsChildIdentity(config) {
return withDangerousMod(config, [
"ios",
async (config) => {
const filePath = path.join(config.modRequest.projectRoot, dynamicViewPath);
if (!fs.existsSync(filePath)) {
throw new Error(`Could not find expo-widgets DynamicView.swift at ${filePath}`);
}
fs.writeFileSync(filePath, patchedSource);
return config;
},
]);
}
module.exports = withStableWidgetsChildIdentity;