Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530d0dc34d | ||
|
|
6497ee4dec |
@@ -10,7 +10,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "20",
|
"buildNumber": "22",
|
||||||
"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
@@ -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"
|
||||||
|
|||||||
+302
-119
@@ -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",
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+276
-140
@@ -1,3 +1,4 @@
|
|||||||
|
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 {
|
||||||
@@ -8,6 +9,8 @@ import {
|
|||||||
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 +20,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 +52,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 +96,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 +119,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 +174,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 +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) =>
|
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 +377,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 +425,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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,6 +72,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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("")} />
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user