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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user