Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import { Platform } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { OnboardingGate } from "@/components/OnboardingGate";
import { ShortcutHandler } from "@/components/ShortcutHandler";
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext";
export default function AppLayout() {
const { colors, isDark } = useAppTheme();
const tintColor = colors.primary;
const labelColor = colors.mutedForeground;
const tabContentStyle = { backgroundColor: colors.background };
const tabBarBlur =
Platform.OS === "ios"
? isDark
? "systemChromeMaterialDark"
: "systemChromeMaterialLight"
: undefined;
return (
<AppLockProvider>
<NativeTabs
tintColor={tintColor}
iconColor={{
default: labelColor,
selected: tintColor,
}}
labelStyle={{ color: labelColor }}
blurEffect={tabBarBlur}
disableTransparentOnScrollEdge
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
>
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "house", selected: "house.fill" }}
md="home"
/>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "timer", selected: "timer" }}
md="timer"
/>
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
md="corporate_fare"
/>
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "doc.text", selected: "doc.text.fill" }}
md="description"
/>
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="more" role="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
md="more_horiz"
/>
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
<OnboardingGate />
<InvoiceReminderSync />
<TimeClockLiveActivitySync />
<ShortcutHandler />
<AppLockOverlay />
</AppLockProvider>
);
}
@@ -0,0 +1,76 @@
import { Stack } from "expo-router";
import { fonts } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export default function EntitiesLayout() {
const { colors } = useAppTheme();
return (
<Stack
screenOptions={{
contentStyle: { backgroundColor: "transparent" },
headerStyle: { backgroundColor: colors.cardGlass },
headerTitleStyle: {
fontFamily: fonts.heading,
fontSize: 18,
color: colors.foreground,
},
headerShadowVisible: false,
headerTintColor: colors.foreground,
}}
>
<Stack.Screen
name="index"
options={{
title: "Entities",
headerShown: false,
statusBarTranslucent: true,
contentStyle: { flex: 1, backgroundColor: "transparent" },
}}
/>
<Stack.Screen
name="clients/new"
options={{
title: "New client",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="clients/[id]"
options={{
title: "Client",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="clients/edit/[id]"
options={{
title: "Edit client",
headerBackTitle: "Client",
}}
/>
<Stack.Screen
name="businesses/new"
options={{
title: "New business",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="businesses/[id]"
options={{
title: "Business",
headerBackTitle: "Entities",
}}
/>
<Stack.Screen
name="businesses/edit/[id]"
options={{
title: "Edit business",
headerBackTitle: "Business",
}}
/>
</Stack>
);
}
@@ -0,0 +1,186 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function BusinessDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createBusinessDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
const utils = api.useUtils();
const businessQuery = api.businesses.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const setDefault = api.businesses.setDefault.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
if (id) void utils.businesses.getById.invalidate({ id });
Alert.alert("Default updated", "This business is now your default.");
},
onError: (err) => Alert.alert("Could not set default", err.message),
});
if (!id) {
return <LoadingScreen message="Invalid business" />;
}
if (businessQuery.isLoading) {
return <LoadingScreen message="Loading business…" />;
}
const business = businessQuery.data;
if (!business) {
return <LoadingScreen message="Business not found" />;
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior="automatic"
scrollIndicatorInsets={{ bottom: scrollPadding }}
>
<View style={styles.hero}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? <Text style={styles.badge}>Default</Text> : null}
</View>
{business.nickname ? <Text style={styles.meta}>{business.nickname}</Text> : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
{business.phone ? <Text style={styles.meta}>{business.phone}</Text> : null}
{business.website ? <Text style={styles.meta}>{business.website}</Text> : null}
</View>
<Card title="Details">
{business.taxId ? (
<DetailRow label="Tax ID" value={business.taxId} />
) : null}
<DetailRow
label="Email sending"
value={business.resendDomain ? "Configured" : "Not configured"}
/>
</Card>
{(business.addressLine1 || business.city || business.state) && (
<Card title="Address">
{business.addressLine1 ? (
<Text style={styles.body}>{business.addressLine1}</Text>
) : null}
{business.addressLine2 ? (
<Text style={styles.body}>{business.addressLine2}</Text>
) : null}
{(business.city || business.state || business.postalCode) && (
<Text style={styles.body}>
{[business.city, business.state, business.postalCode].filter(Boolean).join(", ")}
</Text>
)}
{business.country ? <Text style={styles.body}>{business.country}</Text> : null}
</Card>
)}
<View style={styles.actions}>
<Button
title="Edit business"
onPress={() => router.push(`/(app)/entities/businesses/edit/${business.id}`)}
/>
{!business.isDefault ? (
<Button
title="Set as default"
variant="secondary"
loading={setDefault.isPending}
onPress={() => setDefault.mutate({ id: business.id })}
/>
) : null}
</View>
</ScrollView>
</AppBackground>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 4,
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
const createBusinessDetailStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
nameRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
name: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
badge: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.primary,
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
overflow: "hidden",
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
body: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.foreground,
lineHeight: 20,
},
actions: {
gap: spacing.sm,
},
});
@@ -0,0 +1,32 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { BusinessForm } from "@/components/businesses/BusinessForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function EditBusinessScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Business" }} />
<BusinessForm
mode="edit"
businessId={id}
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Saved", "Business updated", [
{ text: "OK", onPress: () => router.back() },
]);
}}
onDeleted={() => {
Alert.alert("Deleted", "Business removed", [
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
]);
}}
/>
</AppBackground>
);
}
@@ -0,0 +1,25 @@
import { router, Stack } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { BusinessForm } from "@/components/businesses/BusinessForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function NewBusinessScreen() {
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<BusinessForm
mode="create"
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Business created", "Your business has been saved.", [
{ text: "OK", onPress: () => router.back() },
]);
}}
/>
</AppBackground>
);
}
@@ -0,0 +1,218 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function ClientDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createClientDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
const clientQuery = api.clients.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
if (!id) {
return <LoadingScreen message="Invalid client" />;
}
if (clientQuery.isLoading) {
return <LoadingScreen message="Loading client…" />;
}
const client = clientQuery.data;
if (!client) {
return <LoadingScreen message="Client not found" />;
}
const invoices = client.invoices ?? [];
const totalInvoiced = invoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
const currency = client.currency ?? "USD";
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior="automatic"
scrollIndicatorInsets={{ bottom: scrollPadding }}
>
<View style={styles.hero}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? <Text style={styles.meta}>{client.email}</Text> : null}
{client.phone ? <Text style={styles.meta}>{client.phone}</Text> : null}
</View>
<Card title="Summary">
<DetailRow label="Total invoiced" value={formatCurrency(totalInvoiced, currency)} />
<DetailRow label="Invoices" value={String(invoices.length)} />
{client.defaultHourlyRate != null ? (
<DetailRow
label="Default rate"
value={`${formatCurrency(client.defaultHourlyRate, currency)}/hr`}
/>
) : null}
</Card>
{(client.addressLine1 || client.city || client.state) && (
<Card title="Address">
{client.addressLine1 ? <Text style={styles.body}>{client.addressLine1}</Text> : null}
{client.addressLine2 ? <Text style={styles.body}>{client.addressLine2}</Text> : null}
{(client.city || client.state || client.postalCode) && (
<Text style={styles.body}>
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
</Text>
)}
{client.country ? <Text style={styles.body}>{client.country}</Text> : null}
</Card>
)}
<Card title="Invoices">
{invoices.length === 0 ? (
<Text style={styles.muted}>No invoices for this client yet.</Text>
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
style={styles.invoiceRow}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
>
<View style={styles.invoiceMeta}>
<Text style={styles.invoiceTitle}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.muted}>Due {formatDate(invoice.dueDate)}</Text>
</View>
<View style={styles.invoiceRight}>
<Text style={styles.invoiceAmount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<StatusBadge status={status} />
</View>
</Pressable>
);
})
)}
</Card>
<View style={styles.actions}>
<Button
title="Edit client"
onPress={() => router.push(`/(app)/entities/clients/edit/${client.id}`)}
/>
<Button
title="New invoice"
variant="secondary"
onPress={() => router.push("/(app)/invoices/new")}
/>
</View>
</ScrollView>
</AppBackground>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 4,
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
const createClientDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
name: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
body: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.foreground,
lineHeight: 20,
},
muted: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
},
invoiceRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
invoiceMeta: {
flex: 1,
gap: 2,
},
invoiceTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
invoiceRight: {
alignItems: "flex-end",
gap: spacing.sm,
},
invoiceAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
actions: {
gap: spacing.sm,
},
});
@@ -0,0 +1,32 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { ClientForm } from "@/components/clients/ClientForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function EditClientScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Client" }} />
<ClientForm
mode="edit"
clientId={id}
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Saved", "Client updated", [
{ text: "OK", onPress: () => router.back() },
]);
}}
onDeleted={() => {
Alert.alert("Deleted", "Client removed", [
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
]);
}}
/>
</AppBackground>
);
}
@@ -0,0 +1,25 @@
import { router, Stack } from "expo-router";
import { Alert } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { ClientForm } from "@/components/clients/ClientForm";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
export default function NewClientScreen() {
const scrollPadding = useTabBarScrollPadding();
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
<ClientForm
mode="create"
scrollPadding={scrollPadding}
onSaved={() => {
Alert.alert("Client created", "Your client has been saved.", [
{ text: "OK", onPress: () => router.back() },
]);
}}
/>
</AppBackground>
);
}
+314
View File
@@ -0,0 +1,314 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type EntityTab = "clients" | "businesses";
const tabs: Array<{ label: string; value: EntityTab }> = [
{ label: "Clients", value: "clients" },
{ label: "Businesses", value: "businesses" },
];
export default function EntitiesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createEntitiesStyles);
const [tab, setTab] = useState<EntityTab>("clients");
const clientsQuery = api.clients.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 isLoading =
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
if (isLoading) {
return <LoadingScreen message="Loading…" />;
}
if (activeQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load {tab}</Text>
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
</View>
</TabPage>
</AppBackground>
);
}
const clients = clientsQuery.data ?? [];
const businesses = businessesQuery.data ?? [];
function refresh() {
if (tab === "clients") void clientsQuery.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 (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader
title="Entities"
subtitle="Clients you bill and businesses you send from"
/>
}
refreshControl={
<RefreshControl
refreshing={activeQuery.isRefetching}
onRefresh={refresh}
tintColor={colors.primary}
/>
}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabs}
>
{tabs.map((item) => (
<FilterChip
key={item.value}
label={item.label}
active={tab === item.value}
onPress={() => setTab(item.value)}
/>
))}
</ScrollView>
{tab === "clients" ? (
clients.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No clients yet</Text>
<Text style={styles.emptyText}>
Add a client to start creating invoices.
</Text>
</View>
) : (
clients.map((client) => (
<SwipeableRow
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}`)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</SwipeableRow>
))
)
) : businesses.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No businesses yet</Text>
<Text style={styles.emptyText}>
Add your business profile for invoices and email sending.
</Text>
</View>
) : (
businesses.map((business) => (
<SwipeableRow
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}`)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</SwipeableRow>
))
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
onPress={() =>
router.push(
tab === "clients"
? "/(app)/entities/clients/new"
: "/(app)/entities/businesses/new",
)
}
/>
</TabPage>
</AppBackground>
);
}
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
tabScroll: {
flexGrow: 0,
marginBottom: spacing.sm,
},
tabs: {
gap: spacing.sm,
paddingRight: spacing.md,
},
card: {},
cardInner: {
padding: spacing.md,
gap: 4,
},
nameRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
name: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
badge: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.primary,
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
overflow: "hidden",
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
empty: {
padding: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
emptyTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
emptyText: {
textAlign: "center",
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+612
View File
@@ -0,0 +1,612 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { Screen } from "@/components/Screen";
import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, radii, spacing } from "@/constants/theme";
import { useSession } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import type { ThemeColors } from "@/lib/theme-palette";
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type ActionItem = {
key: string;
title: string;
detail: string;
icon: keyof typeof Ionicons.glyphMap;
tone: "warning" | "primary" | "success";
onPress: () => void;
};
export default function DashboardScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createDashboardStyles);
const { data: session } = useSession();
const statsQuery = api.dashboard.getStats.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
if (statsQuery.isLoading) {
return <LoadingScreen message="Loading home…" />;
}
if (statsQuery.error && !statsQuery.data) {
return (
<AppBackground>
<Screen>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load home</Text>
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
</View>
</Screen>
</AppBackground>
);
}
const stats = statsQuery.data;
if (!stats) {
return <LoadingScreen message="Loading home…" />;
}
const now = new Date();
const running = runningQuery.data;
const runningClient = running?.client?.name ?? "No client";
const monthInvoices = stats.monthInvoices ?? [];
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
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 (
<AppBackground>
<TabPage>
<TabScrollView
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void runningQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
<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 ? (
<Pressable onPress={() => router.push("/(app)/timer")}>
<GlassSurface style={styles.runningGlass}>
<View style={styles.runningRow}>
<View style={styles.runningDot} />
<View style={styles.runningMeta}>
<Text style={styles.runningTitle}>
{resolveClockDescription(running.description)}
</Text>
<Text style={styles.runningSub}>
{runningClient}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
</Text>
</View>
<Text style={styles.runningTime}>
{formatElapsedHoursMinutes(runningElapsed)}
</Text>
</View>
</GlassSurface>
</Pressable>
) : null}
<Card
title={now.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
>
<View style={styles.monthSummary}>
<View>
<Text style={styles.monthValue}>{formatCurrency(monthTotal)}</Text>
<Text style={styles.monthLabel}>
{monthInvoices.length} {monthInvoices.length === 1 ? "invoice" : "invoices"} this month
</Text>
</View>
<Button
title="New invoice"
variant="secondary"
onPress={() => router.push("/(app)/invoices/new")}
/>
</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.statCell}>
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
</View>
<View style={styles.statCell}>
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
</View>
<View style={styles.statCell}>
<StatCard label="Overdue" value={String(stats.overdueCount)} />
</View>
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
<StatCard label="Clients" value={String(stats.totalClients)} />
</Pressable>
</View>
<Card title="Revenue trend">
<View style={styles.chart}>
{stats.revenueChartData.map((point) => {
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
return (
<View key={point.month} style={styles.chartColumn}>
<View style={styles.chartBarTrack}>
<View style={[styles.chartBar, { height: barHeight }]} />
</View>
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
</View>
);
})}
</View>
</Card>
<Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
) : (
stats.recentInvoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
>
<View style={styles.invoiceMeta}>
<Text style={styles.invoiceTitle}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
</View>
<View style={styles.invoiceRight}>
<Text style={styles.invoiceAmount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
<StatusBadge status={status} />
</View>
</Pressable>
);
})
)}
</Card>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
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,
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: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
},
runningRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.md,
},
runningDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.success,
},
runningMeta: {
flex: 1,
gap: 2,
},
runningTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
runningSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
runningTime: {
fontFamily: fonts.mono,
fontSize: 18,
color: colors.success,
},
monthSummary: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
},
monthValue: {
color: colors.foreground,
fontFamily: fonts.heading,
fontSize: 24,
lineHeight: 30,
},
monthLabel: {
color: colors.mutedForeground,
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: {
flexDirection: "row",
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: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.md,
alignContent: "flex-start",
},
statCell: {
flexGrow: 0,
flexShrink: 0,
flexBasis: "47%",
},
chart: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.xs,
},
chartColumn: {
flex: 1,
alignItems: "center",
gap: 4,
},
chartBarTrack: {
width: "100%",
height: 80,
justifyContent: "flex-end",
alignItems: "center",
},
chartBar: {
width: "70%",
minHeight: 4,
backgroundColor: colors.primary,
borderRadius: radii.sm,
},
chartLabel: {
fontSize: 10,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
empty: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
recentRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
invoiceMeta: {
flex: 1,
gap: 2,
},
invoiceTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
invoiceClient: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
},
invoiceDate: {
color: colors.mutedForeground,
fontSize: 12,
fontFamily: fonts.body,
},
invoiceRight: {
alignItems: "flex-end",
gap: spacing.sm,
},
invoiceAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 15,
},
pressed: {
opacity: 0.85,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+408
View File
@@ -0,0 +1,408 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() {
const styles = useThemedStyles(createInvoiceDetailStyles);
const { colors } = useAppTheme();
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceViewSection>("details");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Update failed", err.message),
});
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
onSuccess: () => {
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
void utils.invoices.getById.invalidate({ id: id ?? "" });
},
onError: (err) => Alert.alert("Could not send reminder", err.message),
});
const previewInput = useMemo(
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
[invoiceQuery.data],
);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (invoiceQuery.error || !invoiceQuery.data) {
return (
<AppBackground>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load invoice</Text>
<Text style={styles.errorText}>
{invoiceQuery.error?.message ?? "Invoice not found"}
</Text>
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
</View>
</AppBackground>
);
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const clientEmail = invoice.client?.email?.trim() ?? "";
function openSendScreen() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client before sending invoices.",
);
return;
}
if (invoice.items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
router.push(`/(app)/invoices/send/${invoice.id}`);
}
function promptPaymentReminder() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client before sending payment reminders.",
);
return;
}
Alert.alert(
"Send payment reminder",
`Email a payment reminder to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
},
],
);
}
function promptStatusChange(current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
if (current !== "sent" && current !== "overdue") {
options.push({ label: "Mark as sent", status: "sent" });
}
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
if (options.length === 0) return;
Alert.alert("Update status", "Choose a new status", [
...options.map((option) => ({
text: option.label,
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
})),
{ text: "Cancel", style: "cancel" },
]);
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card>
<View style={styles.headerRow}>
<View style={styles.headerMeta}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
</View>
<StatusBadge status={status} />
</View>
<Text style={styles.total}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
</Card>
<InvoiceViewChips
section={section}
onSectionChange={setSection}
status={status}
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
onSend={openSendScreen}
/>
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card title="Details">
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} />
{invoice.taxRate > 0 ? (
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
) : null}
{invoice.status === "draft" && invoice.sendReminderAt ? (
<DetailRow
label="Send reminder"
value={
new Date(invoice.sendReminderAt) <= new Date()
? "Due now"
: formatDate(invoice.sendReminderAt)
}
/>
) : null}
</Card>
<Card title="Line items">
{invoice.items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer tab, or edit to
add lines manually.
</Text>
) : (
invoice.items.map((item) => {
const line = (
<View style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
);
if (invoice.status !== "draft") {
return <View key={item.id}>{line}</View>;
}
return (
<SwipeableRow
key={item.id}
backgroundColor={colors.card}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
]}
>
{line}
</SwipeableRow>
);
})
)}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
taxAmount={
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
}
total={formatCurrency(invoice.totalAmount, invoice.currency)}
/>
</Card>
{invoice.notes ? (
<Card title="Notes">
<Text style={styles.notes}>{invoice.notes}</Text>
</Card>
) : null}
<InvoiceDetailActions
status={status}
clientEmail={clientEmail}
onPaymentReminder={
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
}
paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending}
onTrackTime={() =>
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
}
/>
</>
)}
</ScrollView>
</AppBackground>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: 4,
},
label: {
fontSize: 14,
fontFamily: fonts.body,
},
value: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
});
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
container: {
padding: spacing.md,
gap: spacing.md,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
gap: spacing.md,
},
headerMeta: {
flex: 1,
gap: 4,
},
invoiceNumber: {
fontSize: 22,
lineHeight: 26,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 15,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
total: {
marginTop: spacing.sm,
fontSize: 28,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
lineItem: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
lineMeta: {
flex: 1,
gap: 2,
},
lineDescription: {
fontFamily: fonts.bodyMedium,
color: colors.foreground,
fontSize: 14,
},
lineSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
lineAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.md,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
@@ -0,0 +1,62 @@
import { Stack } from "expo-router";
import { fonts } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export default function InvoicesLayout() {
const { colors } = useAppTheme();
return (
<Stack
screenOptions={{
contentStyle: { backgroundColor: "transparent" },
headerStyle: { backgroundColor: colors.cardGlass },
headerTitleStyle: {
fontFamily: fonts.heading,
fontSize: 18,
color: colors.foreground,
},
headerShadowVisible: false,
headerTintColor: colors.foreground,
}}
>
<Stack.Screen
name="index"
options={{
title: "Invoices",
headerShown: false,
statusBarTranslucent: true,
contentStyle: { flex: 1, backgroundColor: "transparent" },
}}
/>
<Stack.Screen
name="new"
options={{
title: "New invoice",
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="[id]"
options={{
title: "Invoice",
headerBackTitle: "Invoices",
}}
/>
<Stack.Screen
name="send/[id]"
options={{
title: "Send invoice",
headerBackTitle: "Invoice",
}}
/>
<Stack.Screen
name="edit/[id]"
options={{
title: "Edit invoice",
headerBackTitle: "Invoice",
}}
/>
</Stack>
);
}
@@ -0,0 +1,453 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { isValidTaxRate, validateLineItems } from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function InvoiceEditScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceEditStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState("");
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date());
const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const invoice = invoiceQuery.data;
if (!invoice) return;
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
setClientId(invoice.clientId);
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems(
invoice.items.map((item) => ({
id: item.id,
date: new Date(item.date),
description: item.description,
hours: String(item.hours),
rate: String(item.rate),
})),
);
}, [invoiceQuery.data]);
useEffect(() => {
if (businessId || !businessesQuery.data?.length) return;
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
}, [businessId, businessesQuery.data]);
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.invoices.getAll.invalidate({ status: "draft" });
void utils.dashboard.getStats.invalidate();
Alert.alert("Saved", "Invoice updated", [
{ text: "OK", onPress: () => router.back() },
]);
},
onError: (err) => setError(err.message),
});
const invoice = invoiceQuery.data;
const isDraft = invoice?.status === "draft";
const businessOptions = useMemo(
() =>
(businessesQuery.data ?? []).map((business) => ({
label: business.name,
value: business.id,
})),
[businessesQuery.data],
);
const clientOptions = useMemo(
() =>
(clientsQuery.data ?? []).map((client) => ({
label: client.name,
value: client.id,
})),
[clientsQuery.data],
);
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
return sum + hours * rate;
}, 0),
[items],
);
const parsedTaxRate = Number(taxRate) || 0;
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const lineItemsError = isDraft ? validateLineItems(items) : null;
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
const clientError = isDraft && !clientId ? "Select a client" : undefined;
const canSave = isDraft
? !lineItemsError && !taxError && !businessError && !clientError
: true;
const previewInput = useMemo(() => {
if (!invoice) return null;
return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix,
businessId: resolvedBusinessId,
clientId,
issueDate: new Date(invoice.issueDate),
dueDate,
status: invoice.status as "draft" | "sent" | "paid",
notes,
taxRate: parsedTaxRate,
currency,
items,
});
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
return <LoadingScreen message="Invoice not found" />;
}
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
}
function removeItem(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index));
}
function duplicateItem(index: number) {
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
const copy = { ...source, id: undefined };
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
});
}
async function handleSave() {
if (!canSave) return;
setError(null);
if (isDraft && sendReminderAt) {
const granted = await ensureNotificationPermissions();
if (!granted) {
Alert.alert(
"Notifications disabled",
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
);
}
}
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours: Number(item.hours),
rate: Number(item.rate),
});
}
updateInvoice.mutate({
id,
notes,
dueDate,
sendReminderAt,
...(isDraft
? {
businessId: resolvedBusinessId,
clientId,
taxRate: parsedTaxRate,
currency,
items: parsedItems,
}
: {}),
});
}
return (
<AppBackground>
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
</Text>
</View>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : section === "setup" ? (
<Card title="Invoice setup">
<InvoiceSetupForm
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
businessReadOnly={!isDraft}
clientId={clientId}
onClientIdChange={setClientId}
clientOptions={clientOptions}
clientError={clientError}
clientReadOnly={!isDraft}
invoiceNumber={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
invoiceNumberReadOnly
issueDate={new Date(invoice.issueDate)}
issueDateReadOnly
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={isDraft ? setTaxRate : undefined}
taxRateReadOnly={!isDraft}
notes={notes}
onNotesChange={setNotes}
sendReminderAt={sendReminderAt}
onSendReminderAtChange={isDraft ? setSendReminderAt : undefined}
showSendReminder={isDraft}
/>
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
</Card>
) : (
<>
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
</Text>
) : items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Add lines here or clock time to this invoice from the
Timer tab.
</Text>
) : null}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
readOnly={!isDraft}
/>
))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle="Save changes"
onPrimary={handleSave}
primaryLoading={updateInvoice.isPending}
primaryDisabled={!canSave}
secondary={
status !== "paid"
? {
title: status === "draft" ? "Send invoice" : "Resend invoice",
subtitle: clientEmail
? items.length === 0
? "Add line items before sending"
: `Review PDF and email to ${clientEmail}`
: "Add a client email first",
icon: "mail-outline",
onPress: () => {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client before sending invoices.",
);
return;
}
if (items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
router.push(`/(app)/invoices/send/${invoice.id}`);
},
disabled: !clientEmail || items.length === 0,
}
: undefined
}
/>
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
hero: {
gap: 4,
},
invoiceNumber: {
fontSize: 24,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
lockedHint: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
+340
View File
@@ -0,0 +1,340 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
{ label: "All", value: "all" },
{ label: "Draft", value: "draft" },
{ label: "Sent", value: "sent" },
{ label: "Paid", value: "paid" },
{ label: "Overdue", value: "overdue" },
];
export default function InvoicesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoicesStyles);
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
const utils = api.useUtils();
const invoicesQuery = api.invoices.getAll.useQuery();
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
utils.invoices.getAll.invalidate();
utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Update failed", err.message),
});
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
utils.invoices.getAll.invalidate();
utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Delete failed", err.message),
});
if (invoicesQuery.isLoading) {
return <LoadingScreen message="Loading invoices…" />;
}
if (invoicesQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load invoices</Text>
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
</View>
</TabPage>
</AppBackground>
);
}
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
if (filter === "all") return true;
return getInvoiceStatus(invoice) === filter;
});
function promptStatusChange(invoiceId: string, current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
if (current !== "sent" && current !== "overdue") {
options.push({ label: "Mark as sent", status: "sent" });
}
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
if (options.length === 0) return;
Alert.alert("Update status", "Choose a new status", [
...options.map((option) => ({
text: option.label,
onPress: () => {
updateStatus.mutate({ id: invoiceId, status: option.status });
},
})),
{ text: "Cancel", style: "cancel" },
]);
}
function confirmDelete(invoiceId: string, label: string) {
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
},
]);
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
}
refreshControl={
<RefreshControl
refreshing={invoicesQuery.isRefetching}
onRefresh={() => invoicesQuery.refetch()}
tintColor={colors.primary}
/>
}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filterScroll}
contentContainerStyle={styles.filters}
>
{filters.map((item) => (
<FilterChip
key={item.label}
label={item.label}
active={filter === item.value}
onPress={() => setFilter(item.value)}
/>
))}
</ScrollView>
{invoices.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No invoices found</Text>
<Text style={styles.emptyText}>
Tap + to create your first invoice, or pull to refresh.
</Text>
</View>
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
const actions = [
{
key: "open",
label: "Open",
icon: "open-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
},
...(status === "draft"
? [
{
key: "edit",
label: "Edit",
icon: "create-outline" as const,
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
{
key: "send",
label: "Send",
icon: "send-outline" as const,
color: "#fff",
backgroundColor: colors.success,
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => confirmDelete(invoice.id, label),
},
]
: [
{
key: "status",
label: "Status",
icon: "flag-outline" as const,
color: "#fff",
backgroundColor: colors.warning,
onPress: () => promptStatusChange(invoice.id, status),
},
]),
];
return (
<SwipeableRow
key={invoice.id}
actions={actions}
backgroundColor={colors.cardGlass}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
onLongPress={() => promptStatusChange(invoice.id, status)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<Text style={styles.invoiceNumber}>{label}</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View>
<Text style={styles.amount}>
{formatCurrency(invoice.totalAmount, invoice.currency)}
</Text>
</View>
<View style={styles.cardBottom}>
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
<StatusBadge status={status} />
</View>
</View>
</GlassSurface>
</SwipeableRow>
);
})
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel="Create invoice"
onPress={() => {
Alert.alert("Create invoice", "Choose how to start", [
{ text: "Cancel", style: "cancel" },
{
text: "With line items",
onPress: () => router.push("/(app)/invoices/new"),
},
{
text: "Blank (for timer)",
onPress: () => router.push("/(app)/invoices/new?blank=1"),
},
]);
}}
/>
</TabPage>
</AppBackground>
);
}
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
filterScroll: {
flexGrow: 0,
marginBottom: spacing.sm,
},
filters: {
gap: spacing.sm,
paddingRight: spacing.md,
},
card: {},
cardInner: {
padding: spacing.md,
gap: spacing.md,
},
cardTop: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
},
cardMeta: {
flex: 1,
gap: 4,
},
invoiceNumber: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
clientName: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
},
amount: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
cardBottom: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
date: {
color: colors.mutedForeground,
fontSize: 13,
fontFamily: fonts.body,
},
empty: {
padding: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
emptyTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
emptyText: {
textAlign: "center",
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+412
View File
@@ -0,0 +1,412 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { formatCurrency } from "@/lib/format";
import {
isRequiredString,
isValidTaxRate,
validateLineItems,
} from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function NewInvoiceScreen() {
const styles = useThemedStyles(createNewInvoiceStyles);
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const { blank } = useLocalSearchParams<{ blank?: string }>();
const isBlank = blank === "1" || blank === "true";
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date());
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>(() =>
isBlank
? []
: [
{
date: new Date(),
description: "",
hours: "1",
rate: "0",
},
],
);
const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (businessId || !businessesQuery.data?.length) return;
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
}, [businessId, businessesQuery.data]);
const businessOptions = useMemo(
() =>
(businessesQuery.data ?? []).map((business) => ({
label: business.name,
value: business.id,
})),
[businessesQuery.data],
);
const clientOptions = useMemo(
() =>
(clientsQuery.data ?? []).map((client) => ({
label: client.name,
value: client.id,
})),
[clientsQuery.data],
);
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
setItems((prev) =>
prev.map((item, index) =>
index === 0 && (item.rate === "0" || item.rate === "")
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
: item,
),
);
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
const createInvoice = api.invoices.create.useMutation({
onSuccess: (invoice) => {
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
Alert.alert("Invoice created", "Your draft invoice is ready.", [
{
text: "View invoice",
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
},
]);
},
onError: (err) => setError(err.message),
});
const subtotal = useMemo(
() =>
items.reduce((sum, item) => {
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
return sum + hours * rate;
}, 0),
[items],
);
const parsedTaxRate = Number(taxRate) || 0;
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const previewInput = useMemo(
() =>
buildPreviewPdfInput({
invoiceNumber,
businessId: resolvedBusinessId,
clientId,
issueDate,
dueDate,
taxRate: parsedTaxRate,
currency,
notes,
items,
}),
[
invoiceNumber,
resolvedBusinessId,
clientId,
issueDate,
dueDate,
parsedTaxRate,
currency,
notes,
items,
],
);
const businessError = resolvedBusinessId ? undefined : "Select a business";
const clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
: "Invoice number is required";
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items);
const canCreate =
businessOptions.length > 0 &&
clientOptions.length > 0 &&
!businessError &&
!clientError &&
!invoiceNumberError &&
!taxError &&
!lineItemsError;
if (businessesQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading…" />;
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
}
function removeItem(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index));
}
function duplicateItem(index: number) {
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
const copy = { ...source, id: undefined };
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
});
}
function handleCreate() {
if (!canCreate) return;
setError(null);
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours: Number(item.hours),
rate: Number(item.rate),
});
}
createInvoice.mutate({
businessId: resolvedBusinessId,
clientId,
invoiceNumber: invoiceNumber.trim(),
issueDate,
dueDate,
notes,
taxRate: Number(taxRate),
currency,
items: parsedItems,
status: "draft",
});
}
return (
<AppBackground>
<Stack.Screen
options={{
headerBackTitle: "Invoices",
title: isBlank ? "Blank invoice" : "New invoice",
}}
/>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : section === "setup" ? (
<Card title="Invoice setup">
{clientOptions.length === 0 || businessOptions.length === 0 ? (
<View style={styles.noEntities}>
<Text style={styles.noEntitiesText}>
{businessOptions.length === 0
? "Add a business before creating an invoice."
: "Add a client before creating an invoice."}
</Text>
<Button
title={businessOptions.length === 0 ? "Add business" : "Add client"}
variant="secondary"
onPress={() =>
router.push(
businessOptions.length === 0
? "/(app)/entities/businesses/new"
: "/(app)/entities/clients/new",
)
}
/>
</View>
) : (
<InvoiceSetupForm
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
clientId={clientId}
onClientIdChange={setClientId}
clientOptions={clientOptions}
clientError={clientError}
invoiceNumber={invoiceNumber}
onInvoiceNumberChange={setInvoiceNumber}
issueDate={issueDate}
onIssueDateChange={setIssueDate}
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={setTaxRate}
notes={notes}
onNotesChange={setNotes}
/>
)}
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
{invoiceNumberError ? (
<Text style={styles.error}>{invoiceNumberError}</Text>
) : null}
</Card>
) : (
<>
<Card title="Line items">
{isBlank && items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Save this draft and clock time to it from the Timer tab,
or add lines here.
</Text>
) : null}
{items.map((item, index) => (
<LineItemEditor
key={`new-${index}`}
index={index}
item={item}
currency={currency}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
onDuplicate={() => duplicateItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
onPrimary={handleCreate}
primaryLoading={createInvoice.isPending}
primaryDisabled={!canCreate}
/>
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
noEntities: {
gap: spacing.sm,
},
noEntitiesText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.mutedForeground,
lineHeight: 20,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
@@ -0,0 +1,238 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
export default function InvoiceSendScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createSendStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [customMessage, setCustomMessage] = useState("");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
Alert.alert("Invoice sent", data.message, [
{ text: "OK", onPress: () => router.replace(`/(app)/invoices/${id}`) },
]);
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const previewInput = useMemo(
() =>
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
[invoiceQuery.data],
);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoiceQuery.data) {
return <LoadingScreen message="Invoice not found" />;
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const clientEmail = invoice.client?.email?.trim() ?? "";
const businessName = invoice.business?.name ?? "Your business";
const sendLabel = status === "draft" ? "Send invoice" : "Resend invoice";
function handleSend() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client before sending invoices.",
);
return;
}
if (invoice.items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
sendInvoice.mutate({
invoiceId: invoice.id,
customMessage: customMessage.trim() || undefined,
});
}
return (
<AppBackground>
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Email summary">
<SummaryRow label="From" value={businessName} />
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
<SummaryRow
label="Invoice"
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
/>
<SummaryRow label="Due" value={formatDate(invoice.dueDate)} />
<SummaryRow
label="Amount"
value={formatCurrency(invoice.totalAmount, invoice.currency)}
bold
/>
</Card>
<Card title="PDF attachment">
<InvoicePdfPreview input={previewInput} height={480} />
</Card>
<Card title="Message">
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
Optional note included in the email body.
</Text>
<Input
label="Personal message"
value={customMessage}
onChangeText={setCustomMessage}
placeholder="Thanks for your business!"
multiline
style={styles.messageInput}
/>
</Card>
<Button
title={sendLabel}
onPress={handleSend}
loading={sendInvoice.isPending}
disabled={!clientEmail || invoice.items.length === 0}
/>
{!clientEmail ? (
<Text style={[styles.warning, { color: colors.destructive }]}>
Add a client email address before sending.
</Text>
) : invoice.items.length === 0 ? (
<Text style={[styles.warning, { color: colors.destructive }]}>
Add line items before sending this invoice.
</Text>
) : null}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
function SummaryRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={summaryStyles.row}>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text
style={[
summaryStyles.value,
{ color: colors.foreground },
bold && summaryStyles.bold,
]}
>
{value}
</Text>
</View>
);
}
const summaryStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: 4,
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
flex: 1,
textAlign: "right",
},
bold: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
const createSendStyles = (colors: ThemeColors) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
messageHint: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
marginBottom: spacing.xs,
},
messageInput: {
minHeight: 96,
textAlignVertical: "top",
},
warning: {
fontFamily: fonts.body,
fontSize: 13,
textAlign: "center",
},
});
+12
View File
@@ -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,
},
});
+276
View File
@@ -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}`;
}
+125
View File
@@ -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,
},
});
+122
View File
@@ -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,
},
});
+115
View File
@@ -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,
},
});
+700
View File
@@ -0,0 +1,700 @@
import { useState } from "react";
import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import {
Alert,
Platform,
Pressable,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground";
import { InstanceUrlField } from "@/components/InstanceUrlField";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PinPrompt } from "@/components/PinPrompt";
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import {
confirmRemoveAccount,
finishAccountRemoval,
} from "@/lib/account-actions";
import { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
export default function SettingsScreen() {
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
apiUrl,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const { colors, colorMode, setColorMode } = useAppTheme();
const switchProps = {
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
ios_backgroundColor: colors.switchIosBackground,
};
const {
enabled: lockEnabled,
biometricEnabled,
biometricAvailable,
biometricLabel,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
} = useAppLock();
const profileQuery = api.settings.getProfile.useQuery();
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
const [pinPrompt, setPinPrompt] = useState<
| { mode: "create" }
| { mode: "confirm-disable" }
| { mode: "change-current" }
| { mode: "change-next" }
| null
>(null);
const [pendingPin, setPendingPin] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [refreshingAccounts, setRefreshingAccounts] = useState(false);
async function handleRefreshAccounts() {
setRefreshingAccounts(true);
try {
await refreshAccounts();
await profileQuery.refetch();
} finally {
setRefreshingAccounts(false);
}
}
function handleRemoveAccount(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
});
},
);
}
async function handleSignOut() {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in");
}
function confirmSignOut() {
Alert.alert("Sign out", "Sign out of this account on this device?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign out",
style: "destructive",
onPress: () => void handleSignOut(),
},
]);
}
async function handleDeleteAccount() {
if (!activeAccountId) return;
try {
await deleteAccountMutation.mutateAsync({
confirmText: "DELETE MY ACCOUNT",
});
const result = await removeAccount(activeAccountId);
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
});
if (result.remainingCount > 0) {
router.replace("/(auth)/select-account");
}
} catch (error) {
Alert.alert(
"Could not delete account",
error instanceof Error ? error.message : "Please try again.",
);
}
}
function confirmDeleteAccount() {
Alert.alert(
"Permanently delete account?",
"This deletes your account, invoices, clients, businesses, expenses, time entries, uploaded files, and sign-in data. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete Account",
style: "destructive",
onPress: () => void handleDeleteAccount(),
},
],
);
}
function confirmInstanceChange() {
Alert.alert(
"Server updated",
"You may need to sign in again if you switched to a different instance.",
[{ text: "OK" }],
);
}
function handleLockToggle(next: boolean) {
if (next) {
setPinPrompt({ mode: "create" });
return;
}
setPinPrompt({ mode: "confirm-disable" });
}
function handleChangePin() {
setPendingPin("");
setPinPrompt({ mode: "change-current" });
}
function handleBiometricToggle(next: boolean) {
void setUseBiometric(next);
}
async function handlePinPromptSubmit(pin: string) {
if (pinPrompt?.mode === "create") {
try {
await enableLock(pin);
setPinPrompt(null);
} catch (err) {
Alert.alert(
"Could not enable lock",
err instanceof Error ? err.message : "Try again",
);
}
return;
}
if (pinPrompt?.mode === "confirm-disable") {
const success = await disableLock(pin);
if (!success) {
Alert.alert("Incorrect PIN", "Could not disable app lock.");
return;
}
setPinPrompt(null);
return;
}
if (pinPrompt?.mode === "change-current") {
setPendingPin(pin);
setPinPrompt({ mode: "change-next" });
return;
}
if (pinPrompt?.mode === "change-next") {
const success = await changePin(pendingPin, pin);
if (!success) {
Alert.alert(
"Could not change PIN",
"Check your current PIN and try again.",
);
return;
}
setPendingPin("");
setPinPrompt(null);
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
}
}
if (profileQuery.isLoading) {
return <LoadingScreen message="Loading profile…" />;
}
const profile = profileQuery.data;
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
return (
<AppBackground>
<TabPage showMoreBack>
<PinPrompt
visible={pinPrompt !== null}
title={
pinPrompt?.mode === "create"
? "Create PIN"
: pinPrompt?.mode === "confirm-disable"
? "Disable app lock"
: pinPrompt?.mode === "change-current"
? "Current PIN"
: "New PIN"
}
message={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
? "Choose a 46 digit PIN."
: pinPrompt?.mode === "confirm-disable"
? "Enter your PIN to turn off app lock."
: "Enter your current PIN."
}
confirmLabel={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
? "Save"
: "Continue"
}
requireConfirmation={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
}
onCancel={() => {
setPendingPin("");
setPinPrompt(null);
}}
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
/>
<TabScrollView
header={
<PageHeader
title="Settings"
subtitle="Account and app preferences"
/>
}
keyboardShouldPersistTaps="handled"
>
<Card title="Account">
<Text style={[styles.name, { color: colors.foreground }]}>
{profile?.name ?? session?.user.name ?? "User"}
</Text>
<Text style={[styles.email, { color: colors.mutedForeground }]}>
{profile?.email ?? session?.user.email}
</Text>
{profile?.role ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Role: {profile.role}
</Text>
) : null}
</Card>
<Card title="Accounts">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<View
key={account.id}
style={[
styles.accountRow,
{
borderColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
style={({ pressed }) => [
styles.accountMain,
pressed && styles.pressed,
]}
>
<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 },
]}
>
{account.instanceUrl.replace(/^https?:\/\//, "")}
</Text>
</View>
{isActive ? (
<Text
style={[styles.activeBadge, { color: colors.primary }]}
>
Active
</Text>
) : null}
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemoveAccount(
account.id,
account.name || account.email,
)
}
style={({ pressed }) => [
styles.removeButton,
pressed && styles.pressed,
]}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.destructive}
/>
</Pressable>
</View>
);
})}
<Button
title={refreshingAccounts ? "Refreshing…" : "Refresh accounts"}
variant="secondary"
disabled={refreshingAccounts}
onPress={() => void handleRefreshAccounts()}
/>
<Button
title="Add another account"
variant="secondary"
onPress={() =>
void startAdditionalAccountSignIn(clearActiveAccount)
}
/>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Tap an account to switch. Refresh updates names from saved sign-in
data.
</Text>
</Card>
{Platform.OS === "ios" ? (
<Card title="Shortcuts & Siri">
<ShortcutsSetupCard />
</Card>
) : null}
<Card title="Security">
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text
style={[styles.settingTitle, { color: colors.foreground }]}
>
App lock
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Require a PIN when reopening the app
</Text>
</View>
<Switch
value={lockEnabled}
onValueChange={handleLockToggle}
{...switchProps}
/>
</View>
{lockEnabled && biometricAvailable ? (
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text
style={[styles.settingTitle, { color: colors.foreground }]}
>
{biometricLabel}
</Text>
<Text
style={[styles.meta, { color: colors.mutedForeground }]}
>
Unlock with {biometricLabel.toLowerCase()} when available
</Text>
</View>
<Switch
value={biometricEnabled}
onValueChange={handleBiometricToggle}
{...switchProps}
/>
</View>
) : null}
{lockEnabled ? (
<>
<Button
title="Change PIN"
variant="secondary"
onPress={handleChangePin}
/>
<Button title="Lock now" variant="secondary" onPress={lock} />
</>
) : null}
</Card>
<Card title="Appearance">
<View style={styles.themeRow}>
{THEME_OPTIONS.map((option) => {
const selected = colorMode === option.value;
return (
<Pressable
key={option.value}
accessibilityRole="button"
onPress={() => void setColorMode(option.value)}
style={[
styles.themeChip,
{
borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected
? colors.muted
: "transparent",
},
]}
>
<Text
style={[
styles.themeChipLabel,
{
color: selected
? colors.foreground
: colors.mutedForeground,
},
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</Card>
<Card title="App">
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Version
</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{appVersion}
</Text>
</View>
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Platform
</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"}
</Text>
</View>
</Card>
<Card title="Delete account">
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Permanently delete this account and all of its data from the
server. This cannot be undone.
</Text>
<Button
title={
deleteAccountMutation.isPending
? "Deleting account…"
: "Delete Account"
}
variant="danger"
loading={deleteAccountMutation.isPending}
disabled={deleteAccountMutation.isPending}
onPress={confirmDeleteAccount}
/>
</Card>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }}
onPress={() => setShowAdvanced((open) => !open)}
style={styles.advancedToggle}
>
<Text
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
>
Advanced
</Text>
<Ionicons
name={showAdvanced ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{showAdvanced ? (
<Card title="Server instance">
<InstanceUrlField onSaved={confirmInstanceChange} />
<Text
style={[
styles.currentServer,
{ color: colors.mutedForeground },
]}
>
Connected to {activeAccount?.instanceUrl ?? apiUrl}
</Text>
</Card>
) : null}
<View style={styles.actions}>
<Button
title="Sign Out"
variant="danger"
onPress={confirmSignOut}
/>
</View>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
name: {
fontSize: 20,
fontFamily: fonts.heading,
},
email: {
fontSize: 15,
fontFamily: fonts.body,
},
meta: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
currentServer: {
fontSize: 12,
fontFamily: fonts.mono,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
advancedLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
appRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
appValue: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
accountRow: {
borderWidth: 1,
borderRadius: 12,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.md,
},
removeButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 36,
minHeight: 36,
},
pressed: {
opacity: 0.92,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountName: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
accountSub: {
fontSize: 12,
fontFamily: fonts.body,
},
activeBadge: {
fontSize: 12,
fontFamily: fonts.bodySemiBold,
},
themeRow: {
flexDirection: "row",
gap: spacing.sm,
},
themeChip: {
flex: 1,
borderWidth: 1,
borderRadius: 10,
minHeight: 40,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.sm,
},
themeChipLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
lineHeight: 18,
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
},
settingRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
},
settingCopy: {
flex: 1,
gap: 2,
},
settingTitle: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
actions: {
marginTop: spacing.sm,
},
});
+152
View File
@@ -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,
},
});
+139
View File
@@ -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,
},
});
+33
View File
@@ -0,0 +1,33 @@
import { useLocalSearchParams } from "expo-router";
import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
export default function TimerScreen() {
const params = useLocalSearchParams<{
clientId?: string | string[];
invoiceId?: string | string[];
}>();
const clientId = Array.isArray(params.clientId) ? params.clientId[0] : params.clientId;
const invoiceId = Array.isArray(params.invoiceId) ? params.invoiceId[0] : params.invoiceId;
return (
<AppBackground>
<TabPage>
<TimeClockPanel
header={
<PageHeader
title="Time clock"
subtitle="Track billable hours and link them to invoices"
/>
}
defaultClientId={clientId ?? ""}
defaultInvoiceId={invoiceId ?? ""}
compact
/>
</TabPage>
</AppBackground>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
/>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { router } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText, Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { requestPasswordReset } from "@/lib/auth-api";
import { isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function ForgotPasswordScreen() {
const { colors } = useAppTheme();
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const emailValidationError = !email.trim()
? "Email is required"
: isValidEmail(email)
? undefined
: "Enter a valid email";
const canSubmit = isValidEmail(email) && serverReady;
async function handleSubmit() {
markSubmitted();
if (!canSubmit) return;
setError(null);
setMessage(null);
setLoading(true);
try {
const result = await requestPasswordReset(email.trim());
setMessage(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView contentContainerStyle={styles.container}>
<Pressable onPress={() => router.back()}>
<Text style={[styles.back, { color: colors.mutedForeground }]}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="md" />
<HeadingText style={styles.title}>Reset password</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Enter your email and we&apos;ll send reset instructions if an account exists.
</Text>
</View>
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
{message ? (
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
) : null}
<Button
title="Send reset link"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
<Button
title="Have a reset token?"
variant="ghost"
onPress={() => router.push("/(auth)/reset-password")}
/>
</View>
</Card>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
padding: spacing.lg,
paddingBottom: spacing.md,
gap: spacing.md,
justifyContent: "center",
},
back: {
fontFamily: fonts.bodyMedium,
fontSize: 16,
marginBottom: spacing.sm,
},
card: { gap: spacing.lg },
header: { gap: spacing.sm },
title: { fontSize: 28 },
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
form: { gap: spacing.md },
error: {
fontSize: 14,
fontFamily: fonts.body,
},
success: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+12
View File
@@ -0,0 +1,12 @@
import { Redirect } from "expo-router";
import { useAccounts } from "@/contexts/AccountsContext";
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" />;
}
+218
View File
@@ -0,0 +1,218 @@
import { Link } from "expo-router";
import { useState } from "react";
import { 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 { AuthServerPicker } from "@/components/AuthServerPicker";
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import {
isRequiredString,
isValidEmail,
isValidPassword,
useFieldVisibility,
} from "@/lib/form-validation";
export default function RegisterScreen() {
const authClient = useAuthClient();
const { apiUrl, activeAccountId, registerAccount: saveAccount } = useAccounts();
const { colors } = useAppTheme();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
const firstNameError = isRequiredString(firstName) ? undefined : "First name is required";
const lastNameError = isRequiredString(lastName) ? undefined : "Last name is required";
const emailValidationError = isValidEmail(email)
? undefined
: email.trim()
? "Enter a valid email"
: "Email is required";
const passwordValidationError = isValidPassword(password)
? undefined
: password
? "Password must be at least 8 characters"
: "Password is required";
const canRegister =
isRequiredString(firstName) &&
isRequiredString(lastName) &&
isValidEmail(email) &&
isValidPassword(password) &&
serverReady;
async function handleRegister() {
markSubmitted();
if (!canRegister) return;
setError(null);
setLoading(true);
try {
await registerAccount({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
password,
});
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
if (signInError) {
setError(signInError.message || "Account created but sign-in failed. Try signing in.");
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
const completed = await completeSignInAfterAuth(authClient, {
apiUrl,
activeAccountId,
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) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return (
<AuthScreenLayout>
<AuthCard>
<AuthCardHeader
title="Create your account"
description="Get started with your workspace"
/>
<AuthServerPicker onReadyChange={setServerReady} embedded />
<View style={styles.form}>
<View style={styles.row}>
<View style={styles.half}>
<Input
label="First name"
leftIcon="person-outline"
value={firstName}
onChangeText={setFirstName}
onBlur={() => touch("firstName")}
autoComplete="given-name"
placeholder="John"
required
error={visible("firstName") ? firstNameError : undefined}
/>
</View>
<View style={styles.half}>
<Input
label="Last name"
leftIcon="person-outline"
value={lastName}
onChangeText={setLastName}
onBlur={() => touch("lastName")}
autoComplete="family-name"
placeholder="Doe"
required
error={visible("lastName") ? lastNameError : undefined}
/>
</View>
</View>
<Input
label="Email"
leftIcon="mail-outline"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
leftIcon="lock-closed-outline"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
hint="At least 8 characters"
required
error={visible("password") ? passwordValidationError : undefined}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title={loading ? "Creating account…" : "Create account"}
loading={loading}
disabled={!canRegister}
showArrow={!loading}
onPress={handleRegister}
/>
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Already have an account?{" "}
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
Sign in
</Link>
</Text>
<LegalAgreementNotice action="creating an account" />
</AuthCard>
</AuthScreenLayout>
);
}
const styles = StyleSheet.create({
form: {
gap: spacing.md,
},
row: {
flexDirection: "row",
gap: spacing.md,
},
half: {
flex: 1,
},
error: {
fontSize: 14,
fontFamily: fonts.body,
},
footer: {
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
},
link: {
fontFamily: fonts.bodySemiBold,
},
});
+204
View File
@@ -0,0 +1,204 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { HeadingText } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { resetPassword } from "@/lib/auth-api";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, isValidPassword } from "@/lib/form-validation";
export default function ResetPasswordScreen() {
const styles = useThemedStyles(createResetPasswordStyles);
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
const [token, setToken] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
useEffect(() => {
if (typeof tokenParam === "string" && tokenParam.length > 0) {
setToken(tokenParam);
}
}, [tokenParam]);
const tokenError = isRequiredString(token) ? undefined : "Reset token is required";
const passwordError = isValidPassword(password)
? undefined
: password
? "Password must be at least 8 characters"
: "Password is required";
const confirmError =
confirmPassword && password !== confirmPassword ? "Passwords do not match" : undefined;
const canSubmit =
serverReady &&
isRequiredString(token) &&
isValidPassword(password) &&
password === confirmPassword &&
confirmPassword.length > 0;
async function handleSubmit() {
if (!canSubmit) return;
setError(null);
setLoading(true);
try {
await resetPassword(token.trim(), password);
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Reset failed");
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView contentContainerStyle={styles.container}>
<Pressable onPress={() => router.back()}>
<Text style={styles.back}> Back</Text>
</Pressable>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<HeadingText style={styles.title}>Set new password</HeadingText>
<Text style={styles.subtitle}>
Paste the reset token from your email, or open the link on this device.
</Text>
</View>
{success ? (
<View style={styles.successBox}>
<Text style={styles.successTitle}>Password updated</Text>
<Text style={styles.successText}>
You can now sign in with your new password.
</Text>
<Button
title="Go to sign in"
onPress={() => router.replace("/(auth)/sign-in")}
/>
</View>
) : (
<View style={styles.form}>
<Input
label="Reset token"
autoCapitalize="none"
value={token}
onChangeText={setToken}
placeholder="Paste token from email"
required
error={tokenError}
/>
<Input
label="New password"
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="At least 8 characters"
required
error={passwordError}
/>
<Input
label="Confirm password"
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repeat password"
required
error={confirmError}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Update password"
loading={loading}
disabled={!canSubmit}
onPress={handleSubmit}
/>
</View>
)}
</Card>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const createResetPasswordStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
padding: spacing.lg,
gap: spacing.md,
justifyContent: "center",
},
back: {
color: colors.mutedForeground,
fontFamily: fonts.bodyMedium,
fontSize: 16,
marginBottom: spacing.sm,
},
card: { gap: spacing.lg },
header: { gap: spacing.sm },
title: { fontSize: 28 },
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
form: { gap: spacing.md },
error: {
color: colors.destructive,
fontSize: 14,
fontFamily: fonts.body,
},
successBox: {
gap: spacing.md,
padding: spacing.lg,
backgroundColor: colors.muted,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.border,
},
successTitle: {
fontSize: 20,
fontFamily: fonts.heading,
color: colors.foreground,
},
successText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+159
View File
@@ -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,
},
});
+231
View File
@@ -0,0 +1,231 @@
import { Link, router } from "expo-router";
import * as Linking from "expo-linking";
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
import { AuthDivider } from "@/components/auth/AuthDivider";
import { AuthNotice } from "@/components/auth/AuthNotice";
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth";
import { prepareAuthScreenSession } from "@/lib/auth-session";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
const authClient = useAuthClient();
const { apiUrl, activeAccountId, clearActiveAccount, registerAccount } = useAccounts();
const { colors } = useAppTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const [authentikEnabled, setAuthentikEnabled] = useState(false);
const [signupsDisabled, setSignupsDisabled] = useState(false);
const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => {
void prepareAuthScreenSession(authClient, activeAccountId, clearActiveAccount);
}, [authClient, activeAccountId, clearActiveAccount]);
useEffect(() => {
let cancelled = false;
void fetchAuthCapabilities(apiUrl).then((capabilities) => {
if (cancelled) return;
setAuthentikEnabled(capabilities.authentik);
setSignupsDisabled(capabilities.signupsDisabled);
});
return () => {
cancelled = true;
};
}, [apiUrl]);
const emailValidationError = !email.trim()
? "Email is required"
: isValidEmail(email)
? undefined
: "Enter a valid email";
const passwordValidationError = password.trim() ? undefined : "Password is required";
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
async function finishSignIn() {
const completed = await completeSignInAfterAuth(authClient, {
apiUrl,
activeAccountId,
registerAccount,
});
if (!completed) {
setError("Signed in but session was not available. Try again.");
}
}
async function handleSignIn() {
markSubmitted();
if (!canSignIn) return;
setError(null);
setLoading(true);
try {
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
if (signInError) {
setError(formatAuthErrorMessage(signInError));
return;
}
await finishSignIn();
} finally {
setLoading(false);
}
}
async function handleAuthentikSignIn() {
if (!serverReady) return;
setError(null);
setLoading(true);
try {
const { error: oauthError } = await signInWithAuthentik(
authClient,
Linking.createURL("/"),
);
if (oauthError) {
setError(formatAuthErrorMessage(oauthError));
return;
}
await finishSignIn();
} finally {
setLoading(false);
}
}
return (
<AuthScreenLayout>
<AuthCard>
<AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
<AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? (
<AuthNotice>New account registration is currently disabled.</AuthNotice>
) : null}
{authentikEnabled ? (
<View style={styles.ssoSection}>
<Button
title="Sign in with Authentik"
variant="secondary"
loading={loading}
disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()}
/>
<AuthDivider />
</View>
) : null}
<View style={styles.form}>
<Input
label="Email"
leftIcon="mail-outline"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
leftIcon="lock-closed-outline"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
labelAccessory={
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title={loading ? "Signing in…" : "Sign in"}
loading={loading}
disabled={!canSignIn}
showArrow={!loading}
onPress={handleSignIn}
/>
</View>
{!signupsDisabled ? (
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create account
</Link>
</Text>
) : null}
<LegalAgreementNotice action="signing in" />
</AuthCard>
</AuthScreenLayout>
);
}
const styles = StyleSheet.create({
ssoSection: {
gap: spacing.md,
},
form: {
gap: spacing.md,
},
forgot: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
},
error: {
fontSize: 14,
fontFamily: fonts.body,
},
footer: {
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
},
link: {
fontFamily: fonts.bodySemiBold,
},
});
+39
View File
@@ -0,0 +1,39 @@
import { ScrollViewStyleReset } from 'expo-router/html';
import type { ReactNode } from 'react';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;
+40
View File
@@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});
+129
View File
@@ -0,0 +1,129 @@
import { Stack } from "expo-router";
import {
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
} from "@expo-google-fonts/inter";
import {
PlayfairDisplay_600SemiBold,
PlayfairDisplay_700Bold,
} from "@expo-google-fonts/playfair-display";
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, type ReactNode } from "react";
import { View } from "react-native";
import { StatusBar } from "expo-status-bar";
import "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SessionSync } from "@/components/SessionSync";
import { ShortcutLinkCapture } from "@/components/ShortcutLinkCapture";
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
import { TRPCProvider } from "@/lib/trpc";
export { ErrorBoundary } from "expo-router";
SplashScreen.preventAutoHideAsync();
function AppServices({ children }: { children: ReactNode }) {
const { apiUrl, authStoragePrefix, activeAccountId } = useAccounts();
const remountKey = `${activeAccountId ?? "guest"}:${apiUrl}`;
return (
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
<SessionSync />
<ShortcutLinkCapture />
{children}
</TRPCProvider>
</AuthProvider>
);
}
function ThemedChrome({ children }: { children: ReactNode }) {
const { isDark } = useAppTheme();
return (
<View style={{ flex: 1, backgroundColor: "transparent" }}>
<BrandBackground />
<View style={{ flex: 1, zIndex: 1 }}>
<StatusBar style={isDark ? "light" : "dark"} />
{children}
</View>
</View>
);
}
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
PlayfairDisplay_600SemiBold,
PlayfairDisplay_700Bold,
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ThemeProvider>
<ThemedChrome>
<AccountsProvider>
<AppServices>
<RootNavigator />
</AppServices>
</AccountsProvider>
</ThemedChrome>
</ThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
function RootNavigator() {
const { data: session, isPending } = useSession();
const { activeAccountId } = useAccounts();
if (isPending) {
return <LoadingScreen message="Checking session…" />;
}
const isAuthenticated = Boolean(session?.user && activeAccountId);
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "transparent" },
}}
>
<Stack.Protected guard={!isAuthenticated}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated}>
<Stack.Screen name="(app)" />
</Stack.Protected>
</Stack>
);
}