Compare commits
13
Commits
8a3f498874
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3b9bf6a0 | ||
|
|
ae7350dae6 | ||
|
|
530d0dc34d | ||
|
|
6497ee4dec | ||
|
|
107b593002 | ||
|
|
b7eef743bd | ||
|
|
9498a09b5b | ||
|
|
0aa1b0b431 | ||
|
|
57e1aa658a | ||
|
|
477459edd4 | ||
|
|
21268ddd39 | ||
|
|
7bc8559b44 | ||
|
|
8ad4210908 |
@@ -10,12 +10,14 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "11",
|
"buildNumber": "23",
|
||||||
"icon": "./assets/beenvoice.icon",
|
"icon": "./assets/beenvoice.icon",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
|
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
|
||||||
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
|
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice.",
|
||||||
|
"NSCameraUsageDescription": "beenvoice uses the camera to scan expense receipts.",
|
||||||
|
"NSPhotoLibraryUsageDescription": "beenvoice imports receipt photos for expense tracking."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.USE_BIOMETRIC",
|
"android.permission.USE_BIOMETRIC",
|
||||||
"android.permission.USE_FINGERPRINT"
|
"android.permission.USE_FINGERPRINT",
|
||||||
|
"android.permission.CAMERA"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
@@ -65,6 +68,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withSyncWidgetVersions.js",
|
"./plugins/withSyncWidgetVersions.js",
|
||||||
|
"./plugins/withLiveActivityBannerFrame.js",
|
||||||
|
"./plugins/withStableWidgetsChildIdentity.js",
|
||||||
[
|
[
|
||||||
"expo-local-authentication",
|
"expo-local-authentication",
|
||||||
{
|
{
|
||||||
@@ -82,7 +87,20 @@
|
|||||||
"@react-native-community/datetimepicker",
|
"@react-native-community/datetimepicker",
|
||||||
"./plugins/withAppIntents.js",
|
"./plugins/withAppIntents.js",
|
||||||
"./plugins/withAppStoreSigning.js",
|
"./plugins/withAppStoreSigning.js",
|
||||||
"expo-sharing"
|
"expo-sharing",
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "beenvoice imports receipt photos for expense tracking.",
|
||||||
|
"cameraPermission": "beenvoice uses the camera to scan expense receipts."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withExpoMlkitOcrEnv.js",
|
||||||
|
{
|
||||||
|
"iosEngine": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
|
|||||||
|
|
||||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||||
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||||
|
import { OnboardingGate } from "@/components/OnboardingGate";
|
||||||
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||||
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -36,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>
|
||||||
@@ -66,14 +67,15 @@ 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
|
<NativeTabs.Trigger.Icon
|
||||||
sf={{ default: "gearshape", selected: "gearshape.fill" }}
|
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
|
||||||
md="settings"
|
md="more_horiz"
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
<OnboardingGate />
|
||||||
<InvoiceReminderSync />
|
<InvoiceReminderSync />
|
||||||
<TimeClockLiveActivitySync />
|
<TimeClockLiveActivitySync />
|
||||||
<ShortcutHandler />
|
<ShortcutHandler />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Alert,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -15,6 +15,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
|
|||||||
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 { PageHeader } from "@/components/PageHeader";
|
||||||
|
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 { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
@@ -38,6 +39,12 @@ export default function EntitiesScreen() {
|
|||||||
|
|
||||||
const clientsQuery = api.clients.getAll.useQuery();
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
const businessesQuery = api.businesses.getAll.useQuery();
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const deleteClient = api.clients.delete.useMutation({
|
||||||
|
onSuccess: () => void clientsQuery.refetch(),
|
||||||
|
});
|
||||||
|
const deleteBusiness = api.businesses.delete.useMutation({
|
||||||
|
onSuccess: () => void businessesQuery.refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -68,6 +75,20 @@ export default function EntitiesScreen() {
|
|||||||
else void businessesQuery.refetch();
|
else void businessesQuery.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id: string, name: string) {
|
||||||
|
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
if (tab === "clients") deleteClient.mutate({ id });
|
||||||
|
else deleteBusiness.mutate({ id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage>
|
<TabPage>
|
||||||
@@ -112,8 +133,27 @@ export default function EntitiesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
clients.map((client) => (
|
clients.map((client) => (
|
||||||
<Pressable
|
<SwipeableRow
|
||||||
key={client.id}
|
key={client.id}
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(client.id, client.name),
|
||||||
|
},
|
||||||
|
]}
|
||||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||||
>
|
>
|
||||||
<GlassSurface style={styles.card}>
|
<GlassSurface style={styles.card}>
|
||||||
@@ -130,7 +170,7 @@ export default function EntitiesScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
</Pressable>
|
</SwipeableRow>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
) : businesses.length === 0 ? (
|
) : businesses.length === 0 ? (
|
||||||
@@ -142,8 +182,27 @@ export default function EntitiesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
businesses.map((business) => (
|
businesses.map((business) => (
|
||||||
<Pressable
|
<SwipeableRow
|
||||||
key={business.id}
|
key={business.id}
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(business.id, business.name),
|
||||||
|
},
|
||||||
|
]}
|
||||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||||
>
|
>
|
||||||
<GlassSurface style={styles.card}>
|
<GlassSurface style={styles.card}>
|
||||||
@@ -160,7 +219,7 @@ export default function EntitiesScreen() {
|
|||||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
</Pressable>
|
</SwipeableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
|
|||||||
+315
-97
@@ -1,30 +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 { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
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,
|
||||||
@@ -32,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) {
|
||||||
@@ -40,8 +53,8 @@ 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}>{statsQuery.error.message}</Text>
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Screen>
|
</Screen>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
@@ -50,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.sendReminderDue ?? [];
|
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}
|
||||||
@@ -82,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}>
|
||||||
@@ -92,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}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -106,72 +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")}
|
||||||
/>
|
/>
|
||||||
</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 ? (
|
||||||
|
<GlassSurface style={styles.draftGlass}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.draftBanner}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.draftCopy}>
|
||||||
|
<Text style={styles.draftTitle}>Current draft</Text>
|
||||||
|
<Text style={styles.draftText}>
|
||||||
|
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
|
||||||
|
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
|
||||||
|
{stats.currentDraft.totalHours.toFixed(1)}h logged
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</GlassSurface>
|
||||||
|
) : 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);
|
||||||
@@ -181,9 +340,6 @@ 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -199,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}>
|
||||||
@@ -207,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}>
|
||||||
@@ -231,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",
|
||||||
@@ -268,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",
|
||||||
@@ -327,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,
|
||||||
@@ -375,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",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
|||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
@@ -23,6 +24,7 @@ import { api } from "@/lib/trpc";
|
|||||||
|
|
||||||
export default function InvoiceDetailScreen() {
|
export default function InvoiceDetailScreen() {
|
||||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||||
|
const { colors } = useAppTheme();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const scrollPadding = useTabBarScrollPadding();
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
@@ -50,10 +52,9 @@ export default function InvoiceDetailScreen() {
|
|||||||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
|
||||||
const previewInput = useMemo(
|
const previewInput = useMemo(
|
||||||
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
|
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||||
[invoice],
|
[invoiceQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -78,6 +79,7 @@ export default function InvoiceDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
@@ -209,8 +211,9 @@ export default function InvoiceDetailScreen() {
|
|||||||
add lines manually.
|
add lines manually.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
invoice.items.map((item) => (
|
invoice.items.map((item) => {
|
||||||
<View key={item.id} style={styles.lineItem}>
|
const line = (
|
||||||
|
<View style={styles.lineItem}>
|
||||||
<View style={styles.lineMeta}>
|
<View style={styles.lineMeta}>
|
||||||
<Text style={styles.lineDescription}>{item.description}</Text>
|
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||||
<Text style={styles.lineSub}>
|
<Text style={styles.lineSub}>
|
||||||
@@ -222,7 +225,31 @@ export default function InvoiceDetailScreen() {
|
|||||||
{formatCurrency(item.amount, invoice.currency)}
|
{formatCurrency(item.amount, invoice.currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))
|
);
|
||||||
|
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
return <View key={item.id}>{line}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow
|
||||||
|
key={item.id}
|
||||||
|
backgroundColor={colors.card}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||||
|
|||||||
@@ -196,6 +196,15 @@ export default function InvoiceEditScreen() {
|
|||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateItem(index: number) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const source = prev[index];
|
||||||
|
if (!source) return prev;
|
||||||
|
const copy = { ...source, id: undefined };
|
||||||
|
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -325,6 +334,7 @@ export default function InvoiceEditScreen() {
|
|||||||
isLast={index === items.length - 1}
|
isLast={index === items.length - 1}
|
||||||
onChange={(patch) => updateItem(index, patch)}
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
|
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||||
readOnly={!isDraft}
|
readOnly={!isDraft}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { router } from "expo-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -16,6 +15,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
|
|||||||
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 { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -25,6 +25,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
|
|||||||
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 { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||||
@@ -49,6 +50,14 @@ export default function InvoicesScreen() {
|
|||||||
onError: (err) => Alert.alert("Update failed", err.message),
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.invoices.getAll.invalidate();
|
||||||
|
utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Delete failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
if (invoicesQuery.isLoading) {
|
if (invoicesQuery.isLoading) {
|
||||||
return <LoadingScreen message="Loading invoices…" />;
|
return <LoadingScreen message="Loading invoices…" />;
|
||||||
}
|
}
|
||||||
@@ -59,7 +68,7 @@ export default function InvoicesScreen() {
|
|||||||
<TabPage>
|
<TabPage>
|
||||||
<View style={styles.errorBox}>
|
<View style={styles.errorBox}>
|
||||||
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||||
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text>
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</TabPage>
|
</TabPage>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
@@ -93,6 +102,17 @@ export default function InvoicesScreen() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDelete(invoiceId: string, label: string) {
|
||||||
|
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage>
|
<TabPage>
|
||||||
@@ -134,9 +154,60 @@ export default function InvoicesScreen() {
|
|||||||
) : (
|
) : (
|
||||||
invoices.map((invoice) => {
|
invoices.map((invoice) => {
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
icon: "open-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
|
||||||
|
},
|
||||||
|
...(status === "draft"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "send",
|
||||||
|
label: "Send",
|
||||||
|
icon: "send-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(invoice.id, label),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
icon: "flag-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
onPress: () => promptStatusChange(invoice.id, status),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<SwipeableRow
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
|
actions={actions}
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||||
>
|
>
|
||||||
@@ -144,10 +215,7 @@ export default function InvoicesScreen() {
|
|||||||
<View style={styles.cardInner}>
|
<View style={styles.cardInner}>
|
||||||
<View style={styles.cardTop}>
|
<View style={styles.cardTop}>
|
||||||
<View style={styles.cardMeta}>
|
<View style={styles.cardMeta}>
|
||||||
<Text style={styles.invoiceNumber}>
|
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||||
{invoice.invoicePrefix}
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.clientName}>
|
<Text style={styles.clientName}>
|
||||||
{invoice.client?.name ?? "Client"}
|
{invoice.client?.name ?? "Client"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -162,7 +230,7 @@ export default function InvoicesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
</Pressable>
|
</SwipeableRow>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ export default function NewInvoiceScreen() {
|
|||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateItem(index: number) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const source = prev[index];
|
||||||
|
if (!source) return prev;
|
||||||
|
const copy = { ...source, id: undefined };
|
||||||
|
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
if (!canCreate) return;
|
if (!canCreate) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -327,6 +336,7 @@ export default function NewInvoiceScreen() {
|
|||||||
isLast={index === items.length - 1}
|
isLast={index === items.length - 1}
|
||||||
onChange={(patch) => updateItem(index, patch)}
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
|
onDuplicate={() => duplicateItem(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export default function InvoiceSendScreen() {
|
|||||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
|
||||||
const previewInput = useMemo(
|
const previewInput = useMemo(
|
||||||
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
|
() =>
|
||||||
[invoice],
|
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
|
||||||
|
[invoiceQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -65,10 +65,11 @@ export default function InvoiceSendScreen() {
|
|||||||
return <LoadingScreen message="Loading invoice…" />;
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoiceQuery.data) {
|
||||||
return <LoadingScreen message="Invoice not found" />;
|
return <LoadingScreen message="Invoice not found" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
const businessName = invoice.business?.name ?? "Your business";
|
const businessName = invoice.business?.name ?? "Your business";
|
||||||
@@ -91,7 +92,7 @@ export default function InvoiceSendScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendInvoice.mutate({
|
sendInvoice.mutate({
|
||||||
invoiceId: invoice!.id,
|
invoiceId: invoice.id,
|
||||||
customMessage: customMessage.trim() || undefined,
|
customMessage: customMessage.trim() || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function MoreLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { useLocalSearchParams, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
ExpenseFormFields,
|
||||||
|
type ExpenseFormState,
|
||||||
|
} from "@/components/expenses/ExpenseFormFields";
|
||||||
|
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ReceiptSplitDraft = Pick<
|
||||||
|
ReceiptScanResult,
|
||||||
|
"items" | "subtotal" | "tax" | "total"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function ExpenseDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>({
|
||||||
|
description: "",
|
||||||
|
amountText: "",
|
||||||
|
date: new Date(),
|
||||||
|
category: "",
|
||||||
|
businessId: "",
|
||||||
|
clientId: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseQuery = api.expenses.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||||
|
onSuccess: () => void expenseQuery.refetch(),
|
||||||
|
});
|
||||||
|
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||||
|
onSuccess: () => void expenseQuery.refetch(),
|
||||||
|
});
|
||||||
|
const updateExpense = api.expenses.update.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.expenses.getAll.invalidate();
|
||||||
|
await expenseQuery.refetch();
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||||
|
|
||||||
|
const expense = expenseQuery.data;
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
|
||||||
|
async function attachAndScan(fromCamera: boolean) {
|
||||||
|
if (!id || !expense) return;
|
||||||
|
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const result = await scanReceiptImage(
|
||||||
|
fromCamera,
|
||||||
|
{
|
||||||
|
description: expense.description,
|
||||||
|
amountText: String(expense.amount),
|
||||||
|
date: new Date(expense.date),
|
||||||
|
},
|
||||||
|
(input) => suggest.mutateAsync(input),
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: id,
|
||||||
|
filename: result.image.filename,
|
||||||
|
mimeType: result.image.mimeType,
|
||||||
|
data: result.image.base64,
|
||||||
|
});
|
||||||
|
|
||||||
|
setForm(
|
||||||
|
expenseToForm(expense, {
|
||||||
|
description: result.description,
|
||||||
|
amountText: result.amountText,
|
||||||
|
date: result.date,
|
||||||
|
notes: result.ocrText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setReceiptSplit(
|
||||||
|
result.items.length > 0
|
||||||
|
? {
|
||||||
|
items: result.items,
|
||||||
|
subtotal: result.subtotal,
|
||||||
|
tax: result.tax,
|
||||||
|
total: result.total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setEditing(true);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Receipt attached",
|
||||||
|
result.items.length > 0
|
||||||
|
? "Select the owed items, apply the split amount, then save the expense."
|
||||||
|
: "We filled in what we could. Review and save to update this expense.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveEdits() {
|
||||||
|
if (!id) return;
|
||||||
|
const amount = Number(form.amountText);
|
||||||
|
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
Alert.alert("Invalid fields", "Description and amount are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExpense.mutate({
|
||||||
|
id,
|
||||||
|
description: form.description.trim(),
|
||||||
|
amount,
|
||||||
|
date: form.date,
|
||||||
|
category: form.category || undefined,
|
||||||
|
businessId: form.businessId || undefined,
|
||||||
|
clientId: form.clientId || undefined,
|
||||||
|
billable: form.billable,
|
||||||
|
reimbursable: form.reimbursable,
|
||||||
|
taxDeductible: form.taxDeductible,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
if (!expense) return;
|
||||||
|
setForm(expenseToForm(expense));
|
||||||
|
setReceiptSplit(null);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading expense…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
Expense not found
|
||||||
|
</Text>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={expense.description}
|
||||||
|
subtitle={formatDate(expense.date)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
{receiptSplit ? (
|
||||||
|
<ReceiptItemSelector
|
||||||
|
items={receiptSplit.items}
|
||||||
|
subtotal={receiptSplit.subtotal}
|
||||||
|
tax={receiptSplit.tax}
|
||||||
|
total={receiptSplit.total}
|
||||||
|
onApply={(selection) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
amountText: selection.owedTotal.toFixed(2),
|
||||||
|
notes: mergeNotes(selection.notes, current.notes),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ExpenseFormFields
|
||||||
|
value={form}
|
||||||
|
businesses={businesses}
|
||||||
|
clients={clients}
|
||||||
|
onChange={setForm}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Save changes"
|
||||||
|
loading={updateExpense.isPending}
|
||||||
|
onPress={handleSaveEdits}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Cancel edit"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.metaStack}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
{expense.category || "No category"}
|
||||||
|
{expense.business?.name ? ` · ${expense.business.name}` : ""}
|
||||||
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{expense.billable ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ color: colors.primary, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Billable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.reimbursable ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{
|
||||||
|
color: colors.foreground,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Reimbursable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.taxDeductible ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ color: colors.success, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Tax deductible
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{expense.notes ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{expense.notes}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Edit expense"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={startEditing}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={[styles.section, { color: colors.foreground }]}>
|
||||||
|
Receipts ({expense.receipts.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{expense.receipts.map((receipt) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={receipt.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.receiptRow, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</Text>
|
||||||
|
</SwipeableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||||
|
loading={scanning || uploadReceipt.isPending}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void attachAndScan(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Import photo"
|
||||||
|
variant="secondary"
|
||||||
|
loading={scanning || uploadReceipt.isPending}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void attachAndScan(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
amount: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
metaStack: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
receiptRow: {
|
||||||
|
padding: spacing.md,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function expenseToForm(
|
||||||
|
expense: {
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
date: Date | string;
|
||||||
|
category: string | null;
|
||||||
|
businessId: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean | null;
|
||||||
|
notes: string | null;
|
||||||
|
},
|
||||||
|
overrides: Partial<ExpenseFormState> = {},
|
||||||
|
): ExpenseFormState {
|
||||||
|
return {
|
||||||
|
description: expense.description,
|
||||||
|
amountText: String(expense.amount),
|
||||||
|
date: new Date(expense.date),
|
||||||
|
category: expense.category ?? "",
|
||||||
|
businessId: expense.businessId ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNotes(prefix: string, existing: string) {
|
||||||
|
const trimmed = existing.trim();
|
||||||
|
if (!trimmed) return prefix;
|
||||||
|
if (trimmed.startsWith("Receipt split")) {
|
||||||
|
const detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
||||||
|
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
||||||
|
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
||||||
|
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
||||||
|
}
|
||||||
|
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||||
|
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
||||||
|
|
||||||
|
export default function ExpensesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [filter, setFilter] = useState<ExpenseFilter>("all");
|
||||||
|
const expensesQuery = api.expenses.getAll.useQuery();
|
||||||
|
|
||||||
|
const deleteExpense = api.expenses.delete.useMutation({
|
||||||
|
onSuccess: () => void utils.expenses.getAll.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenses = expensesQuery.data ?? [];
|
||||||
|
const filteredExpenses = useMemo(
|
||||||
|
() =>
|
||||||
|
expenses.filter((expense) => {
|
||||||
|
if (filter === "billable") return expense.billable;
|
||||||
|
if (filter === "receipts") return expense.receiptCount > 0;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[expenses, filter],
|
||||||
|
);
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const total = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + expense.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const billable = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + (expense.billable ? expense.amount : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const receiptCount = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + (expense.receiptCount ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return { total, billable, receiptCount };
|
||||||
|
}, [filteredExpenses]);
|
||||||
|
const groupedExpenses = useMemo(
|
||||||
|
() => groupExpensesByMonth(filteredExpenses),
|
||||||
|
[filteredExpenses],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (expensesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading expenses..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expensesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<PageHeader title="Expenses" subtitle="Expense tracking" />
|
||||||
|
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
|
||||||
|
Could not load expenses
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(expensesQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<View style={styles.header}>
|
||||||
|
<PageHeader
|
||||||
|
title="Expenses"
|
||||||
|
subtitle={`${expenses.length} recorded expense${expenses.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Add expense"
|
||||||
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={expensesQuery.isRefetching}
|
||||||
|
onRefresh={() => void expensesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expenses.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.emptyCard,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
||||||
|
No expenses yet
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
|
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Add expense"
|
||||||
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.summaryGrid}>
|
||||||
|
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
||||||
|
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
||||||
|
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
<FilterChip
|
||||||
|
label="All"
|
||||||
|
active={filter === "all"}
|
||||||
|
onPress={() => setFilter("all")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="Billable"
|
||||||
|
active={filter === "billable"}
|
||||||
|
onPress={() => setFilter("billable")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="With receipts"
|
||||||
|
active={filter === "receipts"}
|
||||||
|
onPress={() => setFilter("receipts")}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{filteredExpenses.length === 0 ? (
|
||||||
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
|
No expenses match this filter.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
groupedExpenses.map(([monthLabel, group]) => (
|
||||||
|
<View key={monthLabel} style={styles.monthGroup}>
|
||||||
|
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
||||||
|
{monthLabel}
|
||||||
|
</Text>
|
||||||
|
{group.map((expense) => (
|
||||||
|
<ExpenseRow
|
||||||
|
key={expense.id}
|
||||||
|
expense={expense}
|
||||||
|
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTile({ label, value }: { label: string; value: string }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.summaryTile,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow
|
||||||
|
backgroundColor={colors.cardGlass}
|
||||||
|
contentStyle={({ pressed }) => [
|
||||||
|
styles.row,
|
||||||
|
{ borderColor: colors.border },
|
||||||
|
pressed && styles.rowPressed,
|
||||||
|
]}
|
||||||
|
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
icon: "open-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||||
|
{expense.description}
|
||||||
|
</Text>
|
||||||
|
{expense.receiptCount ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.receiptPill,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
||||||
|
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||||
|
{expense.receiptCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||||
|
{formatDate(expense.date)}
|
||||||
|
{expense.category ? ` · ${expense.category}` : ""}
|
||||||
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.tagRow}>
|
||||||
|
{expense.billable ? (
|
||||||
|
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||||
|
Billable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.reimbursable ? (
|
||||||
|
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||||
|
Reimbursable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.taxDeductible ? (
|
||||||
|
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.amountStack}>
|
||||||
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupExpensesByMonth(expenses: Expense[]) {
|
||||||
|
const groups = new Map<string, Expense[]>();
|
||||||
|
for (const expense of expenses) {
|
||||||
|
const date = new Date(expense.date);
|
||||||
|
const key = date.toLocaleDateString(undefined, {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(expense);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
||||||
|
const normalized = category?.toLowerCase() ?? "";
|
||||||
|
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
|
||||||
|
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
|
||||||
|
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
|
||||||
|
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
|
||||||
|
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
|
||||||
|
return "receipt-outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.82,
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
sub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
tagRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 11,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
amountStack: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summaryGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
summaryTile: {
|
||||||
|
flexGrow: 1,
|
||||||
|
flexBasis: "30%",
|
||||||
|
minWidth: 104,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 20,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
monthGroup: {
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0,
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingRight: spacing.lg,
|
||||||
|
},
|
||||||
|
receiptPill: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
receiptPillText: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 17,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
defaultExpenseFormState,
|
||||||
|
ExpenseFormFields,
|
||||||
|
type ExpenseFormState,
|
||||||
|
} from "@/components/expenses/ExpenseFormFields";
|
||||||
|
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import {
|
||||||
|
scanReceiptImage,
|
||||||
|
type PickedReceiptImage,
|
||||||
|
type ReceiptScanResult,
|
||||||
|
} from "@/lib/receipt-scan";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ReceiptSplitDraft = Pick<
|
||||||
|
ReceiptScanResult,
|
||||||
|
"items" | "subtotal" | "tax" | "total"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function NewExpenseScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
const defaultBusinessId = useMemo(
|
||||||
|
() =>
|
||||||
|
businesses.find((business) => business.isDefault)?.id ??
|
||||||
|
businesses[0]?.id ??
|
||||||
|
"",
|
||||||
|
[businesses],
|
||||||
|
);
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>(() =>
|
||||||
|
defaultExpenseFormState(),
|
||||||
|
);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [pendingReceipt, setPendingReceipt] =
|
||||||
|
useState<PickedReceiptImage | null>(null);
|
||||||
|
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createExpense = api.expenses.create.useMutation();
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
|
||||||
|
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||||
|
|
||||||
|
async function runScan(fromCamera: boolean) {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const result = await scanReceiptImage(
|
||||||
|
fromCamera,
|
||||||
|
{
|
||||||
|
description: form.description,
|
||||||
|
amountText: form.amountText,
|
||||||
|
date: form.date,
|
||||||
|
},
|
||||||
|
(input) => suggest.mutateAsync(input),
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: result.description,
|
||||||
|
amountText: result.amountText,
|
||||||
|
date: result.date,
|
||||||
|
notes: result.ocrText,
|
||||||
|
businessId: current.businessId || defaultBusinessId,
|
||||||
|
}));
|
||||||
|
setPendingReceipt(result.image);
|
||||||
|
setReceiptSplit(
|
||||||
|
result.items.length > 0
|
||||||
|
? {
|
||||||
|
items: result.items,
|
||||||
|
subtotal: result.subtotal,
|
||||||
|
tax: result.tax,
|
||||||
|
total: result.total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Receipt scanned",
|
||||||
|
result.items.length > 0
|
||||||
|
? "Select the items this person owes, then apply the split amount."
|
||||||
|
: result.amountText
|
||||||
|
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
|
||||||
|
: "Review the fields and enter the total before saving.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const amount = Number(form.amountText);
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
Alert.alert("Description required", "Enter what this expense was for.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
Alert.alert("Amount required", "Enter a valid amount.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expense = await createExpense.mutateAsync({
|
||||||
|
description: form.description.trim(),
|
||||||
|
amount,
|
||||||
|
date: form.date,
|
||||||
|
currency: "USD",
|
||||||
|
category: form.category || undefined,
|
||||||
|
clientId: form.clientId || undefined,
|
||||||
|
businessId: form.businessId || defaultBusinessId || undefined,
|
||||||
|
billable: form.billable,
|
||||||
|
reimbursable: form.reimbursable,
|
||||||
|
taxDeductible: form.taxDeductible,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingReceipt) {
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: expense.id,
|
||||||
|
filename: pendingReceipt.filename,
|
||||||
|
mimeType: pendingReceipt.mimeType,
|
||||||
|
data: pendingReceipt.base64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await utils.expenses.getAll.invalidate();
|
||||||
|
router.replace(`/(app)/more/expenses/${expense.id}` as never);
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not save expense",
|
||||||
|
err instanceof Error ? err.message : "Try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="New expense"
|
||||||
|
subtitle="Add a receipt, fill the details, and save it"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card title="Receipt">
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={scanning ? "Scanning..." : "Take photo"}
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon="camera-outline"
|
||||||
|
loading={scanning}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void runScan(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Choose photo"
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon="image-outline"
|
||||||
|
loading={scanning}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void runScan(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{pendingReceipt ? (
|
||||||
|
<View style={[styles.notice, { backgroundColor: colors.successBg }]}>
|
||||||
|
<Text style={[styles.noticeText, { color: colors.success }]}>
|
||||||
|
Receipt attached. Review the details below before saving.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{receiptSplit ? (
|
||||||
|
<ReceiptItemSelector
|
||||||
|
items={receiptSplit.items}
|
||||||
|
subtotal={receiptSplit.subtotal}
|
||||||
|
tax={receiptSplit.tax}
|
||||||
|
total={receiptSplit.total}
|
||||||
|
onApply={(selection) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
amountText: selection.owedTotal.toFixed(2),
|
||||||
|
notes: mergeNotes(selection.notes, current.notes),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card title="Details">
|
||||||
|
<ExpenseFormFields
|
||||||
|
value={{
|
||||||
|
...form,
|
||||||
|
businessId: form.businessId || defaultBusinessId,
|
||||||
|
}}
|
||||||
|
businesses={businesses}
|
||||||
|
clients={clients}
|
||||||
|
onChange={setForm}
|
||||||
|
notesLabel="Notes"
|
||||||
|
notesPlaceholder="Internal details"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View style={styles.saveActions}>
|
||||||
|
<Button
|
||||||
|
title="Save expense"
|
||||||
|
leftIcon="checkmark-circle-outline"
|
||||||
|
loading={createExpense.isPending}
|
||||||
|
onPress={() => void handleSave()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Cancel"
|
||||||
|
leftIcon="close-circle-outline"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
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) {
|
||||||
|
const trimmed = existing.trim();
|
||||||
|
if (!trimmed) return prefix;
|
||||||
|
if (trimmed.startsWith("Receipt split")) {
|
||||||
|
const detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
||||||
|
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
||||||
|
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
||||||
|
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
||||||
|
}
|
||||||
|
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
type HubItem = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS: HubItem[] = [
|
||||||
|
{
|
||||||
|
title: "Expenses",
|
||||||
|
subtitle: "Track costs and attach receipts",
|
||||||
|
href: "/(app)/more/expenses",
|
||||||
|
icon: "receipt-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
subtitle: "Revenue, hours, and tax summaries",
|
||||||
|
href: "/(app)/more/reports",
|
||||||
|
icon: "bar-chart-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Recurring invoices",
|
||||||
|
subtitle: "Scheduled billing templates",
|
||||||
|
href: "/(app)/more/recurring",
|
||||||
|
icon: "repeat-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Time entries",
|
||||||
|
subtitle: "Full history with edit and delete",
|
||||||
|
href: "/(app)/more/time-entries",
|
||||||
|
icon: "time-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
subtitle: "Account, security, and app preferences",
|
||||||
|
href: "/(app)/more/settings",
|
||||||
|
icon: "settings-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MoreScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView header={<PageHeader title="More" subtitle="Additional tools" />}>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{ITEMS.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.href}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
onPress={() => router.push(item.href as never)}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name={item.icon} size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{item.title}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
{item.subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
list: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function RecurringScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const query = api.recurringInvoices.getAll.useQuery();
|
||||||
|
|
||||||
|
const pause = api.recurringInvoices.pause.useMutation({
|
||||||
|
onSuccess: () => void query.refetch(),
|
||||||
|
});
|
||||||
|
const resume = api.recurringInvoices.resume.useMutation({
|
||||||
|
onSuccess: () => void query.refetch(),
|
||||||
|
});
|
||||||
|
const generateNow = api.recurringInvoices.generateNow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading recurring invoices…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = query.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Recurring"
|
||||||
|
subtitle={`${items.length} schedule${items.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={query.isRefetching}
|
||||||
|
onRefresh={() => void query.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
No recurring invoices yet. Create them on the web dashboard for now.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={item.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "generate",
|
||||||
|
label: "Run",
|
||||||
|
icon: "play-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => generateNow.mutate({ id: item.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "toggle",
|
||||||
|
label: item.status === "active" ? "Pause" : "Resume",
|
||||||
|
icon: item.status === "active" ? "pause-outline" : "play-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () =>
|
||||||
|
item.status === "active"
|
||||||
|
? pause.mutate({ id: item.id })
|
||||||
|
: resume.mutate({ id: item.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{item.name}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
{item.client?.name ?? "Client"} · {item.schedule} · {item.status}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
Next due {formatDate(item.nextDueAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(
|
||||||
|
item.items.reduce((sum, line) => sum + line.hours * line.rate, 0),
|
||||||
|
item.currency,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { StatCard } from "@/components/StatCard";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function ReportsScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const statsQuery = api.dashboard.getStats.useQuery();
|
||||||
|
const expensesQuery = api.expenses.getAll.useQuery();
|
||||||
|
const summaryQuery = api.timeEntries.getSummary.useQuery();
|
||||||
|
|
||||||
|
if (statsQuery.isLoading || expensesQuery.isLoading || summaryQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading reports…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<PageHeader title="Reports" subtitle="Business performance snapshot" />
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(statsQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statsQuery.data!;
|
||||||
|
const expenseTotal = (expensesQuery.data ?? []).reduce((sum, e) => sum + e.amount, 0);
|
||||||
|
const summary = summaryQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={statsQuery.isRefetching}
|
||||||
|
onRefresh={() => {
|
||||||
|
void statsQuery.refetch();
|
||||||
|
void expensesQuery.refetch();
|
||||||
|
void summaryQuery.refetch();
|
||||||
|
}}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||||
|
<StatCard label="Expenses" value={formatCurrency(expenseTotal)} />
|
||||||
|
<StatCard
|
||||||
|
label="Billable hours"
|
||||||
|
value={summary ? summary.totalHours.toFixed(1) : "0"}
|
||||||
|
hint={summary ? `${summary.count} entries` : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Invoice status">
|
||||||
|
{(stats.statusChartData ?? []).map((item) => (
|
||||||
|
<View key={item.status} style={styles.statusRow}>
|
||||||
|
<Text style={{ color: colors.foreground }}>{item.name}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{item.count} · {formatCurrency(item.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Revenue trend (6 mo)">
|
||||||
|
{stats.revenueChartData.map((point) => (
|
||||||
|
<View key={point.month} style={styles.statusRow}>
|
||||||
|
<Text style={{ color: colors.foreground }}>{point.monthLabel}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatCurrency(point.revenue)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
statusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
|||||||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||||
@@ -89,16 +90,20 @@ export default function SettingsScreen() {
|
|||||||
async (result) => {
|
async (result) => {
|
||||||
await finishAccountRemoval({
|
await finishAccountRemoval({
|
||||||
result,
|
result,
|
||||||
|
authClient,
|
||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
signOut: () => authClient.signOut(),
|
activeAccountId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
await authClient.signOut();
|
await performAuthReset({
|
||||||
await clearActiveAccount();
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
router.replace("/(auth)/sign-in");
|
router.replace("/(auth)/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +187,7 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage>
|
<TabPage showMoreBack>
|
||||||
<PinPrompt
|
<PinPrompt
|
||||||
visible={pinPrompt !== null}
|
visible={pinPrompt !== null}
|
||||||
title={
|
title={
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatRunningTimerLabel } from "@/lib/time-clock";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||||
|
|
||||||
|
function groupByDate(entries: TimeEntry[]) {
|
||||||
|
const groups = new Map<string, typeof entries>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const d = new Date(entry.startedAt);
|
||||||
|
const key = d.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeEntriesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||||
|
|
||||||
|
const completed = useMemo(
|
||||||
|
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||||
|
[entriesQuery.data],
|
||||||
|
);
|
||||||
|
const grouped = useMemo(() => groupByDate(completed), [completed]);
|
||||||
|
|
||||||
|
if (entriesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading time entries…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entriesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<PageHeader title="Time entries" subtitle="Completed work history" />
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage showMoreBack>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={entriesQuery.isRefetching}
|
||||||
|
onRefresh={() => void entriesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
No completed entries yet. Start the timer from the Timer tab.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
grouped.map(([label, entries]) => (
|
||||||
|
<Card key={label} title={label}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={entry.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => setEditEntryId(entry.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{formatRunningTimerLabel(entry.description)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
{entry.client?.name ?? "No client"}
|
||||||
|
{entry.invoice
|
||||||
|
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
|
: " · not billed"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{entry.hours ?? "—"}h
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
|
||||||
|
<TimeEntryEditSheet
|
||||||
|
entryId={editEntryId}
|
||||||
|
visible={editEntryId != null}
|
||||||
|
onClose={() => setEditEntryId(null)}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation();
|
||||||
|
const createClient = api.clients.create.useMutation();
|
||||||
|
const complete = api.settings.completeOnboarding.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
await utils.settings.getProfile.invalidate();
|
||||||
|
router.replace("/(app)");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (businessName.trim()) {
|
||||||
|
await createBusiness.mutateAsync({
|
||||||
|
name: businessName.trim(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clientName.trim()) {
|
||||||
|
await createClient.mutateAsync({
|
||||||
|
name: clientName.trim(),
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await complete.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "Welcome to beenvoice",
|
||||||
|
body: "Set up your workspace in a minute — business profile and first client.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Your business",
|
||||||
|
body: "This appears on invoices you send to clients.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "First client",
|
||||||
|
body: "Add someone you bill. You can skip and add clients later.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const current = steps[step]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={[styles.kicker, { color: colors.mutedForeground }]}>
|
||||||
|
Step {step + 1} of {steps.length}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{current.title}</Text>
|
||||||
|
<Text style={[styles.bodyText, { color: colors.mutedForeground }]}>{current.body}</Text>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<Input
|
||||||
|
label="Business name"
|
||||||
|
value={businessName}
|
||||||
|
onChangeText={setBusinessName}
|
||||||
|
placeholder="Your studio or company"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 2 ? (
|
||||||
|
<Input
|
||||||
|
label="Client name"
|
||||||
|
value={clientName}
|
||||||
|
onChangeText={setClientName}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{step > 0 ? (
|
||||||
|
<Button title="Back" variant="secondary" onPress={() => setStep((s) => s - 1)} />
|
||||||
|
) : null}
|
||||||
|
{step < steps.length - 1 ? (
|
||||||
|
<Button title="Continue" onPress={() => setStep((s) => s + 1)} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
title={complete.isPending ? "Finishing…" : "Go to dashboard"}
|
||||||
|
loading={complete.isPending}
|
||||||
|
onPress={() => void finish()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{statusQuery.data && !statusQuery.data.completed ? (
|
||||||
|
<Button title="Skip for now" variant="secondary" onPress={() => void complete.mutateAsync()} />
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
kicker: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
bodyText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
|
||||||
export default function AuthIndex() {
|
export default function AuthIndex() {
|
||||||
|
const { accounts, activeAccountId } = useAccounts();
|
||||||
|
|
||||||
|
if (!activeAccountId && accounts.length > 0) {
|
||||||
|
return <Redirect href="/(auth)/select-account" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <Redirect href="/(auth)/sign-in" />;
|
return <Redirect href="/(auth)/sign-in" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ export default function RegisterScreen() {
|
|||||||
const session = await authClient.getSession();
|
const session = await authClient.getSession();
|
||||||
const user = session.data?.user;
|
const user = session.data?.user;
|
||||||
if (user) {
|
if (user) {
|
||||||
await completeSignInAfterAuth(authClient, {
|
const completed = await completeSignInAfterAuth(authClient, {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
activeAccountId,
|
activeAccountId,
|
||||||
registerAccount: saveAccount,
|
registerAccount: saveAccount,
|
||||||
});
|
});
|
||||||
|
if (!completed) {
|
||||||
|
setError("Account created but session setup failed. Try signing in.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Account created. Sign in with your email and password.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Registration failed");
|
setError(err instanceof Error ? err.message : "Registration failed");
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
|
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||||
|
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
|
import { formatServerHost } from "@/lib/server-mode";
|
||||||
|
|
||||||
|
function initials(name: string, email: string) {
|
||||||
|
const source = name.trim() || email.trim();
|
||||||
|
const parts = source.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return (source[0] ?? "?").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectAccountScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { accounts, activeAccountId, switchAccount, clearActiveAccount } = useAccounts();
|
||||||
|
const [selectingId, setSelectingId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
async function handleSelect(accountId: string) {
|
||||||
|
if (selectingId) return;
|
||||||
|
setSelectingId(accountId);
|
||||||
|
try {
|
||||||
|
await switchAccount(accountId);
|
||||||
|
router.replace("/(auth)/sign-in");
|
||||||
|
} finally {
|
||||||
|
setSelectingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAccount() {
|
||||||
|
if (adding) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await startAdditionalAccountSignIn(clearActiveAccount);
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeAccountId || accounts.length === 0) {
|
||||||
|
return <Redirect href="/(auth)/sign-in" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthScreenLayout>
|
||||||
|
<AuthCard>
|
||||||
|
<AuthCardHeader
|
||||||
|
title="Choose account"
|
||||||
|
description="Select the workspace account to use on this device"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.list}>
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isSelecting = selectingId === account.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={Boolean(selectingId)}
|
||||||
|
key={account.id}
|
||||||
|
onPress={() => void handleSelect(account.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.accountRow,
|
||||||
|
{ backgroundColor: colors.muted, borderColor: colors.border },
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
||||||
|
{initials(account.name, account.email)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.accountMeta}>
|
||||||
|
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||||
|
{account.name || account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{formatServerHost(account.instanceUrl)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelecting ? (
|
||||||
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={Boolean(selectingId)}
|
||||||
|
loading={adding}
|
||||||
|
onPress={() => void handleAddAccount()}
|
||||||
|
title="Sign in to another account"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
</AuthCard>
|
||||||
|
</AuthScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
list: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
accountRow: {
|
||||||
|
minHeight: 72,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
accountMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
accountSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -18,12 +18,14 @@ import { useAuthClient } from "@/contexts/AuthContext";
|
|||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
||||||
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
||||||
|
import { prepareAuthScreenSession } from "@/lib/auth-session";
|
||||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||||
|
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
|
||||||
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||||
|
|
||||||
export default function SignInScreen() {
|
export default function SignInScreen() {
|
||||||
const authClient = useAuthClient();
|
const authClient = useAuthClient();
|
||||||
const { apiUrl, activeAccountId, registerAccount } = useAccounts();
|
const { apiUrl, activeAccountId, clearActiveAccount, registerAccount } = useAccounts();
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -34,6 +36,10 @@ export default function SignInScreen() {
|
|||||||
const [signupsDisabled, setSignupsDisabled] = useState(false);
|
const [signupsDisabled, setSignupsDisabled] = useState(false);
|
||||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void prepareAuthScreenSession(authClient, activeAccountId, clearActiveAccount);
|
||||||
|
}, [authClient, activeAccountId, clearActiveAccount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -81,12 +87,7 @@ export default function SignInScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (signInError) {
|
if (signInError) {
|
||||||
const message = signInError.message ?? "";
|
setError(formatAuthErrorMessage(signInError));
|
||||||
if (message.toLowerCase().includes("internal") || message.includes("500")) {
|
|
||||||
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
|
|
||||||
} else {
|
|
||||||
setError(message || "Invalid email or password");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export default function SignInScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (oauthError) {
|
if (oauthError) {
|
||||||
setError(oauthError.message ?? "Could not sign in with Authentik");
|
setError(formatAuthErrorMessage(oauthError));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -16,6 +16,7 @@ import { View } from "react-native";
|
|||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
|
||||||
import { BrandBackground } from "@/components/BrandBackground";
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
@@ -84,6 +85,7 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ThemedChrome>
|
<ThemedChrome>
|
||||||
@@ -95,17 +97,19 @@ export default function RootLayout() {
|
|||||||
</ThemedChrome>
|
</ThemedChrome>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootNavigator() {
|
function RootNavigator() {
|
||||||
const { data: session, isPending, error } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
|
const { activeAccountId } = useAccounts();
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <LoadingScreen message="Checking session…" />;
|
return <LoadingScreen message="Checking session…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthenticated = Boolean(session?.user) && !error;
|
const isAuthenticated = Boolean(session?.user && activeAccountId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
@@ -25,9 +25,11 @@
|
|||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-image": "^56.0.11",
|
"expo-image": "^56.0.11",
|
||||||
|
"expo-image-picker": "~56.0.18",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-local-authentication": "~56.0.4",
|
"expo-local-authentication": "~56.0.4",
|
||||||
|
"expo-mlkit-ocr": "^0.2.7",
|
||||||
"expo-network": "^56.0.5",
|
"expo-network": "^56.0.5",
|
||||||
"expo-notifications": "^56.0.18",
|
"expo-notifications": "^56.0.18",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
@@ -696,6 +698,10 @@
|
|||||||
|
|
||||||
"expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
|
"expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
|
||||||
|
|
||||||
|
"expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="],
|
||||||
|
|
||||||
|
"expo-image-picker": ["expo-image-picker@56.0.18", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg=="],
|
||||||
|
|
||||||
"expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
|
"expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
|
||||||
|
|
||||||
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
||||||
@@ -708,6 +714,8 @@
|
|||||||
|
|
||||||
"expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
|
"expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
|
||||||
|
|
||||||
|
"expo-mlkit-ocr": ["expo-mlkit-ocr@0.2.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-zx+6rZPwfzGc2ck+tKLFiHwkezbFJqt+Xrz3P5Q0dUAxgIIwwIXtXCjMReRQitnC/vfBGLgh26gO2slEGXSuLQ=="],
|
||||||
|
|
||||||
"expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
|
"expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
|
||||||
|
|
||||||
"expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
|
"expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -92,8 +98,9 @@ export function AccountSwitcher() {
|
|||||||
}
|
}
|
||||||
await finishAccountRemoval({
|
await finishAccountRemoval({
|
||||||
result,
|
result,
|
||||||
|
authClient,
|
||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
signOut: () => authClient.signOut(),
|
activeAccountId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -218,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>
|
||||||
@@ -332,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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/** Redirect new users into onboarding until they complete setup. */
|
||||||
|
export function OnboardingGate() {
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
|
||||||
|
if (statusQuery.data.completed) return;
|
||||||
|
redirected.current = true;
|
||||||
|
router.push("/(app)/onboarding" as never);
|
||||||
|
}, [statusQuery.data, statusQuery.isLoading]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
import { useSession } from "@/contexts/AuthContext";
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
|
import { isRateLimitError } from "@/lib/trpc-errors";
|
||||||
|
|
||||||
/** Refetch auth session when the app returns to the foreground. */
|
/** Refetch auth session when the app returns to the foreground. */
|
||||||
export function SessionSync() {
|
export function SessionSync() {
|
||||||
const { refetch } = useSession();
|
const authClient = useAuthClient();
|
||||||
|
const { activeAccountId, clearActiveAccount } = useAccounts();
|
||||||
|
const { data: session, refetch } = useSession();
|
||||||
const wasBackgrounded = useRef(false);
|
const wasBackgrounded = useRef(false);
|
||||||
|
const resettingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||||
@@ -17,11 +23,30 @@ export function SessionSync() {
|
|||||||
|
|
||||||
if (nextState !== "active" || !wasBackgrounded.current) return;
|
if (nextState !== "active" || !wasBackgrounded.current) return;
|
||||||
wasBackgrounded.current = false;
|
wasBackgrounded.current = false;
|
||||||
void refetch();
|
|
||||||
|
void (async () => {
|
||||||
|
await refetch();
|
||||||
|
const next = await authClient.getSession();
|
||||||
|
if (next.error && isRateLimitError(next.error)) return;
|
||||||
|
if (next.data?.user) return;
|
||||||
|
if (!session?.user || resettingRef.current) return;
|
||||||
|
|
||||||
|
resettingRef.current = true;
|
||||||
|
try {
|
||||||
|
await performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession: refetch,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
resettingRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, [refetch]);
|
}, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { ReactNode, useRef } from "react";
|
||||||
|
import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
|
||||||
|
import Swipeable, {
|
||||||
|
type SwipeableMethods,
|
||||||
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
export type SwipeAction = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SwipeableRowProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
actions: SwipeAction[];
|
||||||
|
enabled?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
contentStyle?: PressableProps["style"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwipeableRow({
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
enabled = true,
|
||||||
|
backgroundColor,
|
||||||
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
contentStyle,
|
||||||
|
}: SwipeableRowProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||||
|
const rowBackground = backgroundColor ?? colors.background;
|
||||||
|
const swipeRef = useRef<SwipeableMethods>(null);
|
||||||
|
const rowOpenRef = useRef(false);
|
||||||
|
const suppressPressUntilRef = useRef(0);
|
||||||
|
|
||||||
|
function suppressContentPress() {
|
||||||
|
suppressPressUntilRef.current = Date.now() + 350;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentPress() {
|
||||||
|
if (!onPress) return;
|
||||||
|
|
||||||
|
if (rowOpenRef.current || Date.now() < suppressPressUntilRef.current) {
|
||||||
|
swipeRef.current?.close();
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (!onPress && !onLongPress) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.row,
|
||||||
|
{ backgroundColor: rowBackground },
|
||||||
|
typeof contentStyle === "function" ? undefined : contentStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleContentPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
style={(state) => [
|
||||||
|
styles.row,
|
||||||
|
{ backgroundColor: rowBackground },
|
||||||
|
typeof contentStyle === "function" ? contentStyle(state) : contentStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRightActions() {
|
||||||
|
return (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<Pressable
|
||||||
|
key={action.key}
|
||||||
|
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||||
|
onPress={() => {
|
||||||
|
suppressContentPress();
|
||||||
|
swipeRef.current?.close();
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
action.onPress();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={action.icon} size={20} color={action.color} />
|
||||||
|
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled || actions.length === 0) {
|
||||||
|
return renderContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable
|
||||||
|
ref={swipeRef}
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
overshootRight={false}
|
||||||
|
onSwipeableOpenStartDrag={suppressContentPress}
|
||||||
|
onSwipeableCloseStartDrag={suppressContentPress}
|
||||||
|
onSwipeableWillOpen={() => {
|
||||||
|
suppressContentPress();
|
||||||
|
rowOpenRef.current = true;
|
||||||
|
}}
|
||||||
|
onSwipeableWillClose={suppressContentPress}
|
||||||
|
onSwipeableClose={() => {
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</Swipeable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
width: 80,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { Switch, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { SelectField, type SelectOption } from "@/components/ui/SelectField";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
|
||||||
|
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
export type ExpenseFormState = {
|
||||||
|
description: string;
|
||||||
|
amountText: string;
|
||||||
|
date: Date;
|
||||||
|
category: string;
|
||||||
|
businessId: string;
|
||||||
|
clientId: string;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpenseFormFieldsProps = {
|
||||||
|
value: ExpenseFormState;
|
||||||
|
businesses: Array<{ id: string; name: string; isDefault?: boolean | null }>;
|
||||||
|
clients: Array<{ id: string; name: string }>;
|
||||||
|
onChange: (value: ExpenseFormState) => void;
|
||||||
|
notesLabel?: string;
|
||||||
|
notesPlaceholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultExpenseFormState(
|
||||||
|
defaultBusinessId = "",
|
||||||
|
): ExpenseFormState {
|
||||||
|
return {
|
||||||
|
description: "",
|
||||||
|
amountText: "",
|
||||||
|
date: new Date(),
|
||||||
|
category: "",
|
||||||
|
businessId: defaultBusinessId,
|
||||||
|
clientId: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseFormFields({
|
||||||
|
value,
|
||||||
|
businesses,
|
||||||
|
clients,
|
||||||
|
onChange,
|
||||||
|
notesLabel = "Notes",
|
||||||
|
notesPlaceholder = "Internal details",
|
||||||
|
}: ExpenseFormFieldsProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
const businessOptions: SelectOption[] = [
|
||||||
|
{ label: "Default business", value: NONE },
|
||||||
|
...businesses.map((business) => ({
|
||||||
|
label: business.isDefault ? `${business.name} (default)` : business.name,
|
||||||
|
value: business.id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const clientOptions: SelectOption[] = [
|
||||||
|
{ label: "No client", value: NONE },
|
||||||
|
...clients.map((client) => ({ label: client.name, value: client.id })),
|
||||||
|
];
|
||||||
|
const categoryOptions: SelectOption[] = [
|
||||||
|
{ label: "No category", value: NONE },
|
||||||
|
...EXPENSE_CATEGORIES.map((category) => ({
|
||||||
|
label: category,
|
||||||
|
value: category,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const setField = <K extends keyof ExpenseFormState>(
|
||||||
|
field: K,
|
||||||
|
nextValue: ExpenseFormState[K],
|
||||||
|
) => onChange({ ...value, [field]: nextValue });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.stack}>
|
||||||
|
<Input
|
||||||
|
label="Description"
|
||||||
|
required
|
||||||
|
value={value.description}
|
||||||
|
onChangeText={(text) => setField("description", text)}
|
||||||
|
placeholder="e.g. Client lunch"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Amount"
|
||||||
|
required
|
||||||
|
value={value.amountText}
|
||||||
|
onChangeText={(text) => setField("amountText", text)}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
<DateTimeField
|
||||||
|
label="Date"
|
||||||
|
value={value.date}
|
||||||
|
onChange={(date) => setField("date", date)}
|
||||||
|
mode="date"
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Category"
|
||||||
|
placeholder="No category"
|
||||||
|
value={value.category || NONE}
|
||||||
|
options={categoryOptions}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setField("category", next === NONE ? "" : next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Business"
|
||||||
|
placeholder="Default business"
|
||||||
|
value={value.businessId || NONE}
|
||||||
|
options={businessOptions}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setField("businessId", next === NONE ? "" : next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Client"
|
||||||
|
placeholder="No client"
|
||||||
|
value={value.clientId || NONE}
|
||||||
|
options={clientOptions}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setField("clientId", next === NONE ? "" : next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.flags}>
|
||||||
|
<FlagSwitch
|
||||||
|
label="Billable"
|
||||||
|
value={value.billable}
|
||||||
|
onValueChange={(next) => setField("billable", next)}
|
||||||
|
/>
|
||||||
|
<FlagSwitch
|
||||||
|
label="Reimbursable"
|
||||||
|
value={value.reimbursable}
|
||||||
|
onValueChange={(next) => setField("reimbursable", next)}
|
||||||
|
/>
|
||||||
|
<FlagSwitch
|
||||||
|
label="Tax deductible"
|
||||||
|
value={value.taxDeductible}
|
||||||
|
onValueChange={(next) => setField("taxDeductible", next)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={notesLabel}
|
||||||
|
value={value.notes}
|
||||||
|
onChangeText={(text) => setField("notes", text)}
|
||||||
|
placeholder={notesPlaceholder}
|
||||||
|
multiline
|
||||||
|
style={styles.notesInput}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
function FlagSwitch({
|
||||||
|
label,
|
||||||
|
value: checked,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.flagRow,
|
||||||
|
{
|
||||||
|
borderColor: colors.borderGlass,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.flagLabel, { color: colors.foreground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={checked}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{
|
||||||
|
true: colors.switchTrackOn,
|
||||||
|
false: colors.switchTrackOff,
|
||||||
|
}}
|
||||||
|
thumbColor={colors.switchThumb}
|
||||||
|
ios_backgroundColor={colors.switchIosBackground}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
stack: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
flagRow: {
|
||||||
|
minHeight: 48,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
flagLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
minHeight: 96,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { ReceiptLineItem } from "@/lib/receipt-parse";
|
||||||
|
|
||||||
|
export type ReceiptSplitSelection = {
|
||||||
|
selectedItemIds: string[];
|
||||||
|
selectedSubtotal: number;
|
||||||
|
allocatedTax: number;
|
||||||
|
owedTotal: number;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceiptItemSelectorProps = {
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
total: number | null;
|
||||||
|
onApply: (selection: ReceiptSplitSelection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReceiptItemSelector({
|
||||||
|
items,
|
||||||
|
subtotal,
|
||||||
|
tax,
|
||||||
|
total,
|
||||||
|
onApply,
|
||||||
|
}: ReceiptItemSelectorProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||||
|
() => new Set(items.length === 1 ? [items[0]!.id] : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculation = useMemo(() => {
|
||||||
|
const selected = items.filter((item) => selectedIds.has(item.id));
|
||||||
|
const selectedSubtotal = roundMoney(
|
||||||
|
selected.reduce((sum, item) => sum + item.amount, 0),
|
||||||
|
);
|
||||||
|
const receiptSubtotal =
|
||||||
|
subtotal && subtotal > 0
|
||||||
|
? subtotal
|
||||||
|
: roundMoney(items.reduce((sum, item) => sum + item.amount, 0));
|
||||||
|
const knownTax =
|
||||||
|
tax ??
|
||||||
|
(total && receiptSubtotal > 0
|
||||||
|
? Math.max(0, roundMoney(total - receiptSubtotal))
|
||||||
|
: 0);
|
||||||
|
const allocatedTax =
|
||||||
|
receiptSubtotal > 0
|
||||||
|
? roundMoney(knownTax * (selectedSubtotal / receiptSubtotal))
|
||||||
|
: 0;
|
||||||
|
const owedTotal = roundMoney(selectedSubtotal + allocatedTax);
|
||||||
|
const notes = [
|
||||||
|
"Receipt split",
|
||||||
|
...selected.map(
|
||||||
|
(item) => `- ${item.name}: ${formatCurrency(item.amount)}`,
|
||||||
|
),
|
||||||
|
`Selected subtotal: ${formatCurrency(selectedSubtotal)}`,
|
||||||
|
`Allocated tax: ${formatCurrency(allocatedTax)}`,
|
||||||
|
`Owed total: ${formatCurrency(owedTotal)}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { selected, selectedSubtotal, allocatedTax, owedTotal, notes };
|
||||||
|
}, [items, selectedIds, subtotal, tax, total]);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.wrap,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerCopy}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
Select expense items
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Pick only the receipt lines that should become this expense. Tax is
|
||||||
|
split proportionally.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.total, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(calculation.owedTotal)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<View style={styles.bulkActions}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() =>
|
||||||
|
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||||
|
}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.bulkAction,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.bulkActionText, { color: colors.primary }]}>
|
||||||
|
Select all
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => setSelectedIds(new Set())}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.bulkAction,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.bulkActionText, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.itemList}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const selected = selectedIds.has(item.id);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item.id}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: selected }}
|
||||||
|
onPress={() => toggle(item.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.item,
|
||||||
|
{ borderColor: colors.borderGlass, backgroundColor: colors.background },
|
||||||
|
selected && { backgroundColor: colors.muted },
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={selected ? "checkmark-circle" : "ellipse-outline"}
|
||||||
|
size={21}
|
||||||
|
color={selected ? colors.primary : colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.itemName, { color: colors.foreground }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.itemAmount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.summary}>
|
||||||
|
<SummaryRow label="Items" value={calculation.selectedSubtotal} />
|
||||||
|
<SummaryRow label="Tax" value={calculation.allocatedTax} />
|
||||||
|
<SummaryRow label="Owed" value={calculation.owedTotal} strong />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
calculation.selected.length === 0
|
||||||
|
? "Select an item to apply"
|
||||||
|
: "Apply selected items"
|
||||||
|
}
|
||||||
|
disabled={calculation.selected.length === 0}
|
||||||
|
onPress={() =>
|
||||||
|
onApply({
|
||||||
|
selectedItemIds: calculation.selected.map((item) => item.id),
|
||||||
|
selectedSubtotal: calculation.selectedSubtotal,
|
||||||
|
allocatedTax: calculation.allocatedTax,
|
||||||
|
owedTotal: calculation.owedTotal,
|
||||||
|
notes: calculation.notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
strong,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
strong?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.summaryValue,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
strong && styles.summaryValueStrong,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatCurrency(value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMoney(value: number) {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
gap: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
headerCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
itemList: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
bulkActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
bulkAction: {
|
||||||
|
minHeight: 34,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
bulkActionText: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
minHeight: 48,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
itemAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
summaryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summaryValueStrong: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
|||||||
|
|
||||||
import { CompactDateField } from "@/components/ui/CompactDateField";
|
import { CompactDateField } from "@/components/ui/CompactDateField";
|
||||||
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { formatCurrency, formatShortDate } from "@/lib/format";
|
import { formatCurrency, formatShortDate } from "@/lib/format";
|
||||||
@@ -21,6 +22,7 @@ type LineItemEditorProps = {
|
|||||||
currency: string;
|
currency: string;
|
||||||
onChange: (patch: Partial<EditableLineItem>) => void;
|
onChange: (patch: Partial<EditableLineItem>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
};
|
};
|
||||||
@@ -38,6 +40,7 @@ export function LineItemEditor({
|
|||||||
currency,
|
currency,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onDuplicate,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
}: LineItemEditorProps) {
|
}: LineItemEditorProps) {
|
||||||
@@ -70,7 +73,7 @@ export function LineItemEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.editBlock,
|
styles.editBlock,
|
||||||
@@ -156,6 +159,35 @@ export function LineItemEditor({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const swipeActions = [
|
||||||
|
...(onDuplicate
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "duplicate",
|
||||||
|
label: "Copy",
|
||||||
|
icon: "copy-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: onDuplicate,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: onRemove,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
|
||||||
|
{content}
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { router } from "expo-router";
|
|||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||||
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 { DateTimeField } from "@/components/ui/DateTimeField";
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
@@ -98,10 +100,11 @@ export function TimeClockPanel({
|
|||||||
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
||||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||||
const [clientsExpanded, setClientsExpanded] = useState(false);
|
const [clientsExpanded, setClientsExpanded] = useState(false);
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
|
||||||
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);
|
||||||
@@ -140,6 +143,15 @@ export function TimeClockPanel({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getRunning.invalidate(),
|
||||||
|
utils.invoices.getBillable.invalidate(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await endTimeClockLiveActivity();
|
await endTimeClockLiveActivity();
|
||||||
@@ -184,8 +196,10 @@ export function TimeClockPanel({
|
|||||||
if (!running) return;
|
if (!running) return;
|
||||||
setClientId(running.clientId ?? "");
|
setClientId(running.clientId ?? "");
|
||||||
setInvoiceId(running.invoiceId ?? "");
|
setInvoiceId(running.invoiceId ?? "");
|
||||||
|
setDescription(running.description ?? "");
|
||||||
setStopNote("");
|
setStopNote("");
|
||||||
setRateText(running.rate != null ? String(running.rate) : "");
|
setRateText(running.rate != null ? String(running.rate) : "");
|
||||||
|
setRunningStartedAt(new Date(running.startedAt));
|
||||||
}, [running]);
|
}, [running]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -195,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;
|
||||||
|
|
||||||
@@ -269,8 +257,7 @@ export function TimeClockPanel({
|
|||||||
}, [agoMinutes, startMode, startedAt]);
|
}, [agoMinutes, startMode, startedAt]);
|
||||||
|
|
||||||
const clockInErrors = useMemo(() => {
|
const clockInErrors = useMemo(() => {
|
||||||
const next: { clientId?: string; rate?: string; start?: string } = {};
|
const next: { rate?: string; start?: string } = {};
|
||||||
if (!clientId) next.clientId = "Choose a client to start";
|
|
||||||
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
|
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
|
||||||
next.rate = "Enter a valid hourly rate";
|
next.rate = "Enter a valid hourly rate";
|
||||||
}
|
}
|
||||||
@@ -278,7 +265,7 @@ export function TimeClockPanel({
|
|||||||
next.start = "Enter how long ago you started";
|
next.start = "Enter how long ago you started";
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}, [agoMinutes, clientId, rateText, startMode]);
|
}, [agoMinutes, rateText, startMode]);
|
||||||
|
|
||||||
const canClockIn = Object.keys(clockInErrors).length === 0;
|
const canClockIn = Object.keys(clockInErrors).length === 0;
|
||||||
|
|
||||||
@@ -307,14 +294,45 @@ export function TimeClockPanel({
|
|||||||
|
|
||||||
function selectClient(nextClientId: string) {
|
function selectClient(nextClientId: string) {
|
||||||
const client = clients.find((c) => c.id === nextClientId);
|
const client = clients.find((c) => c.id === nextClientId);
|
||||||
|
if (running) {
|
||||||
|
updateRunning.mutate({
|
||||||
|
clientId: nextClientId,
|
||||||
|
invoiceId: "",
|
||||||
|
rate: client?.defaultHourlyRate ?? undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
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) {
|
||||||
|
if (running) {
|
||||||
|
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInvoiceId(nextInvoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRunningDescriptionBlur() {
|
||||||
|
if (!running) return;
|
||||||
|
const next = resolveClockDescription(description);
|
||||||
|
if (next === (running.description ?? "")) return;
|
||||||
|
updateRunning.mutate({ description: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRunningStartedAtChange(date: Date) {
|
||||||
|
setRunningStartedAt(date);
|
||||||
|
if (!running || date > new Date()) return;
|
||||||
|
updateRunning.mutate({ startedAt: date });
|
||||||
|
}
|
||||||
|
|
||||||
function selectStartMode(mode: StartMode) {
|
function selectStartMode(mode: StartMode) {
|
||||||
setStartMode(mode);
|
setStartMode(mode);
|
||||||
@@ -363,7 +381,9 @@ export function TimeClockPanel({
|
|||||||
rate: effectiveRate ?? undefined,
|
rate: effectiveRate ?? undefined,
|
||||||
startedAt: backdated,
|
startedAt: backdated,
|
||||||
});
|
});
|
||||||
|
if (clientId) {
|
||||||
await persistClientChoice(clientId);
|
await persistClientChoice(clientId);
|
||||||
|
}
|
||||||
setStartMode("now");
|
setStartMode("now");
|
||||||
setStartedAt(new Date());
|
setStartedAt(new Date());
|
||||||
setAgoMinutes(60);
|
setAgoMinutes(60);
|
||||||
@@ -445,7 +465,7 @@ export function TimeClockPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.idleHint}>
|
<Text style={styles.idleHint}>
|
||||||
Choose a client and clock in. A draft invoice is created automatically if needed.
|
Start the timer anytime — add client, invoice, and details later.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -457,6 +477,62 @@ export function TimeClockPanel({
|
|||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
<View style={styles.formSection}>
|
<View style={styles.formSection}>
|
||||||
|
<Input
|
||||||
|
label="What are you working on?"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
onBlur={handleRunningDescriptionBlur}
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimeField
|
||||||
|
label="Started at"
|
||||||
|
value={runningStartedAt}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
onChange={handleRunningStartedAtChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.setupSection}>
|
||||||
|
<Text style={styles.sectionLabel}>Client</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="None"
|
||||||
|
active={!clientId}
|
||||||
|
onPress={() => selectClient("")}
|
||||||
|
/>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<FilterChip
|
||||||
|
key={client.id}
|
||||||
|
label={client.name}
|
||||||
|
active={clientId === client.id}
|
||||||
|
onPress={() => selectClient(client.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{clientId ? (
|
||||||
|
<View style={styles.setupSection}>
|
||||||
|
<Text style={styles.sectionLabel}>Invoice</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="Entry only"
|
||||||
|
active={!invoiceId}
|
||||||
|
onPress={() => selectInvoice("")}
|
||||||
|
/>
|
||||||
|
{billableInvoices.map((invoice) => (
|
||||||
|
<FilterChip
|
||||||
|
key={invoice.id}
|
||||||
|
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
|
||||||
|
active={invoiceId === invoice.id}
|
||||||
|
onPress={() => selectInvoice(invoice.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Note on stop (optional)"
|
label="Note on stop (optional)"
|
||||||
value={stopNote}
|
value={stopNote}
|
||||||
@@ -490,11 +566,16 @@ export function TimeClockPanel({
|
|||||||
<Text style={styles.sectionLabel}>Client</Text>
|
<Text style={styles.sectionLabel}>Client</Text>
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<Text style={styles.emptyClients}>
|
<Text style={styles.emptyClients}>
|
||||||
Add a client first to start tracking time.
|
No clients yet — you can still start the timer and assign a client later.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<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
|
||||||
@@ -511,20 +592,17 @@ export function TimeClockPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{clockInErrors.clientId ? (
|
|
||||||
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{clientId ? (
|
|
||||||
<View style={styles.setupSection}>
|
<View style={styles.setupSection}>
|
||||||
<Text style={styles.sectionLabel}>Invoice</Text>
|
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
|
||||||
|
{!clientId ? (
|
||||||
|
<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
|
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
|
||||||
label="Entry only"
|
|
||||||
active={!invoiceId}
|
|
||||||
onPress={() => setInvoiceId("")}
|
|
||||||
/>
|
|
||||||
{billableInvoices.map((invoice) => {
|
{billableInvoices.map((invoice) => {
|
||||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||||
return (
|
return (
|
||||||
@@ -537,10 +615,9 @@ export function TimeClockPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{clientId ? (
|
|
||||||
<View style={styles.setupSection}>
|
<View style={styles.setupSection}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -647,12 +724,11 @@ export function TimeClockPanel({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
||||||
loading={clockIn.isPending}
|
loading={clockIn.isPending}
|
||||||
disabled={!canClockIn || clients.length === 0}
|
disabled={!canClockIn}
|
||||||
showArrow={!clockIn.isPending}
|
showArrow={!clockIn.isPending}
|
||||||
onPress={handleClockIn}
|
onPress={handleClockIn}
|
||||||
/>
|
/>
|
||||||
@@ -667,8 +743,35 @@ export function TimeClockPanel({
|
|||||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const row = (
|
return (
|
||||||
<>
|
<SwipeableRow
|
||||||
|
key={entry.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => setEditEntryId(entry.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "invoice",
|
||||||
|
label: "Invoice",
|
||||||
|
icon: "document-text-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () => {
|
||||||
|
if (entry.invoice?.id) {
|
||||||
|
router.push(`/(app)/invoices/${entry.invoice.id}`);
|
||||||
|
} else {
|
||||||
|
setEditEntryId(entry.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.entryRow}>
|
||||||
<View style={styles.entryMeta}>
|
<View style={styles.entryMeta}>
|
||||||
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
||||||
<Text style={styles.entrySub}>
|
<Text style={styles.entrySub}>
|
||||||
@@ -677,31 +780,18 @@ export function TimeClockPanel({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
|
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!entry.invoice) {
|
|
||||||
return (
|
|
||||||
<View key={entry.id} style={styles.entryRow}>
|
|
||||||
{row}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
</SwipeableRow>
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={entry.id}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`View invoice ${invoiceLabel}`}
|
|
||||||
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
|
|
||||||
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
|
|
||||||
>
|
|
||||||
{row}
|
|
||||||
</Pressable>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<TimeEntryEditSheet
|
||||||
|
entryId={editEntryId}
|
||||||
|
visible={editEntryId != null}
|
||||||
|
onClose={() => setEditEntryId(null)}
|
||||||
|
/>
|
||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { parseNonNegativeNumber } from "@/lib/form-validation";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type TimeEntryEditSheetProps = {
|
||||||
|
entryId: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeEntryEditSheet({ entryId, visible, onClose }: TimeEntryEditSheetProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const entryQuery = api.timeEntries.getById.useQuery(
|
||||||
|
{ id: entryId ?? "" },
|
||||||
|
{ enabled: visible && Boolean(entryId) },
|
||||||
|
);
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery(undefined, { enabled: visible });
|
||||||
|
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
|
const [invoiceId, setInvoiceId] = useState("");
|
||||||
|
const [rateText, setRateText] = useState("");
|
||||||
|
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||||
|
const [endedAt, setEndedAt] = useState(() => new Date());
|
||||||
|
|
||||||
|
const billableQuery = api.invoices.getBillable.useQuery(
|
||||||
|
clientId ? { clientId } : undefined,
|
||||||
|
{ enabled: visible && Boolean(clientId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const entry = entryQuery.data;
|
||||||
|
if (!entry) return;
|
||||||
|
setDescription(entry.description ?? "");
|
||||||
|
setClientId(entry.clientId ?? "");
|
||||||
|
setInvoiceId(entry.invoiceId ?? "");
|
||||||
|
setRateText(entry.rate != null ? String(entry.rate) : "");
|
||||||
|
setStartedAt(new Date(entry.startedAt));
|
||||||
|
setEndedAt(entry.endedAt ? new Date(entry.endedAt) : new Date());
|
||||||
|
}, [entryQuery.data]);
|
||||||
|
|
||||||
|
const hoursPreview = useMemo(() => {
|
||||||
|
if (endedAt <= startedAt) return null;
|
||||||
|
return Math.max(0, (endedAt.getTime() - startedAt.getTime()) / 3_600_000);
|
||||||
|
}, [endedAt, startedAt]);
|
||||||
|
|
||||||
|
const rate = parseNonNegativeNumber(rateText);
|
||||||
|
|
||||||
|
const updateEntry = api.timeEntries.update.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getAll.invalidate(),
|
||||||
|
utils.timeEntries.getById.invalidate(),
|
||||||
|
utils.invoices.getAll.invalidate(),
|
||||||
|
utils.dashboard.getStats.invalidate(),
|
||||||
|
]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not save", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteEntry = api.timeEntries.delete.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getAll.invalidate(),
|
||||||
|
utils.invoices.getAll.invalidate(),
|
||||||
|
utils.dashboard.getStats.invalidate(),
|
||||||
|
]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not delete", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!entryId) return;
|
||||||
|
if (endedAt <= startedAt) {
|
||||||
|
Alert.alert("Invalid times", "End time must be after start time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntry.mutate({
|
||||||
|
id: entryId,
|
||||||
|
description,
|
||||||
|
clientId: clientId || "",
|
||||||
|
invoiceId: invoiceId || "",
|
||||||
|
rate: rate ?? undefined,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
hours: hoursPreview ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!entryId) return;
|
||||||
|
Alert.alert("Delete time entry?", "This removes the entry and any linked invoice line.", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteEntry.mutate({ id: entryId }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>Edit time entry</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={8}>
|
||||||
|
<Text style={[styles.close, { color: colors.mutedForeground }]}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||||
|
{entryQuery.isLoading ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>Loading…</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input label="Description" value={description} onChangeText={setDescription} />
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>Client</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="None"
|
||||||
|
active={!clientId}
|
||||||
|
onPress={() => {
|
||||||
|
setClientId("");
|
||||||
|
setInvoiceId("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(clientsQuery.data ?? []).map((client) => (
|
||||||
|
<FilterChip
|
||||||
|
key={client.id}
|
||||||
|
label={client.name}
|
||||||
|
active={clientId === client.id}
|
||||||
|
onPress={() => {
|
||||||
|
setClientId(client.id);
|
||||||
|
setInvoiceId("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{clientId ? (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>Invoice</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="Not on invoice"
|
||||||
|
active={!invoiceId}
|
||||||
|
onPress={() => setInvoiceId("")}
|
||||||
|
/>
|
||||||
|
{(billableQuery.data ?? []).map((invoice) => (
|
||||||
|
<FilterChip
|
||||||
|
key={invoice.id}
|
||||||
|
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
|
||||||
|
active={invoiceId === invoice.id}
|
||||||
|
onPress={() => setInvoiceId(invoice.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Hourly rate"
|
||||||
|
value={rateText}
|
||||||
|
onChangeText={setRateText}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimeField
|
||||||
|
label="Started"
|
||||||
|
value={startedAt}
|
||||||
|
maximumDate={endedAt}
|
||||||
|
onChange={setStartedAt}
|
||||||
|
/>
|
||||||
|
<DateTimeField label="Ended" value={endedAt} minimumDate={startedAt} onChange={setEndedAt} />
|
||||||
|
|
||||||
|
{hoursPreview != null ? (
|
||||||
|
<Text style={[styles.preview, { color: colors.mutedForeground }]}>
|
||||||
|
{hoursPreview.toFixed(2)}h
|
||||||
|
{rate != null && rate > 0
|
||||||
|
? ` · ${formatCurrency(hoursPreview * rate)}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button title="Save changes" loading={updateEntry.isPending} onPress={handleSave} />
|
||||||
|
<Button
|
||||||
|
title="Delete entry"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteEntry.isPending}
|
||||||
|
onPress={confirmDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
close: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
chipWrap: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
|||||||
setRuntimeApiUrl(active.instanceUrl);
|
setRuntimeApiUrl(active.instanceUrl);
|
||||||
} else if (draftUrl) {
|
} else if (draftUrl) {
|
||||||
setRuntimeApiUrl(draftUrl);
|
setRuntimeApiUrl(draftUrl);
|
||||||
} else {
|
} else if (!process.env.EXPO_PUBLIC_API_URL?.trim()) {
|
||||||
setRuntimeApiUrl(DEFAULT_API_URL);
|
setRuntimeApiUrl(DEFAULT_API_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,13 +166,8 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
|||||||
await saveAccounts(nextAccounts);
|
await saveAccounts(nextAccounts);
|
||||||
|
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
const fallback = nextAccounts[0] ?? null;
|
await saveActiveAccountId(null);
|
||||||
await saveActiveAccountId(fallback?.id ?? null);
|
setActiveAccountId(null);
|
||||||
setActiveAccountId(fallback?.id ?? null);
|
|
||||||
if (fallback) {
|
|
||||||
setRuntimeApiUrl(fallback.instanceUrl);
|
|
||||||
setApiUrl(fallback.instanceUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { wasActive, remainingCount: nextAccounts.length };
|
return { wasActive, remainingCount: nextAccounts.length };
|
||||||
|
|||||||
+13
-4
@@ -1,24 +1,33 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
|
import type { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
|
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
type FinishAccountRemovalInput = {
|
type FinishAccountRemovalInput = {
|
||||||
result: RemoveAccountResult;
|
result: RemoveAccountResult;
|
||||||
|
authClient: AuthClient;
|
||||||
clearActiveAccount: () => Promise<void>;
|
clearActiveAccount: () => Promise<void>;
|
||||||
signOut: () => Promise<unknown>;
|
activeAccountId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Navigate to sign-in when the last saved account was removed. */
|
/** Navigate to sign-in when the last saved account was removed. */
|
||||||
export async function finishAccountRemoval({
|
export async function finishAccountRemoval({
|
||||||
result,
|
result,
|
||||||
|
authClient,
|
||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
signOut,
|
activeAccountId,
|
||||||
}: FinishAccountRemovalInput): Promise<void> {
|
}: FinishAccountRemovalInput): Promise<void> {
|
||||||
if (result.remainingCount > 0) return;
|
if (result.remainingCount > 0) return;
|
||||||
|
|
||||||
await signOut();
|
await performAuthReset({
|
||||||
await clearActiveAccount();
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
router.replace("/(auth)/sign-in");
|
router.replace("/(auth)/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -13,6 +13,23 @@ export type SavedAccount = {
|
|||||||
lastUsedAt: number;
|
lastUsedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isSavedAccount(value: unknown): value is SavedAccount {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
|
||||||
|
const account = value as Partial<SavedAccount>;
|
||||||
|
return (
|
||||||
|
typeof account.id === "string" &&
|
||||||
|
account.id.length > 0 &&
|
||||||
|
typeof account.instanceUrl === "string" &&
|
||||||
|
account.instanceUrl.length > 0 &&
|
||||||
|
typeof account.userId === "string" &&
|
||||||
|
account.userId.length > 0 &&
|
||||||
|
typeof account.email === "string" &&
|
||||||
|
typeof account.name === "string" &&
|
||||||
|
typeof account.lastUsedAt === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAccountId(instanceUrl: string, userId: string) {
|
export function buildAccountId(instanceUrl: string, userId: string) {
|
||||||
const host = instanceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
const host = instanceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||||
return `${host}::${userId}`;
|
return `${host}::${userId}`;
|
||||||
@@ -26,8 +43,8 @@ export async function loadAccounts(): Promise<SavedAccount[]> {
|
|||||||
const raw = await AsyncStorage.getItem(ACCOUNTS_KEY);
|
const raw = await AsyncStorage.getItem(ACCOUNTS_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as SavedAccount[];
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
return Array.isArray(parsed) ? parsed.filter(isSavedAccount) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-10
@@ -1,11 +1,5 @@
|
|||||||
import { getApiUrl } from "@/lib/config";
|
import { getApiUrl } from "@/lib/config";
|
||||||
|
import { readHttpErrorMessage } from "@/lib/trpc-errors";
|
||||||
type ApiError = { error?: string; message?: string };
|
|
||||||
|
|
||||||
async function parseError(res: Response) {
|
|
||||||
const data = (await res.json().catch(() => ({}))) as ApiError;
|
|
||||||
return data.error ?? data.message ?? "Something went wrong";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAccount(input: {
|
export async function registerAccount(input: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -20,7 +14,7 @@ export async function registerAccount(input: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await parseError(res));
|
throw new Error(await readHttpErrorMessage(res));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +26,7 @@ export async function requestPasswordReset(email: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await parseError(res));
|
throw new Error(await readHttpErrorMessage(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as { message?: string };
|
const data = (await res.json()) as { message?: string };
|
||||||
@@ -47,6 +41,6 @@ export async function resetPassword(token: string, password: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await parseError(res));
|
throw new Error(await readHttpErrorMessage(res));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import type { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
import { GUEST_AUTH_STORAGE_PREFIX } from "@/lib/auth-storage";
|
||||||
|
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
const CHUNK_MARKER = "\u0001ba-chunks:";
|
||||||
|
const SESSION_TOKEN_COOKIE_PART =
|
||||||
|
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
|
||||||
|
const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
|
||||||
|
|
||||||
|
function readSecureStoreValueSync(key: string): string | null {
|
||||||
|
const value = SecureStore.getItem(key);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (!value.startsWith(CHUNK_MARKER)) return value;
|
||||||
|
|
||||||
|
const count = Number(value.slice(CHUNK_MARKER.length));
|
||||||
|
if (!Number.isInteger(count) || count < 1) return null;
|
||||||
|
|
||||||
|
let assembled = "";
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const chunk = SecureStore.getItem(`${key}.${index}`);
|
||||||
|
if (chunk == null) return null;
|
||||||
|
assembled += chunk;
|
||||||
|
}
|
||||||
|
return assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredCookie(storagePrefix: string): string | null {
|
||||||
|
const raw = readSecureStoreValueSync(
|
||||||
|
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
|
||||||
|
);
|
||||||
|
if (!raw || raw === "{}") return null;
|
||||||
|
|
||||||
|
const cookie = serializeStoredCookies(raw);
|
||||||
|
return cookie.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieNames(cookie: string): string[] {
|
||||||
|
return cookie
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim().split("=", 1)[0])
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
|
||||||
|
export function getAuthCookie(
|
||||||
|
authClient: AuthClient,
|
||||||
|
storagePrefix: string,
|
||||||
|
): string | null {
|
||||||
|
const fromClient = (
|
||||||
|
authClient as AuthClient & { getCookie?: () => string }
|
||||||
|
).getCookie?.();
|
||||||
|
if (fromClient?.trim()) {
|
||||||
|
const cookie = fromClient.trim();
|
||||||
|
if (AUTH_COOKIE_DEBUG) {
|
||||||
|
console.info("[auth-cookie] using client cookie", {
|
||||||
|
storagePrefix,
|
||||||
|
length: cookie.length,
|
||||||
|
names: cookieNames(cookie),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromPrefix = readStoredCookie(storagePrefix);
|
||||||
|
if (fromPrefix) {
|
||||||
|
if (AUTH_COOKIE_DEBUG) {
|
||||||
|
console.info("[auth-cookie] using stored cookie", {
|
||||||
|
storagePrefix,
|
||||||
|
length: fromPrefix.length,
|
||||||
|
names: cookieNames(fromPrefix),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fromPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromGuest =
|
||||||
|
storagePrefix === GUEST_AUTH_STORAGE_PREFIX
|
||||||
|
? null
|
||||||
|
: readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
|
||||||
|
if (AUTH_COOKIE_DEBUG) {
|
||||||
|
console.info("[auth-cookie] resolved tRPC cookie", {
|
||||||
|
storagePrefix,
|
||||||
|
fallbackPrefix:
|
||||||
|
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
|
||||||
|
? GUEST_AUTH_STORAGE_PREFIX
|
||||||
|
: null,
|
||||||
|
hasCookie: Boolean(fromGuest),
|
||||||
|
length: fromGuest?.length ?? 0,
|
||||||
|
names: fromGuest ? cookieNames(fromGuest) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fromGuest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthCookieHeaders(
|
||||||
|
authClient: AuthClient,
|
||||||
|
storagePrefix: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const cookie = getAuthCookie(authClient, storagePrefix);
|
||||||
|
if (!cookie) {
|
||||||
|
if (AUTH_COOKIE_DEBUG) {
|
||||||
|
console.info("[auth-cookie] no tRPC auth cookie", { storagePrefix });
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
|
||||||
|
if (AUTH_COOKIE_DEBUG) {
|
||||||
|
console.info("[auth-cookie] sending tRPC auth headers", {
|
||||||
|
storagePrefix,
|
||||||
|
cookieLength: cookie.length,
|
||||||
|
cookieNames: cookieNames(cookie),
|
||||||
|
hasSessionTokenHeader: Boolean(sessionToken),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cookie,
|
||||||
|
Cookie: cookie,
|
||||||
|
"x-beenvoice-auth-cookie": cookie,
|
||||||
|
...(sessionToken ? { "x-beenvoice-session-token": sessionToken } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
import { authStoragePrefix } from "@/lib/accounts";
|
||||||
|
import {
|
||||||
|
clearAuthStorage,
|
||||||
|
GUEST_AUTH_STORAGE_PREFIX,
|
||||||
|
prepareForAdditionalSignIn,
|
||||||
|
} from "@/lib/auth-storage";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
type PerformAuthResetInput = {
|
||||||
|
authClient: AuthClient;
|
||||||
|
clearActiveAccount: () => Promise<void>;
|
||||||
|
activeAccountId?: string | null;
|
||||||
|
refetchSession?: () => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sign out, wipe local auth storage, and return to guest mode for a clean sign-in screen. */
|
||||||
|
export async function performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession,
|
||||||
|
}: PerformAuthResetInput): Promise<void> {
|
||||||
|
const accountPrefix = activeAccountId ? authStoragePrefix(activeAccountId) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authClient.signOut();
|
||||||
|
} catch {
|
||||||
|
// Continue clearing local state even when the server session is already gone.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountPrefix) {
|
||||||
|
await clearAuthStorage(accountPrefix);
|
||||||
|
}
|
||||||
|
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
|
||||||
|
await clearActiveAccount();
|
||||||
|
await refetchSession?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the auth stack is shown, discard stale SecureStore sessions so sign-in starts clean.
|
||||||
|
* Expired account sessions switch back to guest storage; orphaned guest copies are cleared.
|
||||||
|
*/
|
||||||
|
export async function prepareAuthScreenSession(
|
||||||
|
authClient: AuthClient,
|
||||||
|
activeAccountId: string | null,
|
||||||
|
clearActiveAccount: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
if (session.data?.user) return;
|
||||||
|
|
||||||
|
if (activeAccountId) {
|
||||||
|
await clearAuthStorage(authStoragePrefix(activeAccountId));
|
||||||
|
await clearActiveAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prepareForAdditionalSignIn();
|
||||||
|
}
|
||||||
+15
-2
@@ -66,14 +66,23 @@ export async function readStoredSessionUser(prefix: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise<void> {
|
export async function migrateAuthStorage(
|
||||||
|
fromPrefix: string,
|
||||||
|
toPrefix: string,
|
||||||
|
options: { clearSource?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
if (fromPrefix === toPrefix) return;
|
if (fromPrefix === toPrefix) return;
|
||||||
|
const clearSource = options.clearSource ?? true;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
AUTH_STORAGE_SUFFIXES.map((suffix) =>
|
AUTH_STORAGE_SUFFIXES.map((suffix) =>
|
||||||
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
|
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (clearSource) {
|
||||||
|
await clearAuthStorage(fromPrefix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAuthStorage(prefix: string): Promise<void> {
|
export async function clearAuthStorage(prefix: string): Promise<void> {
|
||||||
@@ -122,7 +131,11 @@ export async function finalizeAuthenticatedAccount(input: {
|
|||||||
? authStoragePrefix(input.activeAccountId)
|
? authStoragePrefix(input.activeAccountId)
|
||||||
: GUEST_AUTH_STORAGE_PREFIX;
|
: GUEST_AUTH_STORAGE_PREFIX;
|
||||||
|
|
||||||
await migrateAuthStorage(sourcePrefix, targetPrefix);
|
if (sourcePrefix !== targetPrefix) {
|
||||||
|
await clearAuthStorage(targetPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
await migrateAuthStorage(sourcePrefix, targetPrefix, { clearSource: false });
|
||||||
|
|
||||||
await input.registerAccount({
|
await input.registerAccount({
|
||||||
instanceUrl: input.apiUrl,
|
instanceUrl: input.apiUrl,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { isUnauthorizedError } from "@/lib/trpc-errors";
|
import { isRateLimitError, isUnauthorizedError } from "@/lib/trpc-errors";
|
||||||
|
|
||||||
export function createAppQueryClient(onUnauthorized: () => void) {
|
export function createAppQueryClient(onUnauthorized: () => void) {
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
@@ -16,7 +16,13 @@ export function createAppQueryClient(onUnauthorized: () => void) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
if (isUnauthorizedError(error)) return false;
|
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
|
||||||
|
return failureCount < 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
if (isRateLimitError(error)) return false;
|
||||||
return failureCount < 1;
|
return failureCount < 1;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { isSupported, recognizeText } from "expo-mlkit-ocr";
|
||||||
|
|
||||||
|
import { parseReceiptText, type ReceiptParseResult } from "@/lib/receipt-parse";
|
||||||
|
|
||||||
|
export type ReceiptOcrResult = ReceiptParseResult & {
|
||||||
|
rawText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mlKitOcrAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
return isSupported();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageUri(uri: string): string {
|
||||||
|
if (uri.startsWith("file://") || uri.startsWith("content://")) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
return `file://${uri}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run on-device ML Kit OCR and parse receipt fields from recognized text. */
|
||||||
|
export async function recognizeReceiptFromImage(uri: string): Promise<ReceiptOcrResult> {
|
||||||
|
if (!mlKitOcrAvailable()) {
|
||||||
|
throw new Error("On-device OCR is not supported on this device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await recognizeText(normalizeImageUri(uri));
|
||||||
|
const rawText = result.text?.trim() ?? "";
|
||||||
|
const parsed = parseReceiptText(rawText);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
rawText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReceiptOcrToForm(
|
||||||
|
ocr: ReceiptParseResult,
|
||||||
|
current: { description: string; amountText: string; date: Date },
|
||||||
|
): { description: string; amountText: string; date: Date; ocrText: string } {
|
||||||
|
return {
|
||||||
|
description: ocr.vendor?.trim() || current.description,
|
||||||
|
amountText: ocr.amount != null ? String(ocr.amount) : current.amountText,
|
||||||
|
date: ocr.date ?? current.date,
|
||||||
|
ocrText: ocr.rawLines.join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
export type ReceiptParseResult = {
|
||||||
|
amount: number | null;
|
||||||
|
date: Date | null;
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
vendor: string | null;
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
rawLines: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReceiptLineItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
rawLine: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMOUNT_PATTERNS = [
|
||||||
|
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
|
||||||
|
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_PATTERNS = [
|
||||||
|
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
|
||||||
|
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUBTOTAL_PATTERNS = [
|
||||||
|
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAX_PATTERNS = [
|
||||||
|
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const NON_ITEM_LINE =
|
||||||
|
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
|
||||||
|
|
||||||
|
function parseAmount(text: string): number | null {
|
||||||
|
for (const pattern of AMOUNT_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
const value = Number(match[1].replace(/,/g, ""));
|
||||||
|
if (Number.isFinite(value) && value > 0) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
||||||
|
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
|
||||||
|
return amounts.length > 0 ? Math.max(...amounts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFirstMatchingAmount(
|
||||||
|
text: string,
|
||||||
|
patterns: RegExp[],
|
||||||
|
): number | null {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
const value = Number(match[1].replace(/,/g, ""));
|
||||||
|
if (Number.isFinite(value) && value >= 0) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(text: string): Date | null {
|
||||||
|
for (const pattern of DATE_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
const parsed = new Date(match[1]);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVendor(lines: string[]): string | null {
|
||||||
|
const candidate = lines.find((line) => line.trim().length >= 3);
|
||||||
|
return candidate?.trim().slice(0, 120) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLineItems(lines: string[]): ReceiptLineItem[] {
|
||||||
|
const items: ReceiptLineItem[] = [];
|
||||||
|
|
||||||
|
for (const [index, rawLine] of lines.entries()) {
|
||||||
|
const line = rawLine.replace(/\s+/g, " ").trim();
|
||||||
|
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
|
||||||
|
|
||||||
|
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
|
||||||
|
if (!match?.[1] || !match[2]) continue;
|
||||||
|
|
||||||
|
const amount = Number(match[2].replace(/,/g, ""));
|
||||||
|
const name = match[1]
|
||||||
|
.replace(/^\d+\s*[xX]\s+/, "")
|
||||||
|
.replace(/\s+\d+\s*[xX]\s*$/, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
|
||||||
|
name: name.slice(0, 80),
|
||||||
|
amount,
|
||||||
|
rawLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReceiptText(text: string): ReceiptParseResult {
|
||||||
|
const normalized = text.replace(/\r/g, "\n").trim();
|
||||||
|
const rawLines = normalized
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: parseAmount(normalized),
|
||||||
|
date: parseDate(normalized),
|
||||||
|
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
|
||||||
|
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
|
||||||
|
vendor: parseVendor(rawLines),
|
||||||
|
items: parseLineItems(rawLines),
|
||||||
|
rawLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyReceiptOcrToForm,
|
||||||
|
recognizeReceiptFromImage,
|
||||||
|
} from "@/lib/receipt-ocr";
|
||||||
|
import type { ReceiptLineItem } from "@/lib/receipt-parse";
|
||||||
|
|
||||||
|
export type ReceiptSuggestFn = (input: { text: string }) => Promise<{
|
||||||
|
amount: number | null;
|
||||||
|
date: Date | null;
|
||||||
|
description: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type PickedReceiptImage = {
|
||||||
|
uri: string;
|
||||||
|
base64: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReceiptScanResult = {
|
||||||
|
image: PickedReceiptImage;
|
||||||
|
description: string;
|
||||||
|
amountText: string;
|
||||||
|
date: Date;
|
||||||
|
ocrText: string;
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
total: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function requestPermissions(fromCamera: boolean): Promise<boolean> {
|
||||||
|
const permission = fromCamera
|
||||||
|
? await ImagePicker.requestCameraPermissionsAsync()
|
||||||
|
: await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
|
||||||
|
if (permission.granted) return true;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
fromCamera ? "Camera access needed" : "Photos access needed",
|
||||||
|
fromCamera
|
||||||
|
? "Allow camera access to scan receipts."
|
||||||
|
: "Allow photo library access to import receipt images.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickReceiptImage(
|
||||||
|
fromCamera: boolean,
|
||||||
|
): Promise<PickedReceiptImage | null> {
|
||||||
|
if (!(await requestPermissions(fromCamera))) return null;
|
||||||
|
|
||||||
|
const result = fromCamera
|
||||||
|
? await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 0.9,
|
||||||
|
base64: true,
|
||||||
|
})
|
||||||
|
: await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 0.9,
|
||||||
|
base64: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets[0]) return null;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
if (!asset.uri || !asset.base64) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not read image",
|
||||||
|
"Try another photo or lower the image size.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: asset.uri,
|
||||||
|
base64: asset.base64,
|
||||||
|
filename:
|
||||||
|
asset.fileName ?? (fromCamera ? "receipt.jpg" : "receipt-import.jpg"),
|
||||||
|
mimeType: asset.mimeType ?? "image/jpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanReceiptImage(
|
||||||
|
fromCamera: boolean,
|
||||||
|
current: { description: string; amountText: string; date: Date },
|
||||||
|
suggest?: ReceiptSuggestFn,
|
||||||
|
): Promise<ReceiptScanResult | null> {
|
||||||
|
const image = await pickReceiptImage(fromCamera);
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ocr = await recognizeReceiptFromImage(image.uri);
|
||||||
|
let next = applyReceiptOcrToForm(ocr, current);
|
||||||
|
next = { ...next, ocrText: ocr.rawText || next.ocrText };
|
||||||
|
|
||||||
|
if (ocr.rawText && suggest) {
|
||||||
|
try {
|
||||||
|
const suggestion = await suggest({ text: ocr.rawText });
|
||||||
|
next = {
|
||||||
|
description: suggestion.description?.trim() || next.description,
|
||||||
|
amountText:
|
||||||
|
suggestion.amount != null
|
||||||
|
? String(suggestion.amount)
|
||||||
|
: next.amountText,
|
||||||
|
date: suggestion.date ? new Date(suggestion.date) : next.date,
|
||||||
|
ocrText: ocr.rawText,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Local parse is enough when server suggest fails offline.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
image,
|
||||||
|
...next,
|
||||||
|
items: ocr.items,
|
||||||
|
subtotal: ocr.subtotal,
|
||||||
|
tax: ocr.tax,
|
||||||
|
total: ocr.amount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
"OCR failed",
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not read text from the receipt.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
+116
-3
@@ -1,8 +1,121 @@
|
|||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
export function isUnauthorizedError(error: unknown): boolean {
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof TRPCClientError) return error.message;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "object" && error !== null && "message" in error) {
|
||||||
|
const message = (error as { message: unknown }).message;
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorStatus(error: unknown): number | undefined {
|
||||||
|
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
|
||||||
|
const status = (error as { status: unknown }).status;
|
||||||
|
return typeof status === "number" ? status : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRetryAfterSeconds(value: string | number | null | undefined): number | null {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const seconds = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||||
|
return Math.ceil(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRateLimitError(error: unknown): boolean {
|
||||||
|
if (errorStatus(error) === 429) return true;
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === "TOO_MANY_REQUESTS") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = errorMessage(error).toLowerCase();
|
||||||
return (
|
return (
|
||||||
error instanceof TRPCClientError &&
|
message.includes("too many") ||
|
||||||
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
|
message.includes("rate limit") ||
|
||||||
|
message.includes("try again later")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnauthorizedError(error: unknown): boolean {
|
||||||
|
if (isRateLimitError(error)) return false;
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
if (error.data?.code === "UNAUTHORIZED") return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorStatus(error) === 401) return true;
|
||||||
|
|
||||||
|
const message = errorMessage(error).toLowerCase();
|
||||||
|
return message === "unauthorized" || message.includes("not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRateLimitMessage(retryAfterSeconds?: number | null): string {
|
||||||
|
const retryAfter = parseRetryAfterSeconds(retryAfterSeconds ?? null);
|
||||||
|
if (retryAfter != null) {
|
||||||
|
if (retryAfter < 60) {
|
||||||
|
return `Too many attempts. Wait ${retryAfter} second${retryAfter === 1 ? "" : "s"} and try again.`;
|
||||||
|
}
|
||||||
|
const minutes = Math.ceil(retryAfter / 60);
|
||||||
|
return `Too many attempts. Wait about ${minutes} minute${minutes === 1 ? "" : "s"} and try again.`;
|
||||||
|
}
|
||||||
|
return "Too many attempts. Please wait a moment and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTrpcErrorMessage(error: unknown, fallback = "Something went wrong"): string {
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return formatRateLimitMessage();
|
||||||
|
}
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
return "Your session expired. Sign in again to continue.";
|
||||||
|
}
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthLikeError = {
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatAuthErrorMessage(error: AuthLikeError | null | undefined): string {
|
||||||
|
if (!error) return "Something went wrong";
|
||||||
|
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return formatRateLimitMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message ?? "";
|
||||||
|
if (message.toLowerCase().includes("internal") || message.includes("500")) {
|
||||||
|
return "Server error — is the API running with Postgres? Check beenvoice dev + docker.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || "Invalid email or password";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHttpErrorMessage(response: Response): Promise<string> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = parseRetryAfterSeconds(
|
||||||
|
response.headers.get("x-retry-after") ?? response.headers.get("retry-after"),
|
||||||
|
);
|
||||||
|
return formatRateLimitMessage(retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = data.error ?? data.message;
|
||||||
|
if (message && isRateLimitError({ message, status: response.status })) {
|
||||||
|
return formatRateLimitMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return message ?? "Something went wrong";
|
||||||
|
}
|
||||||
|
|||||||
+50
-10
@@ -1,25 +1,65 @@
|
|||||||
import { httpBatchLink } from "@trpc/client";
|
import { httpBatchLink } from "@trpc/client";
|
||||||
import { createTRPCReact } from "@trpc/react-query";
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
import { createAppQueryClient } from "@/lib/query-client";
|
import { createAppQueryClient } from "@/lib/query-client";
|
||||||
import type { AppRouter } from "beenvoice/server/api/root";
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
export const api = createTRPCReact<AppRouter>();
|
||||||
|
|
||||||
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
|
export function TRPCProvider({
|
||||||
|
apiUrl,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
apiUrl: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
const authClient = useAuthClient();
|
const authClient = useAuthClient();
|
||||||
|
const { authStoragePrefix, activeAccountId, clearActiveAccount } =
|
||||||
|
useAccounts();
|
||||||
const { refetch } = useSession();
|
const { refetch } = useSession();
|
||||||
|
const authStoragePrefixRef = useRef(authStoragePrefix);
|
||||||
|
authStoragePrefixRef.current = authStoragePrefix;
|
||||||
|
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const resettingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleUnauthorized = useCallback(async () => {
|
const handleUnauthorized = useCallback(async () => {
|
||||||
|
if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
|
||||||
|
|
||||||
const session = await authClient.getSession();
|
const session = await authClient.getSession();
|
||||||
if (!session.data?.user) {
|
if (session.data?.user || !mountedRef.current) return;
|
||||||
await authClient.signOut();
|
|
||||||
await refetch();
|
resettingRef.current = true;
|
||||||
|
try {
|
||||||
|
await performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession: refetch,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
resettingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [authClient, refetch]);
|
}, [authClient, clearActiveAccount, activeAccountId, refetch]);
|
||||||
|
|
||||||
const onUnauthorizedRef = useRef(handleUnauthorized);
|
const onUnauthorizedRef = useRef(handleUnauthorized);
|
||||||
onUnauthorizedRef.current = handleUnauthorized;
|
onUnauthorizedRef.current = handleUnauthorized;
|
||||||
@@ -37,10 +77,10 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
|
|||||||
url: `${apiUrl}/api/trpc`,
|
url: `${apiUrl}/api/trpc`,
|
||||||
transformer: SuperJSON,
|
transformer: SuperJSON,
|
||||||
headers() {
|
headers() {
|
||||||
const cookie = (
|
return getAuthCookieHeaders(
|
||||||
authClient as { getCookie?: () => string | null | undefined }
|
authClient,
|
||||||
).getCookie?.();
|
authStoragePrefixRef.current,
|
||||||
return cookie ? { cookie } : {};
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,9 +23,11 @@
|
|||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-image": "^56.0.11",
|
"expo-image": "^56.0.11",
|
||||||
|
"expo-image-picker": "~56.0.18",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-local-authentication": "~56.0.4",
|
"expo-local-authentication": "~56.0.4",
|
||||||
|
"expo-mlkit-ocr": "^0.2.7",
|
||||||
"expo-network": "^56.0.5",
|
"expo-network": "^56.0.5",
|
||||||
"expo-notifications": "^56.0.18",
|
"expo-notifications": "^56.0.18",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// @ts-check
|
||||||
|
const { createRunOncePlugin, withDangerousMod } = require("@expo/config-plugins");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const pkg = require("expo-mlkit-ocr/package.json");
|
||||||
|
|
||||||
|
const DISABLE_LINE = "ENV['EXPO_MLKIT_OCR_DISABLE_MLKIT'] = '1'";
|
||||||
|
const MARKER = "expo-mlkit-ocr: iOS Simulator MLKit handling";
|
||||||
|
|
||||||
|
function resolveIosEngine(props = {}) {
|
||||||
|
const raw = props.iosEngine ?? "auto";
|
||||||
|
return raw === "auto" || raw === "mlkit" || raw === "vision" ? raw : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMarkedBlock(podfile) {
|
||||||
|
const lines = podfile.split(/\r?\n/);
|
||||||
|
const next = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.includes(MARKER)) {
|
||||||
|
next.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerIndent = (line.match(/^\s*/) ?? [""])[0].length;
|
||||||
|
while (i + 1 < lines.length) {
|
||||||
|
i += 1;
|
||||||
|
const candidate = lines[i];
|
||||||
|
const candidateIndent = (candidate.match(/^\s*/) ?? [""])[0].length;
|
||||||
|
if (candidate.trim() === "end" && candidateIndent <= markerIndent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripOrphanedMlkitArchPatch(podfile) {
|
||||||
|
return podfile
|
||||||
|
.replace(
|
||||||
|
/\n\s*end\s*\n\s*\n\s*installer\.aggregate_targets\.each do \|aggregate_target\|[\s\S]*?aggregate_target\.user_project\.save\s*\n\s*end\s*\n/g,
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\n\s*if ENV\['EXPO_MLKIT_OCR_DISABLE_MLKIT'\] == '1'\s*\n\s*installer\.pods_project\.targets\.each do \|target\|[\s\S]*?\n\s*end\s*\n/g,
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDisableLine(podfile) {
|
||||||
|
const lines = podfile
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.trim() !== DISABLE_LINE);
|
||||||
|
|
||||||
|
let insertAt = 0;
|
||||||
|
while (insertAt < lines.length && lines[insertAt].trim().startsWith("#")) {
|
||||||
|
insertAt += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.splice(insertAt, 0, DISABLE_LINE);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('@expo/config-plugins').ConfigPlugin<{ iosEngine?: "auto" | "mlkit" | "vision", disableMlkitOnSimulator?: boolean }>} */
|
||||||
|
function withExpoMlkitOcrEnv(config, props = {}) {
|
||||||
|
return withDangerousMod(config, [
|
||||||
|
"ios",
|
||||||
|
async (config) => {
|
||||||
|
const podfilePath = path.join(config.modRequest.platformProjectRoot, "Podfile");
|
||||||
|
if (!fs.existsSync(podfilePath)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iosEngine = resolveIosEngine(props);
|
||||||
|
const shouldDisableMlkit =
|
||||||
|
iosEngine !== "mlkit" || props.disableMlkitOnSimulator === true;
|
||||||
|
|
||||||
|
let podfile = fs.readFileSync(podfilePath, "utf8");
|
||||||
|
podfile = stripMarkedBlock(podfile);
|
||||||
|
podfile = stripOrphanedMlkitArchPatch(podfile);
|
||||||
|
|
||||||
|
if (shouldDisableMlkit) {
|
||||||
|
podfile = insertDisableLine(podfile);
|
||||||
|
} else {
|
||||||
|
podfile = podfile
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.trim() !== DISABLE_LINE)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(podfilePath, podfile.endsWith("\n") ? podfile : `${podfile}\n`);
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createRunOncePlugin(
|
||||||
|
withExpoMlkitOcrEnv,
|
||||||
|
"beenvoice-expo-mlkit-ocr-env",
|
||||||
|
pkg.version,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { HStack, Image, Spacer, Text } from "@expo/ui/swift-ui";
|
import { HStack, Image, Text, VStack } from "@expo/ui/swift-ui";
|
||||||
import {
|
import {
|
||||||
|
frame,
|
||||||
font,
|
font,
|
||||||
foregroundStyle,
|
foregroundStyle,
|
||||||
|
layoutPriority,
|
||||||
lineLimit,
|
lineLimit,
|
||||||
minimumScaleFactor,
|
minimumScaleFactor,
|
||||||
monospacedDigit,
|
monospacedDigit,
|
||||||
@@ -12,120 +14,100 @@ import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
|
|||||||
|
|
||||||
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
|
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
|
||||||
|
|
||||||
const TIMER_HORIZON_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function liveTimer(
|
|
||||||
startedAtMs: number,
|
|
||||||
modifiers: ReturnType<typeof font>[],
|
|
||||||
) {
|
|
||||||
const lower = new Date(startedAtMs);
|
|
||||||
const upper = new Date(startedAtMs + TIMER_HORIZON_MS);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
timerInterval={{ lower, upper }}
|
|
||||||
countsDown={false}
|
|
||||||
modifiers={modifiers}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
|
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
|
||||||
"widget";
|
"widget";
|
||||||
|
|
||||||
const green = "green";
|
const island = "#FFFFFF";
|
||||||
const title = props.description.trim() || "Clock In";
|
const businessLabel = props.businessName.trim();
|
||||||
const clientLabel = props.clientName.trim() || title;
|
const clientLabel = props.clientName.trim();
|
||||||
const subtitle = props.invoiceLabel.trim();
|
const title = clientLabel || businessLabel || "Clock In";
|
||||||
const startedAtMs = props.startedAtMs > 0 ? props.startedAtMs : Date.now();
|
|
||||||
|
|
||||||
const timerMods = [
|
const timerMods = [
|
||||||
font({ design: "monospaced", weight: "bold", size: 20 }),
|
font({ weight: "bold", size: 17 }),
|
||||||
monospacedDigit(),
|
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
||||||
foregroundStyle(green),
|
|
||||||
lineLimit(1),
|
lineLimit(1),
|
||||||
minimumScaleFactor(0.85),
|
minimumScaleFactor(0.75),
|
||||||
|
layoutPriority(2),
|
||||||
|
frame({ width: 100, alignment: "center" }),
|
||||||
];
|
];
|
||||||
const compactTimerMods = [
|
const compactTimerMods = [
|
||||||
font({ design: "monospaced", weight: "semibold", size: 11 }),
|
font({ design: "monospaced", weight: "semibold", size: 11 }),
|
||||||
monospacedDigit(),
|
monospacedDigit(),
|
||||||
foregroundStyle(green),
|
foregroundStyle(island),
|
||||||
lineLimit(1),
|
lineLimit(1),
|
||||||
minimumScaleFactor(0.8),
|
minimumScaleFactor(0.75),
|
||||||
|
frame({ minWidth: 38, alignment: "center" }),
|
||||||
|
layoutPriority(2),
|
||||||
];
|
];
|
||||||
const clientMods = [
|
const clientMods = [
|
||||||
font({ weight: "semibold", size: 13 }),
|
font({ weight: "bold", size: 17 }),
|
||||||
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
||||||
lineLimit(1),
|
lineLimit(1),
|
||||||
minimumScaleFactor(0.85),
|
minimumScaleFactor(0.75),
|
||||||
];
|
];
|
||||||
const subtitleMods = [
|
// Avoid greedy layout primitives here: the live timerInterval Text can
|
||||||
font({ size: 11 }),
|
// collapse when paired with Spacer/maxWidth. Fixed centered boxes keep the
|
||||||
foregroundStyle({ type: "hierarchical", style: "secondary" }),
|
// content stable without forcing left/right edge alignment.
|
||||||
lineLimit(1),
|
const titleRowMods = [
|
||||||
minimumScaleFactor(0.85),
|
layoutPriority(1),
|
||||||
|
frame({ width: 140, alignment: "center" }),
|
||||||
|
];
|
||||||
|
const sideZoneMods = [
|
||||||
|
frame({ width: 100, alignment: "center" }),
|
||||||
|
];
|
||||||
|
const bannerRowMods = [
|
||||||
|
padding({ horizontal: 10, vertical: 10 }),
|
||||||
|
frame({ maxWidth: Infinity, alignment: "center" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const bannerTimer = liveTimer(startedAtMs, timerMods);
|
const bannerTimer = <Text modifiers={timerMods}>{props.elapsedShort}</Text>;
|
||||||
const compactTimer = liveTimer(startedAtMs, compactTimerMods);
|
const compactTimer = <Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>;
|
||||||
|
const logoLarge = (
|
||||||
return {
|
<Image
|
||||||
banner: (
|
assetName="SplashScreenLogo"
|
||||||
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 14, vertical: 12 })]}>
|
systemName="dollarsign.circle.fill"
|
||||||
|
size={22}
|
||||||
|
modifiers={[
|
||||||
|
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
||||||
|
frame({ width: 22, height: 22 }),
|
||||||
|
widgetAccentedRenderingMode("fullColor"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const logoSmall = (
|
||||||
<Image
|
<Image
|
||||||
systemName="dollarsign.circle.fill"
|
systemName="dollarsign.circle.fill"
|
||||||
color={green}
|
color={island}
|
||||||
size={22}
|
size={14}
|
||||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
modifiers={[frame({ width: 14, height: 14 }), widgetAccentedRenderingMode("fullColor")]}
|
||||||
/>
|
/>
|
||||||
<Text modifiers={clientMods}>{clientLabel}</Text>
|
);
|
||||||
<Spacer minLength={12} />
|
return {
|
||||||
|
banner: (
|
||||||
|
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
|
||||||
|
<VStack alignment="center" modifiers={sideZoneMods}>
|
||||||
|
{logoLarge}
|
||||||
|
</VStack>
|
||||||
|
<VStack alignment="center" spacing={1} modifiers={titleRowMods}>
|
||||||
|
<Text modifiers={clientMods}>{title}</Text>
|
||||||
|
</VStack>
|
||||||
{bannerTimer}
|
{bannerTimer}
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
bannerSmall: (
|
bannerSmall: (
|
||||||
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 12, vertical: 10 })]}>
|
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
|
||||||
<Image
|
<VStack alignment="center" modifiers={sideZoneMods}>
|
||||||
systemName="dollarsign.circle.fill"
|
{logoLarge}
|
||||||
color={green}
|
</VStack>
|
||||||
size={18}
|
<VStack alignment="center" spacing={1} modifiers={titleRowMods}>
|
||||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
<Text modifiers={clientMods}>{title}</Text>
|
||||||
/>
|
</VStack>
|
||||||
<Text modifiers={clientMods}>{clientLabel}</Text>
|
{bannerTimer}
|
||||||
<Spacer minLength={8} />
|
|
||||||
{compactTimer}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
compactLeading: (
|
compactLeading: logoSmall,
|
||||||
<Image
|
|
||||||
systemName="dollarsign.circle.fill"
|
|
||||||
color={green}
|
|
||||||
size={15}
|
|
||||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
compactTrailing: compactTimer,
|
compactTrailing: compactTimer,
|
||||||
minimal: (
|
minimal: logoSmall,
|
||||||
<Image
|
|
||||||
systemName="dollarsign.circle.fill"
|
|
||||||
color={green}
|
|
||||||
size={12}
|
|
||||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
expandedLeading: (
|
|
||||||
<Image
|
|
||||||
systemName="dollarsign.circle.fill"
|
|
||||||
color={green}
|
|
||||||
size={20}
|
|
||||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
expandedCenter: <Text modifiers={clientMods}>{clientLabel}</Text>,
|
|
||||||
expandedTrailing: bannerTimer,
|
|
||||||
expandedBottom: (
|
|
||||||
<Text modifiers={subtitleMods}>{subtitle || "beenvoice"}</Text>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user