Add beenvoice mobile companion app with full dark mode support.

Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 22:36:37 -04:00
parent 8a7a8df477
commit 14c880123c
93 changed files with 8849 additions and 7849 deletions
+69
View File
@@ -0,0 +1,69 @@
import { DynamicColorIOS, Platform } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { useAppTheme } from "@/contexts/ThemeContext";
import { mutedForeground, primary } from "@/lib/beenvoice-theme";
import { AppLockProvider } from "@/contexts/AppLockContext";
export default function AppLayout() {
const { colors } = useAppTheme();
const tintColor =
Platform.OS === "ios"
? DynamicColorIOS({ light: primary, dark: "#FAFAFA" })
: colors.primary;
const labelColor =
Platform.OS === "ios"
? DynamicColorIOS({ light: mutedForeground, dark: "#A1A1AA" })
: colors.mutedForeground;
const tabContentStyle = { backgroundColor: "transparent" as const };
return (
<AppLockProvider>
<NativeTabs
tintColor={tintColor}
iconColor={{
default: labelColor,
selected: tintColor,
}}
labelStyle={{ color: labelColor }}
>
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger.Icon
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
md="grid_view"
/>
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="timer" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger.Icon
sf={{ default: "timer", selected: "timer" }}
md="timer"
/>
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<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="settings" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger.Icon
sf={{ default: "gearshape", selected: "gearshape.fill" }}
md="settings"
/>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
<AppLockOverlay />
</AppLockProvider>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { Screen } from "@/components/Screen";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground";
import { PageHeader } from "@/components/PageHeader";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, radii, 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 } from "@/lib/invoice-status";
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export default function DashboardScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createDashboardStyles);
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 dashboard…" />;
}
if (statsQuery.error) {
return (
<AppBackground>
<Screen>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load dashboard</Text>
<Text style={styles.errorText}>{statsQuery.error.message}</Text>
</View>
</Screen>
</AppBackground>
);
}
const stats = statsQuery.data;
if (!stats) {
return <LoadingScreen message="Loading dashboard…" />;
}
const running = runningQuery.data;
const revenueChange =
stats.revenueChange > 0
? `+${stats.revenueChange.toFixed(0)}% vs last month`
: stats.revenueChange < 0
? `${stats.revenueChange.toFixed(0)}% vs last month`
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader title="Overview" subtitle="Your invoicing at a glance" />
}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void runningQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{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}>
{running.description || "Timer running"}
</Text>
<Text style={styles.runningSub}>
{running.client?.name ?? "No client"}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
</Text>
</View>
<Text style={styles.runningTime}>
{formatElapsedHoursMinutes(runningElapsed)}
</Text>
</View>
</GlassSurface>
</Pressable>
) : null}
{stats.overdueCount > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"}
</Text>
<Text style={styles.alertText}>
Follow up on outstanding payments from the Invoices tab.
</Text>
</View>
</GlassSurface>
) : null}
<View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button
title="View invoices"
variant="secondary"
onPress={() => router.push("/(app)/invoices")}
/>
</View>
<View style={styles.statsGrid}>
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
<StatCard
label="Overdue"
value={String(stats.overdueCount)}
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
/>
<StatCard label="Clients" value={String(stats.totalClients)} hint={revenueChange} />
</View>
<Card title="Revenue (6 months)">
<View style={styles.chart}>
{stats.revenueChartData.map((point) => (
<View key={point.month} style={styles.chartColumn}>
<View style={styles.chartBarTrack}>
<View
style={[
styles.chartBar,
{ height: `${Math.max(8, (point.revenue / maxRevenue) * 100)}%` },
]}
/>
</View>
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
<Text style={styles.chartValue}>
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
</Text>
</View>
))}
</View>
</Card>
<Card title="Recent invoices">
{stats.recentInvoices.length === 0 ? (
<Text style={styles.empty}>No invoices yet. Create one on the web app.</Text>
) : (
stats.recentInvoices.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.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({
safe: {
flex: 1,
},
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,
},
alertBanner: {
padding: spacing.md,
gap: 4,
},
alertGlass: {
borderColor: isDark ? "rgba(251, 191, 36, 0.4)" : "#FDE68A",
},
alertTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.warning,
fontSize: 14,
},
alertText: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
},
quickActions: {
flexDirection: "row",
gap: spacing.sm,
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.md,
},
chart: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.xs,
minHeight: 140,
},
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,
},
chartValue: {
fontSize: 9,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
empty: {
color: colors.mutedForeground,
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
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,
},
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,
},
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,
},
});
+395
View File
@@ -0,0 +1,395 @@
import { router, useLocalSearchParams } from "expo-router";
import { Alert, Platform, 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 type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
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 sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
Alert.alert("Invoice sent", data.message);
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
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 promptSendInvoice() {
if (!clientEmail) {
Alert.alert(
"No client email",
"Add an email address to this client on the web app before sending invoices.",
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: 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>
<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>
<Card title="Details">
<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}
</Card>
<Card title="Line items">
{invoice.items.map((item) => (
<View key={item.id} 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>
))}
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, invoice.currency)} />
{invoice.taxRate > 0 ? (
<TotalRow
label={`Tax (${invoice.taxRate}%)`}
value={formatCurrency(taxAmount, invoice.currency)}
/>
) : null}
<TotalRow
label="Total"
value={formatCurrency(invoice.totalAmount, invoice.currency)}
bold
/>
</View>
</Card>
{invoice.notes ? (
<Card title="Notes">
<Text style={styles.notes}>{invoice.notes}</Text>
</Card>
) : null}
<View style={styles.actions}>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
<Button
title="Edit invoice"
variant="secondary"
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
/>
<Button
title="Update status"
variant="ghost"
onPress={() => promptStatusChange(status)}
loading={updateStatus.isPending}
/>
<Button
title="Track time to this invoice"
variant="ghost"
onPress={() =>
router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
}
/>
</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>
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.totalRow}>
<Text
style={[
detailStyles.totalLabel,
{ color: colors.mutedForeground },
bold && detailStyles.totalBold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
detailStyles.totalValue,
{ color: colors.foreground },
bold && detailStyles.totalBold,
]}
>
{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,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
},
totalLabel: {
fontFamily: fonts.body,
fontSize: 14,
},
totalValue: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
totalBold: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
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,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 4,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
actions: {
gap: spacing.sm,
},
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,
},
});
+35
View File
@@ -0,0 +1,35 @@
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={{
headerShown: false,
statusBarTranslucent: true,
contentStyle: { flex: 1, backgroundColor: "transparent" },
}}
/>
<Stack.Screen name="[id]" options={{ title: "Invoice" }} />
<Stack.Screen name="edit/[id]" options={{ title: "Edit" }} />
</Stack>
);
}
+378
View File
@@ -0,0 +1,378 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppBackground } from "@/components/AppBackground";
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 { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { formatCurrency } from "@/lib/format";
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 insets = useSafeAreaInsets();
const tabBarHeight = useNativeTabBarHeight();
const scrollPadding = useTabBarScrollPadding();
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date());
const [items, setItems] = useState<EditableLineItem[]>([]);
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState(0);
useEffect(() => {
const invoice = invoiceQuery.data;
if (!invoice) return;
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setItems(
invoice.items.map((item) => ({
id: item.id,
date: new Date(item.date),
description: item.description,
hours: String(item.hours),
rate: String(item.rate),
})),
);
setExpandedIndex(null);
}, [invoiceQuery.data]);
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
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 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 taxRate = invoice?.taxRate ?? 0;
const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading) {
return <LoadingScreen message="Loading invoice…" />;
}
if (!invoice) {
return <LoadingScreen message="Invoice not found" />;
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
const nextIndex = items.length;
setItems((prev) => [
...prev,
{
date: new Date(),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
setExpandedIndex(nextIndex);
}
function removeItem(index: number) {
if (items.length <= 1) {
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
setExpandedIndex((current) => {
if (current === null) return null;
if (current === index) return null;
return current > index ? current - 1 : current;
});
}
function handleSave() {
setError(null);
const parsedItems: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}> = [];
for (const item of items) {
const hours = Number(item.hours);
const rate = Number(item.rate);
if (!item.description.trim()) {
setError("Each line needs a description");
return;
}
if (Number.isNaN(hours) || hours < 0) {
setError("Hours must be a valid number");
return;
}
if (Number.isNaN(rate) || rate < 0) {
setError("Rate must be a valid number");
return;
}
parsedItems.push({
date: item.date,
description: item.description.trim(),
hours,
rate,
});
}
updateInvoice.mutate({
id,
notes,
dueDate,
items: parsedItems,
});
}
return (
<AppBackground>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding + footerHeight },
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
</View>
<Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
<Input
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</Card>
<Card title="Line items">
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
item={item}
currency={currency}
expanded={expandedIndex === index}
onToggle={() =>
setExpandedIndex((current) => (current === index ? null : index))
}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
{taxRate > 0 ? (
<TotalRow
label={`Tax (${taxRate}%)`}
value={formatCurrency(taxAmount, currency)}
/>
) : null}
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
</View>
</Card>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
<View
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
style={[
styles.footer,
{
bottom: tabBarHeight,
paddingBottom: Math.max(insets.bottom, spacing.sm),
},
]}
>
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
</View>
</KeyboardAvoidingView>
</AppBackground>
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={totalStyles.row}>
<Text
style={[
totalStyles.label,
{ color: colors.mutedForeground },
bold && totalStyles.bold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
totalStyles.value,
{ color: colors.foreground },
bold && totalStyles.bold,
]}
>
{value}
</Text>
</View>
);
}
const totalStyles = StyleSheet.create({
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
label: {
fontFamily: fonts.body,
fontSize: 14,
},
value: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
bold: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
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,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
addLineText: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
color: colors.primary,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 6,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
footer: {
position: "absolute",
left: 0,
right: 0,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.cardGlass,
},
});
+255
View File
@@ -0,0 +1,255 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
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 { 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),
});
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}>{invoicesQuery.error.message}</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" },
]);
}
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}>
Create invoices on the web app, then view and edit them here.
</Text>
</View>
) : (
invoices.map((invoice) => {
const status = getInvoiceStatus(invoice);
return (
<Pressable
key={invoice.id}
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}>
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</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>
</Pressable>
);
})
)}
</TabScrollView>
</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,
},
});
+451
View File
@@ -0,0 +1,451 @@
import { useState } from "react";
import Constants from "expo-constants";
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 { 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 { 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,
clearActiveAccount,
} = useAccounts();
const { colors, colorMode, setColorMode } = useAppTheme();
const {
enabled: lockEnabled,
biometricEnabled,
biometricAvailable,
biometricLabel,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
} = useAppLock();
const profileQuery = api.settings.getProfile.useQuery();
const [pinPrompt, setPinPrompt] = useState<
| { mode: "create" }
| { mode: "confirm-disable" }
| { mode: "change-current" }
| { mode: "change-next" }
| null
>(null);
const [pendingPin, setPendingPin] = useState("");
async function handleSignOut() {
await authClient.signOut();
await clearActiveAccount();
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: handleSignOut },
]);
}
function confirmRemoveAccount(accountId: string, label: string) {
Alert.alert("Remove account", `Remove ${label} from this device?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => void removeAccount(accountId),
},
]);
}
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>
<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 (
<Pressable
key={account.id}
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
onLongPress={() => confirmRemoveAccount(account.id, account.email)}
style={({ pressed }) => [
styles.accountRow,
{
borderColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
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>
);
})}
<Button
title="Add another account"
variant="secondary"
onPress={async () => {
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}}
/>
{accounts.length > 1 ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Long-press an account to remove it from this device.
</Text>
) : null}
</Card>
<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}
trackColor={{ false: colors.border, true: colors.primary }}
/>
</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}
trackColor={{ false: colors.border, true: colors.primary }}
/>
</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="Server instance">
<InstanceUrlField onSaved={confirmInstanceChange} />
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
Connected to {activeAccount?.instanceUrl ?? apiUrl}
</Text>
</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>
<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,
},
appRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
appValue: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
accountRow: {
borderWidth: 1,
borderRadius: 12,
padding: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
},
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,
},
});
+31
View File
@@ -0,0 +1,31 @@
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 { clientId, invoiceId } = useLocalSearchParams<{
clientId?: string;
invoiceId?: string;
}>();
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" },
}}
/>
);
}
+133
View File
@@ -0,0 +1,133 @@
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 { CollapsibleServerField } from "@/components/CollapsibleServerField";
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";
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);
async function handleSubmit() {
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>
<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}
placeholder="you@example.com"
/>
{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} onPress={handleSubmit} />
<Button
title="Have a reset token?"
variant="ghost"
onPress={() => router.push("/(auth)/reset-password")}
/>
</View>
</Card>
</ScrollView>
</KeyboardAvoidingView>
<CollapsibleServerField />
</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,
},
});
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function AuthIndex() {
return <Redirect href="/(auth)/sign-in" />;
}
+191
View File
@@ -0,0 +1,191 @@
import { Link, router } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FullScreen } from "@/components/Screen";
import { AuthBackground } from "@/components/AppBackground";
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
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 { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api";
export default function RegisterScreen() {
const authClient = useAuthClient();
const { apiUrl, 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);
async function handleRegister() {
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) {
router.replace("/(auth)/sign-in");
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await saveAccount({
instanceUrl: apiUrl,
userId: user.id,
email: user.email,
name: user.name,
});
}
router.replace("/(app)");
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Create your account</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Get started today
</Text>
</View>
<View style={styles.form}>
<View style={styles.row}>
<View style={styles.half}>
<Input
label="First name"
value={firstName}
onChangeText={setFirstName}
autoComplete="given-name"
placeholder="Jane"
/>
</View>
<View style={styles.half}>
<Input
label="Last name"
value={lastName}
onChangeText={setLastName}
autoComplete="family-name"
placeholder="Doe"
/>
</View>
</View>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
/>
<Input
label="Password"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
placeholder="At least 8 characters"
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button title="Create Account" loading={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>
</Card>
</ScrollView>
</KeyboardAvoidingView>
<CollapsibleServerField />
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
flex: { flex: 1 },
container: {
flexGrow: 1,
justifyContent: "center",
padding: spacing.lg,
paddingBottom: spacing.md,
},
card: {
gap: spacing.lg,
},
header: { gap: spacing.sm },
title: { fontSize: 24, marginTop: spacing.sm },
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
},
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,
},
});
+182
View File
@@ -0,0 +1,182 @@
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 { 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";
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);
useEffect(() => {
if (typeof tokenParam === "string" && tokenParam.length > 0) {
setToken(tokenParam);
}
}, [tokenParam]);
async function handleSubmit() {
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
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>
<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"
/>
<Input
label="New password"
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="At least 8 characters"
/>
<Input
label="Confirm password"
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repeat password"
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title="Update password" loading={loading} 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,
},
});
+179
View File
@@ -0,0 +1,179 @@
import { Link, router } from "expo-router";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
import { HeadingText, Logo } from "@/components/Logo";
import { FullScreen } from "@/components/Screen";
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 { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
export default function SignInScreen() {
const authClient = useAuthClient();
const { apiUrl, 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);
async function handleSignIn() {
setError(null);
setLoading(true);
const { error: signInError } = await authClient.signIn.email({ email: email.trim(), password });
if (signInError) {
setLoading(false);
const message = signInError.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
} else {
setError(message || "Invalid email or password");
}
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await registerAccount({
instanceUrl: apiUrl,
userId: user.id,
email: user.email,
name: user.name,
});
}
setLoading(false);
router.replace("/(app)");
}
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Welcome back</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Sign in to manage invoices on the go
</Text>
</View>
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
/>
<Input
label="Password"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
placeholder="••••••••"
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button title="Sign In" loading={loading} onPress={handleSignIn} />
</View>
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one
</Link>
</Text>
</Card>
</ScrollView>
</KeyboardAvoidingView>
<CollapsibleServerField />
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
flex: {
flex: 1,
},
container: {
flexGrow: 1,
justifyContent: "center",
padding: spacing.lg,
paddingBottom: spacing.md,
},
card: {
gap: spacing.lg,
},
header: {
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
},
form: {
gap: spacing.md,
},
forgot: {
alignSelf: "flex-end",
fontFamily: fonts.bodyMedium,
fontSize: 12,
},
error: {
fontSize: 14,
fontFamily: fonts.body,
},
footer: {
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
},
link: {
fontFamily: fonts.bodySemiBold,
},
});
-70
View File
@@ -1,70 +0,0 @@
import { SymbolView } from 'expo-symbols';
import { Link, Tabs } from 'expo-router';
import { Platform, Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
),
headerRight: () => (
<Link href="/modal" asChild>
<Pressable style={{ marginRight: 15 }}>
{({ pressed }) => (
<SymbolView
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
size={25}
tintColor={Colors[colorScheme].text}
style={{ opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
),
}}
/>
</Tabs>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
-31
View File
@@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
+96 -30
View File
@@ -1,30 +1,71 @@
import { useFonts } from 'expo-font';
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
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 { useColorScheme } from '@/components/useColorScheme';
import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
import { TRPCProvider } from "@/lib/trpc";
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export { ErrorBoundary } from "expo-router";
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
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}>
{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'),
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
PlayfairDisplay_600SemiBold,
PlayfairDisplay_700Bold,
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
@@ -39,18 +80,43 @@ export default function RootLayout() {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
<SafeAreaProvider>
<ThemeProvider>
<ThemedChrome>
<AccountsProvider>
<AppServices>
<RootNavigator />
</AppServices>
</AccountsProvider>
</ThemedChrome>
</ThemeProvider>
</SafeAreaProvider>
);
}
function RootNavigator() {
const { data: session, isPending } = useSession();
if (isPending) {
return <LoadingScreen message="Checking session…" />;
}
const isAuthenticated = Boolean(session?.user);
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>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});