Redesign mobile time clock, add shortcuts, and improve account management.

Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 16:06:17 -04:00
parent 0b2d65a4e9
commit 06bc91ac13
33 changed files with 1844 additions and 320 deletions
+4
View File
@@ -2,6 +2,8 @@ import { Platform } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { ShortcutHandler } from "@/components/ShortcutHandler";
import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext";
@@ -71,6 +73,8 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
<InvoiceReminderSync />
<ShortcutHandler />
<AppLockOverlay />
</AppLockProvider>
);
+21
View File
@@ -62,6 +62,7 @@ export default function DashboardScreen() {
: "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.sendReminderDue ?? [];
return (
<AppBackground>
@@ -118,6 +119,26 @@ export default function DashboardScreen() {
</GlassSurface>
) : null}
{sendReminderDue.length > 0 ? (
<GlassSurface style={styles.alertGlass}>
<View style={styles.alertBanner}>
<Text style={styles.alertTitle}>
{sendReminderDue.length} draft{" "}
{sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
</Text>
<Text style={styles.alertText}>
{sendReminderDue
.slice(0, 2)
.map(
(inv) =>
`${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
)
.join(" · ")}
</Text>
</View>
</GlassSurface>
) : null}
<View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button
+48
View File
@@ -46,6 +46,14 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send invoice", 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),
});
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
@@ -96,6 +104,28 @@ export default function InvoiceDetailScreen() {
);
}
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" });
@@ -165,6 +195,16 @@ export default function InvoiceDetailScreen() {
{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">
@@ -212,6 +252,14 @@ export default function InvoiceDetailScreen() {
loading={sendInvoice.isPending}
/>
) : null}
{status === "sent" || status === "overdue" ? (
<Button
title="Send payment reminder"
variant="secondary"
onPress={promptPaymentReminder}
loading={sendPaymentReminder.isPending}
/>
) : null}
<Button
title="Edit invoice"
variant="secondary"
+66 -7
View File
@@ -23,6 +23,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { validateLineItems } from "@/lib/form-validation";
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";
@@ -42,6 +43,7 @@ export default function InvoiceEditScreen() {
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date());
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -51,6 +53,7 @@ export default function InvoiceEditScreen() {
if (!invoice) return;
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems(
invoice.items.map((item) => ({
id: item.id,
@@ -67,6 +70,7 @@ export default function InvoiceEditScreen() {
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() },
@@ -86,6 +90,7 @@ export default function InvoiceEditScreen() {
});
const invoice = invoiceQuery.data;
const isDraft = invoice?.status === "draft";
const subtotal = useMemo(
() =>
@@ -101,8 +106,8 @@ export default function InvoiceEditScreen() {
const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD";
const lineItemsError = validateLineItems(items);
const canSave = !lineItemsError;
const lineItemsError = isDraft ? validateLineItems(items) : null;
const canSave = isDraft ? !lineItemsError : true;
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
@@ -172,10 +177,20 @@ export default function InvoiceEditScreen() {
});
}
function handleSave() {
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;
@@ -196,7 +211,12 @@ export default function InvoiceEditScreen() {
id,
notes,
dueDate,
items: parsedItems,
sendReminderAt,
...(isDraft
? {
items: parsedItems,
}
: {}),
});
}
@@ -223,6 +243,25 @@ export default function InvoiceEditScreen() {
<Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
{isDraft ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={setSendReminderAt}
/>
{sendReminderAt ? (
<Pressable onPress={() => setSendReminderAt(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
@@ -234,6 +273,12 @@ export default function InvoiceEditScreen() {
</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>
) : null}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
@@ -245,12 +290,15 @@ export default function InvoiceEditScreen() {
}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
) : null}
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
@@ -369,6 +417,17 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
minHeight: 72,
textAlignVertical: "top",
},
lockedHint: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
marginBottom: spacing.sm,
},
addLine: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
+86 -39
View File
@@ -19,6 +19,7 @@ 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 { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -37,6 +38,7 @@ export default function SettingsScreen() {
apiUrl,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const { colors, colorMode, setColorMode } = useAppTheme();
@@ -67,6 +69,31 @@ export default function SettingsScreen() {
>(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,
clearActiveAccount,
signOut: () => authClient.signOut(),
});
},
);
}
async function handleSignOut() {
await authClient.signOut();
@@ -77,18 +104,7 @@ export default function SettingsScreen() {
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),
},
{ text: "Sign out", style: "destructive", onPress: () => void handleSignOut() },
]);
}
@@ -218,47 +234,64 @@ export default function SettingsScreen() {
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<Pressable
<View
key={account.id}
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
onLongPress={() => confirmRemoveAccount(account.id, account.email)}
style={({ pressed }) => [
style={[
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>
<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)}
/>
{accounts.length > 1 ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Long-press an account to remove it from this device.
</Text>
) : null}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Tap an account to switch. Refresh updates names from saved sign-in data.
</Text>
</Card>
<Card title="Security">
@@ -418,11 +451,25 @@ const styles = StyleSheet.create({
accountRow: {
borderWidth: 1,
borderRadius: 12,
padding: spacing.md,
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,
+7 -5
View File
@@ -6,10 +6,12 @@ import { TabPage } from "@/components/TabPage";
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
export default function TimerScreen() {
const { clientId, invoiceId } = useLocalSearchParams<{
clientId?: string;
invoiceId?: string;
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>
@@ -21,8 +23,8 @@ export default function TimerScreen() {
subtitle="Track billable hours and link them to invoices"
/>
}
defaultClientId={clientId}
defaultInvoiceId={invoiceId}
defaultClientId={clientId ?? ""}
defaultInvoiceId={invoiceId ?? ""}
compact
/>
</TabPage>
+4 -2
View File
@@ -19,6 +19,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SessionSync } from "@/components/SessionSync";
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
@@ -34,6 +35,7 @@ function AppServices({ children }: { children: ReactNode }) {
return (
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
<SessionSync />
{children}
</TRPCProvider>
</AuthProvider>
@@ -95,13 +97,13 @@ export default function RootLayout() {
}
function RootNavigator() {
const { data: session, isPending } = useSession();
const { data: session, isPending, error } = useSession();
if (isPending) {
return <LoadingScreen message="Checking session…" />;
}
const isAuthenticated = Boolean(session?.user);
const isAuthenticated = Boolean(session?.user) && !error;
return (
<Stack