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
co-authored by Cursor
parent 0b2d65a4e9
commit 06bc91ac13
33 changed files with 1844 additions and 320 deletions
+17 -4
View File
@@ -84,12 +84,25 @@ bun run ios
Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model) Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model)
## Deep links ## Deep links & Shortcuts
| Scheme | Screen | | URL | Action |
|--------|--------| |-----|--------|
| `beenvoice://reset-password?token=…` | Reset password | | `beenvoice://reset-password?token=…` | Reset password |
| `beenvoice://timer` | Timer tab (from Live Activity) | | `beenvoice://timer` | Open time clock |
| `beenvoice://shortcuts/clock-in` | Clock in (last client) |
| `beenvoice://shortcuts/clock-in?title=…` | Clock in with title |
| `beenvoice://shortcuts/clock-out` | Clock out running timer |
**iOS Shortcuts / Siri** (dev build after `npx expo prebuild`):
- **Clock In** — starts the timer with your last client
- **Clock Out** — stops the running timer
- **Open Time Clock** — opens the timer tab
Try “Hey Siri, clock in with beenvoice” or add actions from the Shortcuts app under beenvoice.
Rebuild iOS after pulling shortcut changes: `npx expo prebuild --platform ios && bun run ios`
## Project layout ## Project layout
+20 -2
View File
@@ -12,7 +12,8 @@
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app." "NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
} }
}, },
"android": { "android": {
@@ -35,6 +36,14 @@
}, },
"plugins": [ "plugins": [
"expo-dev-client", "expo-dev-client",
[
"expo-build-properties",
{
"ios": {
"buildReactNativeFromSource": true
}
}
],
"expo-router", "expo-router",
"expo-secure-store", "expo-secure-store",
[ [
@@ -58,7 +67,16 @@
"faceIDPermission": "Unlock beenvoice with Face ID when returning to the app." "faceIDPermission": "Unlock beenvoice with Face ID when returning to the app."
} }
], ],
"@react-native-community/datetimepicker" [
"expo-notifications",
{
"icon": "./assets/images/icon.png",
"color": "#18181B",
"sounds": []
}
],
"@react-native-community/datetimepicker",
"./plugins/withAppIntents.js"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
+4
View File
@@ -2,6 +2,8 @@ import { Platform } from "react-native";
import { NativeTabs } from "expo-router/unstable-native-tabs"; import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay"; import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
import { ShortcutHandler } from "@/components/ShortcutHandler";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { AppLockProvider } from "@/contexts/AppLockContext"; import { AppLockProvider } from "@/contexts/AppLockContext";
@@ -71,6 +73,8 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
</NativeTabs> </NativeTabs>
<InvoiceReminderSync />
<ShortcutHandler />
<AppLockOverlay /> <AppLockOverlay />
</AppLockProvider> </AppLockProvider>
); );
+21
View File
@@ -62,6 +62,7 @@ export default function DashboardScreen() {
: "No change vs last month"; : "No change vs last month";
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1); const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
const sendReminderDue = stats.sendReminderDue ?? [];
return ( return (
<AppBackground> <AppBackground>
@@ -118,6 +119,26 @@ export default function DashboardScreen() {
</GlassSurface> </GlassSurface>
) : null} ) : 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}> <View style={styles.quickActions}>
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} /> <Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
<Button <Button
+48
View File
@@ -46,6 +46,14 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send invoice", err.message), 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) { if (!id) {
return <LoadingScreen message="Invalid invoice" />; 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) { function promptStatusChange(current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = []; const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" }); if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
@@ -165,6 +195,16 @@ export default function InvoiceDetailScreen() {
{invoice.taxRate > 0 ? ( {invoice.taxRate > 0 ? (
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} /> <DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
) : null} ) : 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>
<Card title="Line items"> <Card title="Line items">
@@ -212,6 +252,14 @@ export default function InvoiceDetailScreen() {
loading={sendInvoice.isPending} loading={sendInvoice.isPending}
/> />
) : null} ) : null}
{status === "sent" || status === "overdue" ? (
<Button
title="Send payment reminder"
variant="secondary"
onPress={promptPaymentReminder}
loading={sendPaymentReminder.isPending}
/>
) : null}
<Button <Button
title="Edit invoice" title="Edit invoice"
variant="secondary" variant="secondary"
+62 -3
View File
@@ -23,6 +23,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status"; import { getInvoiceStatus } from "@/lib/invoice-status";
import { validateLineItems } from "@/lib/form-validation"; import { validateLineItems } from "@/lib/form-validation";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
@@ -42,6 +43,7 @@ export default function InvoiceEditScreen() {
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date()); const [dueDate, setDueDate] = useState(() => new Date());
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]); const [items, setItems] = useState<EditableLineItem[]>([]);
const [expandedIndex, setExpandedIndex] = useState<number | null>(null); const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,6 +53,7 @@ export default function InvoiceEditScreen() {
if (!invoice) return; if (!invoice) return;
setNotes(invoice.notes ?? ""); setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate)); setDueDate(new Date(invoice.dueDate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setItems( setItems(
invoice.items.map((item) => ({ invoice.items.map((item) => ({
id: item.id, id: item.id,
@@ -67,6 +70,7 @@ export default function InvoiceEditScreen() {
onSuccess: () => { onSuccess: () => {
void utils.invoices.getById.invalidate({ id: id ?? "" }); void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
void utils.invoices.getAll.invalidate({ status: "draft" });
void utils.dashboard.getStats.invalidate(); void utils.dashboard.getStats.invalidate();
Alert.alert("Saved", "Invoice updated", [ Alert.alert("Saved", "Invoice updated", [
{ text: "OK", onPress: () => router.back() }, { text: "OK", onPress: () => router.back() },
@@ -86,6 +90,7 @@ export default function InvoiceEditScreen() {
}); });
const invoice = invoiceQuery.data; const invoice = invoiceQuery.data;
const isDraft = invoice?.status === "draft";
const subtotal = useMemo( const subtotal = useMemo(
() => () =>
@@ -101,8 +106,8 @@ export default function InvoiceEditScreen() {
const taxAmount = subtotal * (taxRate / 100); const taxAmount = subtotal * (taxRate / 100);
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const currency = invoice?.currency ?? "USD"; const currency = invoice?.currency ?? "USD";
const lineItemsError = validateLineItems(items); const lineItemsError = isDraft ? validateLineItems(items) : null;
const canSave = !lineItemsError; const canSave = isDraft ? !lineItemsError : true;
if (!id) { if (!id) {
return <LoadingScreen message="Invalid invoice" />; return <LoadingScreen message="Invalid invoice" />;
@@ -172,10 +177,20 @@ export default function InvoiceEditScreen() {
}); });
} }
function handleSave() { async function handleSave() {
if (!canSave) return; if (!canSave) return;
setError(null); 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<{ const parsedItems: Array<{
date: Date; date: Date;
description: string; description: string;
@@ -196,7 +211,12 @@ export default function InvoiceEditScreen() {
id, id,
notes, notes,
dueDate, dueDate,
sendReminderAt,
...(isDraft
? {
items: parsedItems, items: parsedItems,
}
: {}),
}); });
} }
@@ -223,6 +243,25 @@ export default function InvoiceEditScreen() {
<Card> <Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} /> <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 <Input
label="Notes" label="Notes"
value={notes} value={notes}
@@ -234,6 +273,12 @@ export default function InvoiceEditScreen() {
</Card> </Card>
<Card title="Line items"> <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) => ( {items.map((item, index) => (
<LineItemEditor <LineItemEditor
key={item.id ?? `new-${index}`} key={item.id ?? `new-${index}`}
@@ -245,12 +290,15 @@ export default function InvoiceEditScreen() {
} }
onChange={(patch) => updateItem(index, patch)} onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)} onRemove={() => removeItem(index)}
readOnly={!isDraft}
/> />
))} ))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}> <Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Text style={styles.addLineText}>+ Add line</Text> <Text style={styles.addLineText}>+ Add line</Text>
</Pressable> </Pressable>
) : null}
<View style={styles.totals}> <View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} /> <TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
@@ -369,6 +417,17 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
minHeight: 72, minHeight: 72,
textAlignVertical: "top", textAlignVertical: "top",
}, },
lockedHint: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
marginBottom: spacing.sm,
},
addLine: { addLine: {
paddingTop: spacing.sm, paddingTop: spacing.sm,
paddingBottom: spacing.xs, paddingBottom: spacing.xs,
+69 -22
View File
@@ -19,6 +19,7 @@ import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext"; import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account"; import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [ const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -37,6 +38,7 @@ export default function SettingsScreen() {
apiUrl, apiUrl,
switchAccount, switchAccount,
removeAccount, removeAccount,
refreshAccounts,
clearActiveAccount, clearActiveAccount,
} = useAccounts(); } = useAccounts();
const { colors, colorMode, setColorMode } = useAppTheme(); const { colors, colorMode, setColorMode } = useAppTheme();
@@ -67,6 +69,31 @@ export default function SettingsScreen() {
>(null); >(null);
const [pendingPin, setPendingPin] = useState(""); const [pendingPin, setPendingPin] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false); 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() { async function handleSignOut() {
await authClient.signOut(); await authClient.signOut();
@@ -77,18 +104,7 @@ export default function SettingsScreen() {
function confirmSignOut() { function confirmSignOut() {
Alert.alert("Sign out", "Sign out of this account on this device?", [ Alert.alert("Sign out", "Sign out of this account on this device?", [
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ text: "Sign out", style: "destructive", onPress: handleSignOut }, { text: "Sign out", style: "destructive", onPress: () => void 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),
},
]); ]);
} }
@@ -218,19 +234,20 @@ export default function SettingsScreen() {
{accounts.map((account) => { {accounts.map((account) => {
const isActive = account.id === activeAccountId; const isActive = account.id === activeAccountId;
return ( return (
<Pressable <View
key={account.id} key={account.id}
accessibilityRole="button" style={[
onPress={() => void switchAccount(account.id)}
onLongPress={() => confirmRemoveAccount(account.id, account.email)}
style={({ pressed }) => [
styles.accountRow, styles.accountRow,
{ {
borderColor: colors.border, borderColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent", backgroundColor: isActive ? colors.muted : "transparent",
}, },
pressed && styles.pressed,
]} ]}
>
<Pressable
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
style={({ pressed }) => [styles.accountMain, pressed && styles.pressed]}
> >
<View style={styles.accountMeta}> <View style={styles.accountMeta}>
<Text style={[styles.accountName, { color: colors.foreground }]}> <Text style={[styles.accountName, { color: colors.foreground }]}>
@@ -247,18 +264,34 @@ export default function SettingsScreen() {
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text> <Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
) : null} ) : null}
</Pressable> </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 <Button
title="Add another account" title="Add another account"
variant="secondary" variant="secondary"
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)} onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
/> />
{accounts.length > 1 ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}> <Text style={[styles.meta, { color: colors.mutedForeground }]}>
Long-press an account to remove it from this device. Tap an account to switch. Refresh updates names from saved sign-in data.
</Text> </Text>
) : null}
</Card> </Card>
<Card title="Security"> <Card title="Security">
@@ -418,11 +451,25 @@ const styles = StyleSheet.create({
accountRow: { accountRow: {
borderWidth: 1, borderWidth: 1,
borderRadius: 12, borderRadius: 12,
padding: spacing.md, paddingLeft: spacing.md,
paddingRight: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountMain: {
flex: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: spacing.md, gap: spacing.md,
paddingVertical: spacing.md,
},
removeButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 36,
minHeight: 36,
}, },
pressed: { pressed: {
opacity: 0.92, opacity: 0.92,
+7 -5
View File
@@ -6,10 +6,12 @@ import { TabPage } from "@/components/TabPage";
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel"; import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
export default function TimerScreen() { export default function TimerScreen() {
const { clientId, invoiceId } = useLocalSearchParams<{ const params = useLocalSearchParams<{
clientId?: string; clientId?: string | string[];
invoiceId?: 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 ( return (
<AppBackground> <AppBackground>
@@ -21,8 +23,8 @@ export default function TimerScreen() {
subtitle="Track billable hours and link them to invoices" subtitle="Track billable hours and link them to invoices"
/> />
} }
defaultClientId={clientId} defaultClientId={clientId ?? ""}
defaultInvoiceId={invoiceId} defaultInvoiceId={invoiceId ?? ""}
compact compact
/> />
</TabPage> </TabPage>
+4 -2
View File
@@ -19,6 +19,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { BrandBackground } from "@/components/BrandBackground"; import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { SessionSync } from "@/components/SessionSync";
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext"; import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext"; import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext"; import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
@@ -34,6 +35,7 @@ function AppServices({ children }: { children: ReactNode }) {
return ( return (
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}> <AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
<TRPCProvider apiUrl={apiUrl} key={remountKey}> <TRPCProvider apiUrl={apiUrl} key={remountKey}>
<SessionSync />
{children} {children}
</TRPCProvider> </TRPCProvider>
</AuthProvider> </AuthProvider>
@@ -95,13 +97,13 @@ export default function RootLayout() {
} }
function RootNavigator() { function RootNavigator() {
const { data: session, isPending } = useSession(); const { data: session, isPending, error } = useSession();
if (isPending) { if (isPending) {
return <LoadingScreen message="Checking session…" />; return <LoadingScreen message="Checking session…" />;
} }
const isAuthenticated = Boolean(session?.user); const isAuthenticated = Boolean(session?.user) && !error;
return ( return (
<Stack <Stack
+10
View File
@@ -19,6 +19,7 @@
"better-auth": "^1.6.19", "better-auth": "^1.6.19",
"expo": "~56.0.12", "expo": "~56.0.12",
"expo-blur": "~56.0.3", "expo-blur": "~56.0.3",
"expo-build-properties": "^56.0.19",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~56.0.20",
"expo-font": "~56.0.7", "expo-font": "~56.0.7",
@@ -27,6 +28,7 @@
"expo-linking": "~56.0.14", "expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4", "expo-local-authentication": "~56.0.4",
"expo-network": "^56.0.5", "expo-network": "^56.0.5",
"expo-notifications": "^56.0.18",
"expo-router": "~56.2.11", "expo-router": "~56.2.11",
"expo-secure-store": "^56.0.4", "expo-secure-store": "^56.0.4",
"expo-splash-screen": "~56.0.10", "expo-splash-screen": "~56.0.10",
@@ -505,6 +507,8 @@
"babel-preset-expo": ["babel-preset-expo@56.0.15", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^56.0.18", "react-refresh": ">=0.14.0 <1.0.0" } }, "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw=="], "babel-preset-expo": ["babel-preset-expo@56.0.15", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^56.0.18", "react-refresh": ">=0.14.0 <1.0.0" } }, "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw=="],
"badgin": ["badgin@1.2.3", "", {}, "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
@@ -663,10 +667,14 @@
"expo": ["expo@56.0.12", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.16", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.4", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.15", "expo-asset": "~56.0.17", "expo-constants": "~56.0.18", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.16", "expo-modules-core": "~56.0.17", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["react-native-webview"], "bin": { "expo": "bin/cli", "expo-modules-autolinking": "bin/autolinking", "fingerprint": "bin/fingerprint" } }, "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ=="], "expo": ["expo@56.0.12", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.16", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.4", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.15", "expo-asset": "~56.0.17", "expo-constants": "~56.0.18", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.16", "expo-modules-core": "~56.0.17", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["react-native-webview"], "bin": { "expo": "bin/cli", "expo-modules-autolinking": "bin/autolinking", "fingerprint": "bin/fingerprint" } }, "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ=="],
"expo-application": ["expo-application@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw=="],
"expo-asset": ["expo-asset@56.0.17", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.18" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg=="], "expo-asset": ["expo-asset@56.0.17", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.18" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg=="],
"expo-blur": ["expo-blur@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ=="], "expo-blur": ["expo-blur@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ=="],
"expo-build-properties": ["expo-build-properties@56.0.19", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA=="],
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="], "expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
"expo-dev-client": ["expo-dev-client@56.0.20", "", { "dependencies": { "expo-dev-launcher": "~56.0.20", "expo-dev-menu": "~56.0.17", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ=="], "expo-dev-client": ["expo-dev-client@56.0.20", "", { "dependencies": { "expo-dev-launcher": "~56.0.20", "expo-dev-menu": "~56.0.17", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ=="],
@@ -705,6 +713,8 @@
"expo-network": ["expo-network@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw=="], "expo-network": ["expo-network@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw=="],
"expo-notifications": ["expo-notifications@56.0.18", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~56.0.3", "expo-constants": "~56.0.18" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw=="],
"expo-router": ["expo-router@56.2.11", "", { "dependencies": { "@expo/log-box": "^56.0.13", "@expo/metro-runtime": "^56.0.15", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.18", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.5", "expo-symbols": "^56.0.6", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "^56.0.13", "@expo/metro-runtime": "^56.0.15", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.18", "expo-linking": "^56.0.14", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "^4.25.2", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-server-dom-webpack"] }, "sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ=="], "expo-router": ["expo-router@56.2.11", "", { "dependencies": { "@expo/log-box": "^56.0.13", "@expo/metro-runtime": "^56.0.15", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.18", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.5", "expo-symbols": "^56.0.6", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "^56.0.13", "@expo/metro-runtime": "^56.0.15", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.18", "expo-linking": "^56.0.14", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "^4.25.2", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-server-dom-webpack"] }, "sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ=="],
"expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="], "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="],
+78 -1
View File
@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useState } from "react"; import { useState } from "react";
import { import {
ActivityIndicator,
Modal, Modal,
Pressable, Pressable,
ScrollView, ScrollView,
@@ -11,8 +12,9 @@ import {
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useSession } from "@/contexts/AuthContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account"; import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode"; import { formatServerHost } from "@/lib/server-mode";
@@ -34,15 +36,19 @@ function displayName(name: string, email: string) {
/** Header control to switch signed-in accounts or add another. */ /** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() { export function AccountSwitcher() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession(); const { data: session } = useSession();
const { const {
accounts, accounts,
activeAccount, activeAccount,
activeAccountId, activeAccountId,
switchAccount, switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount, clearActiveAccount,
} = useAccounts(); } = useAccounts();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const label = displayName( const label = displayName(
activeAccount?.name ?? session?.user.name ?? "", activeAccount?.name ?? session?.user.name ?? "",
@@ -67,6 +73,32 @@ export function AccountSwitcher() {
await switchAccount(accountId); await switchAccount(accountId);
} }
async function handleRefresh() {
setRefreshing(true);
try {
await refreshAccounts();
} finally {
setRefreshing(false);
}
}
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
if (result.remainingCount === 0) {
setOpen(false);
}
await finishAccountRemoval({
result,
clearActiveAccount,
signOut: () => authClient.signOut(),
});
},
);
}
return ( return (
<> <>
<Pressable <Pressable
@@ -100,10 +132,26 @@ export function AccountSwitcher() {
> >
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}> <View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text> <Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
<View style={styles.sheetActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Refresh accounts"
disabled={refreshing}
hitSlop={8}
onPress={() => void handleRefresh()}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
{refreshing ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Ionicons name="refresh" size={20} color={colors.primary} />
)}
</Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}> <Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text> <Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable> </Pressable>
</View> </View>
</View>
<ScrollView keyboardShouldPersistTaps="handled"> <ScrollView keyboardShouldPersistTaps="handled">
{accounts.map((account) => { {accounts.map((account) => {
@@ -138,9 +186,22 @@ export function AccountSwitcher() {
{formatServerHost(account.instanceUrl)} {formatServerHost(account.instanceUrl)}
</Text> </Text>
</View> </View>
<View style={styles.accountActions}>
{isActive ? ( {isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} /> <Ionicons name="checkmark" size={18} color={colors.primary} />
) : null} ) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemove(account.id, account.name || account.email)
}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</Pressable> </Pressable>
); );
})} })}
@@ -218,6 +279,17 @@ const styles = StyleSheet.create({
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 16, fontSize: 16,
}, },
sheetActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
iconButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 28,
minHeight: 28,
},
done: { done: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 16, fontSize: 16,
@@ -234,6 +306,11 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
gap: 2, gap: 2,
}, },
accountActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountName: { accountName: {
fontFamily: fonts.bodyMedium, fontFamily: fonts.bodyMedium,
fontSize: 15, fontSize: 15,
+65
View File
@@ -0,0 +1,65 @@
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return;
router.push(`/(app)/invoices/${invoiceId}`);
}
/** Schedules local iOS/Android notifications for draft invoice send reminders. */
export function InvoiceReminderSync() {
const utils = api.useUtils();
const invoicesQuery = api.invoices.getAll.useQuery(
{ status: "draft" },
{ staleTime: 60_000 },
);
const wasBackgrounded = useRef(false);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data);
}, [invoicesQuery.data]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
});
return () => subscription.remove();
}, [utils.invoices.getAll]);
useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
},
);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return;
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
});
return () => responseSubscription.remove();
}, []);
return null;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useSession } from "@/contexts/AuthContext";
/** Refetch auth session when the app returns to the foreground. */
export function SessionSync() {
const { refetch } = useSession();
const wasBackgrounded = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void refetch();
});
return () => subscription.remove();
}, [refetch]);
return null;
}
+141
View File
@@ -0,0 +1,141 @@
import * as Linking from "expo-linking";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { Alert, Platform } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import { DEFAULT_CLOCK_DESCRIPTION, resolveClockDescription, resolveEffectiveHourlyRate } from "@/lib/time-clock";
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
import { parseShortcutUrl, type ParsedShortcut } from "@/lib/shortcuts";
import { api } from "@/lib/trpc";
/**
* Handles deep links from the Shortcuts app, Siri, and Live Activities.
* Mounted inside the authenticated app shell.
*/
export function ShortcutHandler() {
const { activeAccountId } = useAccounts();
const { isLocked } = useAppLock();
const url = Linking.useURL();
const utils = api.useUtils();
const clientsQuery = api.clients.getAll.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery();
const processedRef = useRef<string | null>(null);
const pendingRef = useRef<ParsedShortcut | null>(null);
const clockIn = api.timeEntries.clockIn.useMutation();
const clockOut = api.timeEntries.clockOut.useMutation();
useEffect(() => {
void Linking.getInitialURL().then((initialUrl) => {
const parsed = parseShortcutUrl(initialUrl);
if (parsed) pendingRef.current = parsed;
});
}, []);
useEffect(() => {
const parsed = parseShortcutUrl(url);
if (parsed) pendingRef.current = parsed;
}, [url]);
useEffect(() => {
if (isLocked || !activeAccountId || clientsQuery.isLoading || runningQuery.isLoading) return;
const pending = pendingRef.current;
if (!pending) return;
const key = JSON.stringify(pending);
if (processedRef.current === key) return;
processedRef.current = key;
pendingRef.current = null;
void (async () => {
if (pending.action === "open-timer") {
router.push("/(app)/timer");
return;
}
if (pending.action === "clock-out") {
if (!runningQuery.data) {
router.push("/(app)/timer");
if (Platform.OS === "ios") {
Alert.alert("No timer running", "There is nothing to clock out.");
}
return;
}
try {
await clockOut.mutateAsync({});
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
router.push("/(app)/timer");
} catch (err) {
Alert.alert(
"Clock out failed",
err instanceof Error ? err.message : "Could not stop the timer.",
);
router.push("/(app)/timer");
}
return;
}
if (pending.action === "clock-in") {
if (runningQuery.data) {
router.push("/(app)/timer");
if (Platform.OS === "ios") {
Alert.alert("Timer already running", "Stop the current timer before clocking in again.");
}
return;
}
const clientId =
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
if (!clientId) {
router.push("/(app)/timer");
Alert.alert(
"Choose a client",
"Open the time clock and pick a client once — shortcuts will use it next time.",
);
return;
}
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
try {
await clockIn.mutateAsync({
clientId,
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
rate: rate ?? undefined,
});
await utils.timeEntries.getRunning.invalidate();
router.push("/(app)/timer");
} catch (err) {
Alert.alert(
"Clock in failed",
err instanceof Error ? err.message : "Could not start the timer.",
);
router.push("/(app)/timer");
}
}
})();
}, [
activeAccountId,
clockIn,
clockOut,
clientsQuery.data,
clientsQuery.isLoading,
isLocked,
runningQuery.data,
runningQuery.isLoading,
utils,
]);
return null;
}
+8 -3
View File
@@ -23,6 +23,7 @@ type LineItemEditorProps = {
onToggle: () => void; onToggle: () => void;
onChange: (patch: Partial<EditableLineItem>) => void; onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void; onRemove: () => void;
readOnly?: boolean;
}; };
export function LineItemEditor({ export function LineItemEditor({
@@ -32,6 +33,7 @@ export function LineItemEditor({
onToggle, onToggle,
onChange, onChange,
onRemove, onRemove,
readOnly = false,
}: LineItemEditorProps) { }: LineItemEditorProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const hours = Number(item.hours) || 0; const hours = Number(item.hours) || 0;
@@ -39,12 +41,13 @@ export function LineItemEditor({
const amount = hours * rate; const amount = hours * rate;
const borderStyle = { borderTopColor: colors.border }; const borderStyle = { borderTopColor: colors.border };
if (!expanded) { if (!expanded || readOnly) {
return ( return (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
onPress={onToggle} onPress={readOnly ? undefined : onToggle}
style={({ pressed }) => [styles.row, borderStyle, pressed && styles.rowPressed]} disabled={readOnly}
style={({ pressed }) => [styles.row, borderStyle, pressed && !readOnly && styles.rowPressed]}
> >
<View style={styles.rowMain}> <View style={styles.rowMain}>
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}> <Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
@@ -57,7 +60,9 @@ export function LineItemEditor({
<Text style={[styles.rowAmount, { color: colors.foreground }]}> <Text style={[styles.rowAmount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)} {formatCurrency(amount, currency)}
</Text> </Text>
{!readOnly ? (
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} /> <Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
) : null}
</Pressable> </Pressable>
); );
} }
+523 -121
View File
@@ -1,7 +1,16 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Alert, Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; import {
Alert,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface"; import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
@@ -9,29 +18,60 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField"; import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDateTime } from "@/lib/format"; import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation"; import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import {
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { import {
endTimeClockLiveActivity, endTimeClockLiveActivity,
syncTimeClockLiveActivity, syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity"; } from "@/lib/time-clock-live-activity";
import { DEFAULT_CLOCK_DESCRIPTION, describeClockOutOutcome, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock"; import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed"; import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export type TimeClockPanelProps = { export type TimeClockPanelProps = {
defaultClientId?: string; defaultClientId?: string;
defaultInvoiceId?: string; defaultInvoiceId?: string;
/** Hides the in-panel title card when idle (tab screen already has PageHeader). */
compact?: boolean; compact?: boolean;
header?: ReactNode; header?: ReactNode;
}; };
type ClientRow = {
id: string;
name: string;
defaultHourlyRate: number | null;
currency?: string;
};
type StartMode = "now" | "at" | "ago";
const AGO_PRESETS = [
{ label: "15m", minutes: 15 },
{ label: "30m", minutes: 30 },
{ label: "1h", minutes: 60 },
{ label: "2h", minutes: 120 },
{ label: "4h", minutes: 240 },
] as const;
function clientRateText(client: ClientRow | undefined): string {
return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
}
export function TimeClockPanel({ export function TimeClockPanel({
defaultClientId = "", defaultClientId = "",
defaultInvoiceId = "", defaultInvoiceId = "",
@@ -40,6 +80,7 @@ export function TimeClockPanel({
}: TimeClockPanelProps) { }: TimeClockPanelProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles); const styles = useThemedStyles(createTimeClockStyles);
const { activeAccountId } = useAccounts();
const utils = api.useUtils(); const utils = api.useUtils();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, { const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000, refetchInterval: 30_000,
@@ -48,9 +89,18 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId); const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId); const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState(DEFAULT_CLOCK_DESCRIPTION); const [description, setDescription] = useState("");
const [rateText, setRateText] = useState(""); const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date()); const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
const [agoMinutes, setAgoMinutes] = useState(60);
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data; const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt); const elapsed = useRunningElapsed(running?.startedAt);
@@ -68,7 +118,20 @@ export function TimeClockPanel({
return d; return d;
}, []); }, []);
const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart }); const entriesQuery = api.timeEntries.getAll.useQuery();
const recentClientIds = useMemo(() => {
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of entriesQuery.data ?? []) {
if (entry.clientId && !seen.has(entry.clientId)) {
seen.add(entry.clientId);
ids.push(entry.clientId);
if (ids.length >= 2) break;
}
}
return ids;
}, [entriesQuery.data]);
const clockIn = api.timeEntries.clockIn.useMutation({ const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => { onSuccess: async () => {
@@ -91,6 +154,9 @@ export function TimeClockPanel({
const clockOut = api.timeEntries.clockOut.useMutation({ const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => { onSuccess: async (data) => {
await endTimeClockLiveActivity(); await endTimeClockLiveActivity();
if (running?.clientId && activeAccountId) {
await setLastTimeClockClientId(activeAccountId, running.clientId);
}
const message = describeClockOutOutcome({ const message = describeClockOutOutcome({
outcome: data.outcome, outcome: data.outcome,
hours: data.hours, hours: data.hours,
@@ -108,25 +174,78 @@ export function TimeClockPanel({
utils.invoices.getBillable.invalidate(), utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(), utils.dashboard.getStats.invalidate(),
]); ]);
setDescription(DEFAULT_CLOCK_DESCRIPTION); setDescription("");
}, },
}); });
useEffect(() => {
if (!activeAccountId) {
setPrefsLoaded(true);
return;
}
setPrefsLoaded(false);
void getLastTimeClockClientId(activeAccountId).then((id) => {
setStoredLastClientId(id);
setPrefsLoaded(true);
});
}, [activeAccountId]);
useEffect(() => { useEffect(() => {
if (!running) return; if (!running) return;
setClientId(running.clientId ?? ""); setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? ""); setInvoiceId(running.invoiceId ?? "");
setDescription(running.description?.trim() || DEFAULT_CLOCK_DESCRIPTION); setDescription(running.description?.trim() ?? "");
setRateText(running.rate != null ? String(running.rate) : ""); setRateText(running.rate != null ? String(running.rate) : "");
}, [running]); }, [running]);
useEffect(() => { useEffect(() => {
if (running || !clientId || rateText) return; if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId); const client = clients.find((c) => c.id === clientId);
if (client?.defaultHourlyRate) { if (!client?.defaultHourlyRate) return;
setRateText(String(client.defaultHourlyRate)); setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
useEffect(() => {
if (running || defaultClientId || initialClientResolved) return;
if (!prefsLoaded || clients.length === 0) return;
const preferredId =
storedLastClientId && clients.some((client) => client.id === storedLastClientId)
? storedLastClientId
: recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
if (preferredId) {
const client = clients.find((c) => c.id === preferredId);
setClientId(preferredId);
setRateText(clientRateText(client));
} }
}, [clientId, clients, rateText, running]);
setInitialClientResolved(true);
}, [
clients,
defaultClientId,
initialClientResolved,
prefsLoaded,
recentClientIds,
running,
storedLastClientId,
]);
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
const ids: string[] = [];
const add = (id: string | null | undefined) => {
if (!id || ids.includes(id)) return;
if (!clients.some((client) => client.id === id)) return;
ids.push(id);
};
add(storedLastClientId);
for (const id of recentClientIds) add(id);
setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
useEffect(() => { useEffect(() => {
if (!running) { if (!running) {
@@ -138,10 +257,7 @@ export function TimeClockPanel({
const seconds = Math.floor( const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000, (Date.now() - new Date(running.startedAt).getTime()) / 1000,
); );
void syncTimeClockLiveActivity( void syncTimeClockLiveActivity({ ...running, description }, seconds);
{ ...running, description },
seconds,
);
}; };
sync(); sync();
@@ -149,49 +265,137 @@ export function TimeClockPanel({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [running, description]); }, [running, description]);
const rate = parseFloat(rateText) || 0; const selectedClient = clients.find((client) => client.id === clientId);
const displayRate = running ? (running.rate ?? 0) : rate; const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
rateText,
selectedClient?.defaultHourlyRate,
);
const displayRate = running
? (running.rate ?? effectiveRate ?? 0)
: (effectiveRate ?? 0);
const clientOptions = useMemo( const featuredClients = useMemo(
() => clients.map((client) => ({ label: client.name, value: client.id })), () =>
[clients], featuredClientIds
.map((id) => clients.find((client) => client.id === id))
.filter((client) => client != null),
[clients, featuredClientIds],
); );
const invoiceOptions = useMemo( const moreClients = useMemo(() => {
() => [ const featuredIds = new Set(featuredClientIds);
{ label: "No invoice — save entry only", value: "" }, return clients
...billableInvoices.map((invoice) => ({ .filter((client) => !featuredIds.has(client.id))
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber} (${invoice.status})`, .sort((a, b) => a.name.localeCompare(b.name));
value: invoice.id, }, [clients, featuredClientIds]);
})),
], const resolvedStartAt = useMemo(() => {
[billableInvoices], if (startMode === "now") return new Date();
); if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
return startedAt;
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => { const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string } = {}; const next: { clientId?: string; rate?: string; start?: string } = {};
if (!clientId) next.clientId = "Select a client"; if (!clientId) next.clientId = "Choose a client to start";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) { if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate"; next.rate = "Enter a valid hourly rate";
} }
if (startMode === "ago" && agoMinutes <= 0) {
next.start = "Enter how long ago you started";
}
return next; return next;
}, [clientId, rateText]); }, [agoMinutes, clientId, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0; const canClockIn = Object.keys(clockInErrors).length === 0;
const optionsSummary = useMemo(() => {
const rate =
effectiveRate ??
(selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
return `${rateLabel} · ${startLabel}`;
}, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
const todayEntries = useMemo(
() =>
(entriesQuery.data ?? []).filter(
(entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
),
[entriesQuery.data, todayStart],
);
async function persistClientChoice(nextClientId: string, syncState = false) {
if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId);
if (syncState) setStoredLastClientId(nextClientId);
}
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (!featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
void persistClientChoice(nextClientId);
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
if (mode === "now") {
setStartedAt(new Date());
return;
}
if (mode === "ago") {
setStartedAt(startedAtFromMinutesAgo(agoMinutes));
return;
}
setStartedAt((current) =>
Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
);
}
function selectAgoPreset(minutes: number) {
setStartMode("ago");
setAgoMinutes(minutes);
setAgoMinutesText(String(minutes));
setStartedAt(startedAtFromMinutesAgo(minutes));
}
function handleAgoMinutesChange(text: string) {
setAgoMinutesText(text);
const parsed = Number(text);
if (!Number.isNaN(parsed) && parsed > 0) {
setAgoMinutes(parsed);
setStartedAt(startedAtFromMinutesAgo(parsed));
}
}
async function handleClockIn() { async function handleClockIn() {
if (!canClockIn) return; if (!canClockIn) {
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
return;
}
try { try {
const backdated = const backdated =
Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined; startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({ await clockIn.mutateAsync({
description: resolveClockDescription(description), description: resolveClockDescription(description),
clientId: clientId || "", clientId: clientId || "",
invoiceId: invoiceId || undefined, invoiceId: invoiceId || undefined,
rate: rate || undefined, rate: effectiveRate ?? undefined,
startedAt: backdated, startedAt: backdated,
}); });
await persistClientChoice(clientId);
setStartMode("now");
setStartedAt(new Date()); setStartedAt(new Date());
setAgoMinutes(60);
setAgoMinutesText("60");
} catch (err) { } catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again"); Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
} }
@@ -214,9 +418,8 @@ export function TimeClockPanel({
try { try {
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" }); await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
const client = clients.find((c) => c.id === nextClientId); const client = clients.find((c) => c.id === nextClientId);
if (client?.defaultHourlyRate != null) { setRateText(clientRateText(client));
setRateText(String(client.defaultHourlyRate)); await persistClientChoice(nextClientId);
}
} catch { } catch {
setClientId(running.clientId ?? ""); setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? ""); setInvoiceId(running.invoiceId ?? "");
@@ -238,7 +441,6 @@ export function TimeClockPanel({
return <LoadingScreen message="Loading time clock…" />; return <LoadingScreen message="Loading time clock…" />;
} }
const todayEntries = (todayQuery.data ?? []).filter((entry) => entry.endedAt);
const runningMeta = [ const runningMeta = [
running?.client?.name ?? (running ? "No client" : null), running?.client?.name ?? (running ? "No client" : null),
running?.invoice running?.invoice
@@ -249,6 +451,23 @@ export function TimeClockPanel({
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningClientChange(client.id);
else selectClient(client.id);
}}
/>
);
}
return ( return (
<TabScrollView <TabScrollView
style={styles.scroll} style={styles.scroll}
@@ -260,7 +479,7 @@ export function TimeClockPanel({
void runningQuery.refetch(); void runningQuery.refetch();
void clientsQuery.refetch(); void clientsQuery.refetch();
void billableQuery.refetch(); void billableQuery.refetch();
void todayQuery.refetch(); void entriesQuery.refetch();
}} }}
tintColor={colors.primary} tintColor={colors.primary}
/> />
@@ -276,107 +495,204 @@ export function TimeClockPanel({
<Text style={styles.heroLabel}>Running</Text> <Text style={styles.heroLabel}>Running</Text>
</View> </View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text> <Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>
{resolveClockDescription(description)}
</Text>
<Text style={styles.runningMeta}> <Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)} Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""} {runningMeta ? ` · ${runningMeta}` : ""}
</Text> </Text>
</> </>
) : ( ) : (
<Text style={styles.idleHint}>Track billable time and link it to invoices.</Text> <Text style={styles.idleHint}>
Choose a client and clock in. A draft invoice is created automatically if needed.
</Text>
)} )}
</View> </View>
</GlassSurface> </GlassSurface>
) : null} ) : null}
{running ? ( <GlassSurface style={styles.setupCard}>
<Card style={styles.formCard}>
<View style={styles.formFields}>
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
disabled={updateRunning.isPending}
onValueChange={(next) => void handleRunningClientChange(next)}
/>
<SelectField
label="Invoice"
placeholder="No invoice — save entry only"
value={invoiceId}
options={invoiceOptions}
disabled={updateRunning.isPending}
onValueChange={(next) => void handleRunningInvoiceChange(next)}
/>
<Input <Input
label="Description" label="Title"
value={description} value={description}
onChangeText={setDescription} onChangeText={setDescription}
placeholder={DEFAULT_CLOCK_DESCRIPTION} placeholder="What are you working on?"
returnKeyType="done"
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
/> />
</View>
</Card> <View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
Add a client first to start tracking time.
</Text>
) : ( ) : (
<Card style={styles.formCard}> <>
<View style={styles.formFields}> <View style={styles.chipWrap}>
<SelectField {featuredClients.map((client) => renderClientChip(client))}
label="Client" {moreClients.length > 0 ? (
placeholder="Select client…" <FilterChip
value={clientId} label={clientsExpanded ? "Show less" : "Show more"}
options={clientOptions} active={clientsExpanded}
required onPress={() => {
error={clockInErrors.clientId} if (controlsDisabled) return;
onValueChange={(next) => { setClientsExpanded((open) => !open);
setClientId(next);
setInvoiceId("");
const client = clients.find((c) => c.id === next);
setRateText(
client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
);
}} }}
/> />
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
{clockInErrors.clientId && !running ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View>
<SelectField {clientId ? (
label="Invoice" <View style={styles.setupSection}>
placeholder="No invoice — save entry only" <Text style={styles.sectionLabel}>Invoice</Text>
value={invoiceId} <View style={styles.chipWrap}>
options={invoiceOptions} <FilterChip
disabled={!clientId} label="Entry only"
onValueChange={setInvoiceId} active={!invoiceId}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange("");
else setInvoiceId("");
}}
/> />
{billableInvoices.map((invoice) => {
<Input const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
label="Description" return (
value={description} <FilterChip
onChangeText={setDescription} key={invoice.id}
placeholder={DEFAULT_CLOCK_DESCRIPTION} label={label}
active={invoiceId === invoice.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange(invoice.id);
else setInvoiceId(invoice.id);
}}
/> />
);
})}
</View>
</View>
) : null}
{!running && clientId ? (
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
{!optionsExpanded ? (
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
) : null}
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? (
<View style={styles.optionsBody}>
<Input <Input
label="Hourly rate" label="Hourly rate"
value={rateText} value={rateText}
onChangeText={setRateText} onChangeText={setRateText}
keyboardType="decimal-pad" keyboardType="decimal-pad"
placeholder="Optional" placeholder={
selectedClient?.defaultHourlyRate != null
? String(selectedClient.defaultHourlyRate)
: "0"
}
error={clockInErrors.rate} error={clockInErrors.rate}
/> />
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
<Text style={styles.rateHint}>
Defaults to{" "}
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
</Text>
) : null}
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Now"
active={startMode === "now"}
onPress={() => selectStartMode("now")}
/>
<FilterChip
label="Pick time"
active={startMode === "at"}
onPress={() => selectStartMode("at")}
/>
<FilterChip
label="Time ago"
active={startMode === "ago"}
onPress={() => selectStartMode("ago")}
/>
</View>
{startMode === "at" ? (
<DateTimeField <DateTimeField
label="Started at" label="Started at"
value={startedAt} value={startedAt}
maximumDate={new Date()} maximumDate={new Date()}
onChange={setStartedAt} onChange={(date) => {
setStartedAt(date);
setStartMode("at");
}}
/> />
<Text style={styles.startedHint}> ) : null}
Set an earlier time if you forgot to clock in when you started working.
{startMode === "ago" ? (
<View style={styles.agoBlock}>
<View style={styles.chipWrap}>
{AGO_PRESETS.map((preset) => (
<FilterChip
key={preset.label}
label={preset.label}
active={agoMinutes === preset.minutes}
onPress={() => selectAgoPreset(preset.minutes)}
/>
))}
</View>
<View style={styles.agoCustomRow}>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
Started
</Text>
<TextInput
value={agoMinutesText}
onChangeText={handleAgoMinutesChange}
keyboardType="number-pad"
style={[
styles.agoInput,
{ color: colors.foreground, borderColor: colors.border },
]}
/>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
min ago
</Text> </Text>
</View> </View>
</Card> </View>
)} ) : null}
{clockInErrors.start ? (
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
) : null}
</View>
) : null}
</View>
) : null}
</GlassSurface>
{running ? ( {running ? (
<Button <Button
@@ -389,7 +705,7 @@ export function TimeClockPanel({
<Button <Button
title="Clock in" title="Clock in"
loading={clockIn.isPending} loading={clockIn.isPending}
disabled={!canClockIn} disabled={!canClockIn || clients.length === 0}
onPress={handleClockIn} onPress={handleClockIn}
/> />
)} )}
@@ -477,11 +793,6 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
color: colors.foreground, color: colors.foreground,
fontVariant: ["tabular-nums"], fontVariant: ["tabular-nums"],
}, },
runningTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
runningMeta: { runningMeta: {
fontSize: 13, fontSize: 13,
fontFamily: fonts.body, fontFamily: fonts.body,
@@ -491,20 +802,111 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
color: colors.mutedForeground, color: colors.mutedForeground,
marginTop: spacing.xs, lineHeight: 20,
}, },
startedHint: { setupCard: {
padding: spacing.md,
gap: spacing.lg,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
titleInput: {
minHeight: 44,
textAlignVertical: "center",
},
titleInputPlaceholder: {
textAlign: "center",
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabelInset: {
marginTop: spacing.sm,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
moreClientsWrap: {
paddingTop: spacing.xs,
},
emptyClients: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
fieldError: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.destructive,
},
optionsToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
optionsTogglePressed: {
opacity: 0.7,
},
optionsToggleText: {
flex: 1,
gap: 2,
},
optionsToggleLabel: {
fontSize: 14,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
optionsToggleSummary: {
fontSize: 12, fontSize: 12,
fontFamily: fonts.body, fontFamily: fonts.body,
color: colors.mutedForeground, color: colors.mutedForeground,
lineHeight: 18,
marginTop: -spacing.xs,
}, },
formCard: { optionsChevron: {
gap: 0, fontSize: 20,
lineHeight: 22,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
}, },
formFields: { optionsBody: {
gap: spacing.md, gap: spacing.md,
paddingBottom: spacing.xs,
},
rateHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
agoBlock: {
gap: spacing.sm,
},
agoCustomRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
agoCustomLabel: {
fontSize: 14,
fontFamily: fonts.body,
},
agoInput: {
minWidth: 56,
fontSize: 16,
fontFamily: fonts.bodySemiBold,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 4,
textAlign: "center",
}, },
entryRow: { entryRow: {
flexDirection: "row", flexDirection: "row",
@@ -536,4 +938,4 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
color: colors.foreground, color: colors.foreground,
fontSize: 14, fontSize: 14,
}, },
}); });
+38 -3
View File
@@ -21,7 +21,14 @@ import {
type SavedAccount, type SavedAccount,
} from "@/lib/accounts"; } from "@/lib/accounts";
import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config"; import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url"; import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
export type RemoveAccountResult = {
wasActive: boolean;
remainingCount: number;
};
type AccountsContextValue = { type AccountsContextValue = {
accounts: SavedAccount[]; accounts: SavedAccount[];
@@ -37,7 +44,8 @@ type AccountsContextValue = {
email: string; email: string;
name: string; name: string;
}) => Promise<SavedAccount>; }) => Promise<SavedAccount>;
removeAccount: (accountId: string) => Promise<void>; removeAccount: (accountId: string) => Promise<RemoveAccountResult>;
refreshAccounts: () => Promise<void>;
clearActiveAccount: () => Promise<void>; clearActiveAccount: () => Promise<void>;
}; };
@@ -148,12 +156,17 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
); );
const removeAccount = useCallback( const removeAccount = useCallback(
async (accountId: string) => { async (accountId: string): Promise<RemoveAccountResult> => {
const wasActive = activeAccountId === accountId;
await clearAuthStorage(authStoragePrefix(accountId));
await clearTimeClockPrefsForAccount(accountId);
const nextAccounts = accounts.filter((account) => account.id !== accountId); const nextAccounts = accounts.filter((account) => account.id !== accountId);
setAccounts(nextAccounts); setAccounts(nextAccounts);
await saveAccounts(nextAccounts); await saveAccounts(nextAccounts);
if (activeAccountId === accountId) { if (wasActive) {
const fallback = nextAccounts[0] ?? null; const fallback = nextAccounts[0] ?? null;
await saveActiveAccountId(fallback?.id ?? null); await saveActiveAccountId(fallback?.id ?? null);
setActiveAccountId(fallback?.id ?? null); setActiveAccountId(fallback?.id ?? null);
@@ -162,10 +175,30 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
setApiUrl(fallback.instanceUrl); setApiUrl(fallback.instanceUrl);
} }
} }
return { wasActive, remainingCount: nextAccounts.length };
}, },
[accounts, activeAccountId], [accounts, activeAccountId],
); );
const refreshAccounts = useCallback(async () => {
const stored = await loadAccounts();
const refreshed = await Promise.all(
stored.map(async (account) => {
const user = await readStoredSessionUser(authStoragePrefix(account.id));
if (!user?.name && !user?.email) return account;
return {
...account,
name: user.name?.trim() || account.name,
email: user.email?.trim() || account.email,
};
}),
);
setAccounts(refreshed);
await saveAccounts(refreshed);
}, []);
const clearActiveAccount = useCallback(async () => { const clearActiveAccount = useCallback(async () => {
await saveActiveAccountId(null); await saveActiveAccountId(null);
setActiveAccountId(null); setActiveAccountId(null);
@@ -184,6 +217,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
switchAccount, switchAccount,
registerAccount, registerAccount,
removeAccount, removeAccount,
refreshAccounts,
clearActiveAccount, clearActiveAccount,
}), }),
[ [
@@ -195,6 +229,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
switchAccount, switchAccount,
registerAccount, registerAccount,
removeAccount, removeAccount,
refreshAccounts,
clearActiveAccount, clearActiveAccount,
], ],
); );
+2
View File
@@ -19,6 +19,8 @@ function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient
scheme: "beenvoice", scheme: "beenvoice",
storagePrefix, storagePrefix,
storage: SecureStore, storage: SecureStore,
// Avoid showing a cached session when cookies have already expired.
disableCache: true,
}), }),
genericOAuthClient(), genericOAuthClient(),
], ],
+43
View File
@@ -0,0 +1,43 @@
import { router } from "expo-router";
import { Alert } from "react-native";
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
type FinishAccountRemovalInput = {
result: RemoveAccountResult;
clearActiveAccount: () => Promise<void>;
signOut: () => Promise<unknown>;
};
/** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({
result,
clearActiveAccount,
signOut,
}: FinishAccountRemovalInput): Promise<void> {
if (result.remainingCount > 0) return;
await signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}
export function confirmRemoveAccount(
label: string,
onRemove: () => Promise<RemoveAccountResult>,
onFinished: (result: RemoveAccountResult) => Promise<void>,
) {
Alert.alert("Remove account", `Remove ${label} from this device?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => {
void (async () => {
const result = await onRemove();
await onFinished(result);
})();
},
},
]);
}
+35
View File
@@ -12,6 +12,20 @@ function storageKeyForPrefix(prefix: string, suffix: (typeof AUTH_STORAGE_SUFFIX
return normalizeSecureStoreKey(`${prefix}${suffix}`); return normalizeSecureStoreKey(`${prefix}${suffix}`);
} }
async function readSecureStoreValue(key: string): Promise<string | null> {
const value = await SecureStore.getItemAsync(key);
if (value == null) return null;
if (!value.startsWith(CHUNK_MARKER)) return value;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return null;
const chunks = await Promise.all(
Array.from({ length: count }, (_, index) => SecureStore.getItemAsync(`${key}.${index}`)),
);
return chunks.map((chunk) => chunk ?? "").join("");
}
async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<void> { async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<void> {
const value = await SecureStore.getItemAsync(fromKey); const value = await SecureStore.getItemAsync(fromKey);
if (value == null) return; if (value == null) return;
@@ -31,6 +45,27 @@ async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<voi
} }
} }
export async function readStoredSessionUser(prefix: string): Promise<{
id?: string;
name?: string;
email?: string;
} | null> {
const raw = await readSecureStoreValue(storageKeyForPrefix(prefix, "_session_data"));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as {
user?: { id?: string; name?: string; email?: string };
session?: { user?: { id?: string; name?: string; email?: string } };
};
const user = parsed.user ?? parsed.session?.user;
if (!user) return null;
return { id: user.id, name: user.name, email: user.email };
} catch {
return null;
}
}
export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise<void> { export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise<void> {
if (fromPrefix === toPrefix) return; if (fromPrefix === toPrefix) return;
+151
View File
@@ -0,0 +1,151 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
const REMINDER_PREFIX = "invoice-send-reminder:";
const FIRED_PREFIX = "invoice-reminder-fired:";
export type InvoiceSendReminderSource = {
id: string;
status: string;
invoiceNumber: string;
invoicePrefix: string | null;
sendReminderAt: Date | string | null | undefined;
client?: { name: string } | null;
};
export function invoiceSendReminderNotificationId(invoiceId: string) {
return `${REMINDER_PREFIX}${invoiceId}`;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
async function ensureAndroidChannel() {
if (Platform.OS !== "android") return;
await Notifications.setNotificationChannelAsync("invoice-reminders", {
name: "Invoice reminders",
importance: Notifications.AndroidImportance.HIGH,
sound: "default",
vibrationPattern: [0, 250, 250, 250],
});
}
export async function ensureNotificationPermissions(): Promise<boolean> {
if (Platform.OS === "web") return false;
await ensureAndroidChannel();
const { status: existing } = await Notifications.getPermissionsAsync();
if (existing === "granted") return true;
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: true,
},
});
return status === "granted";
}
function reminderContent(invoice: InvoiceSendReminderSource): Notifications.NotificationContentInput {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
const clientName = invoice.client?.name ?? "your client";
return {
title: "Time to send invoice",
body: `${label} for ${clientName} is ready to send.`,
data: {
invoiceId: invoice.id,
type: "invoice-send-reminder",
},
sound: true,
...(Platform.OS === "android" ? { channelId: "invoice-reminders" } : {}),
};
}
export async function syncInvoiceSendReminders(invoices: InvoiceSendReminderSource[]) {
if (Platform.OS === "web") return;
const granted = await ensureNotificationPermissions();
if (!granted) return;
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
const ourScheduled = new Set(
scheduled
.map((entry) => entry.identifier)
.filter((id): id is string => Boolean(id?.startsWith(REMINDER_PREFIX))),
);
const wanted = new Set<string>();
const now = Date.now();
for (const invoice of invoices) {
if (invoice.status !== "draft" || !invoice.sendReminderAt) continue;
const notificationId = invoiceSendReminderNotificationId(invoice.id);
wanted.add(notificationId);
const reminderAt = new Date(invoice.sendReminderAt);
const reminderMs = reminderAt.getTime();
if (Number.isNaN(reminderMs)) continue;
const firedKey = `${FIRED_PREFIX}${invoice.id}`;
const firedAt = await AsyncStorage.getItem(firedKey);
const content = reminderContent(invoice);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
if (reminderMs <= now) {
const alreadyFiredForThisDate = firedAt === reminderAt.toISOString();
if (alreadyFiredForThisDate) continue;
await Notifications.scheduleNotificationAsync({
identifier: notificationId,
content,
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 2,
},
});
await AsyncStorage.setItem(firedKey, reminderAt.toISOString());
continue;
}
if (firedAt && firedAt !== reminderAt.toISOString()) {
await AsyncStorage.removeItem(firedKey);
}
await Notifications.scheduleNotificationAsync({
identifier: notificationId,
content,
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: reminderAt,
},
});
}
for (const notificationId of ourScheduled) {
if (wanted.has(notificationId)) continue;
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
const invoiceId = notificationId.slice(REMINDER_PREFIX.length);
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
}
}
export async function cancelInvoiceSendReminder(invoiceId: string) {
if (Platform.OS === "web") return;
const notificationId = invoiceSendReminderNotificationId(invoiceId);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
}
+25
View File
@@ -0,0 +1,25 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { isUnauthorizedError } from "@/lib/trpc-errors";
export function createAppQueryClient(onUnauthorized: () => void) {
const handleError = (error: unknown) => {
if (isUnauthorizedError(error)) {
onUnauthorized();
}
};
return new QueryClient({
queryCache: new QueryCache({ onError: handleError }),
mutationCache: new MutationCache({ onError: handleError }),
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (failureCount, error) => {
if (isUnauthorizedError(error)) return false;
return failureCount < 1;
},
},
},
});
}
+53
View File
@@ -0,0 +1,53 @@
import * as Linking from "expo-linking";
export type ShortcutAction = "clock-in" | "clock-out" | "open-timer";
export type ParsedShortcut = {
action: ShortcutAction;
title: string;
clientId: string;
};
function queryParam(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
}
/** Parse `beenvoice://shortcuts/clock-in` and related URLs from Shortcuts / Siri. */
export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut | null {
if (!url) return null;
const parsed = Linking.parse(url);
if (parsed.scheme !== "beenvoice") return null;
const path = (parsed.path ?? "").replace(/^\/+/, "");
const host = parsed.hostname ?? "";
if (path === "timer" || host === "timer") {
return { action: "open-timer", title: "", clientId: "" };
}
let shortcutAction: string | null = null;
if (host === "shortcuts" && path) {
shortcutAction = path;
} else {
const match = path.match(/^shortcuts\/(clock-in|clock-out)$/);
shortcutAction = match?.[1] ?? null;
}
if (shortcutAction === "clock-in" || shortcutAction === "clock-out") {
return {
action: shortcutAction,
title: queryParam(parsed.queryParams?.title),
clientId: queryParam(parsed.queryParams?.clientId),
};
}
return null;
}
export const SHORTCUT_URLS = {
timer: "beenvoice://timer",
clockIn: "beenvoice://shortcuts/clock-in",
clockOut: "beenvoice://shortcuts/clock-out",
} as const;
+21
View File
@@ -0,0 +1,21 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
function storageKey(accountId: string) {
return `beenvoice:time-clock:last-client:${accountId}`;
}
export async function getLastTimeClockClientId(accountId: string): Promise<string | null> {
return AsyncStorage.getItem(storageKey(accountId));
}
export async function setLastTimeClockClientId(
accountId: string,
clientId: string,
): Promise<void> {
if (!clientId) return;
await AsyncStorage.setItem(storageKey(accountId), clientId);
}
export async function clearTimeClockPrefsForAccount(accountId: string): Promise<void> {
await AsyncStorage.removeItem(storageKey(accountId));
}
+14
View File
@@ -25,6 +25,20 @@ export function formatElapsedHoursMinutes(seconds: number): string {
return `${h}:${String(m).padStart(2, "0")}`; return `${h}:${String(m).padStart(2, "0")}`;
} }
export function resolveEffectiveHourlyRate(
rateText: string,
clientDefaultRate?: number | null,
): number | null {
const parsed = rateText.trim() ? Number(rateText) : null;
if (parsed != null && !Number.isNaN(parsed) && parsed >= 0) return parsed;
if (clientDefaultRate != null && clientDefaultRate >= 0) return clientDefaultRate;
return null;
}
export function startedAtFromMinutesAgo(minutes: number): Date {
return new Date(Date.now() - minutes * 60_000);
}
export function describeClockOutOutcome(input: { export function describeClockOutOutcome(input: {
outcome: ClockOutOutcome; outcome: ClockOutOutcome;
hours: number; hours: number;
+8
View File
@@ -0,0 +1,8 @@
import { TRPCClientError } from "@trpc/client";
export function isUnauthorizedError(error: unknown): boolean {
return (
error instanceof TRPCClientError &&
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
);
}
+23 -16
View File
@@ -1,28 +1,35 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query"; import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react"; import { useCallback, useRef, useState, type ReactNode } from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { useAuthClient } from "@/contexts/AuthContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { createAppQueryClient } from "@/lib/query-client";
import type { AppRouter } from "beenvoice/server/api/root"; import type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact<AppRouter>(); export const api = createTRPCReact<AppRouter>();
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
}
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) { export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
const authClient = useAuthClient(); const authClient = useAuthClient();
const [queryClient] = useState(createQueryClient); const { refetch } = useSession();
const handleUnauthorized = useCallback(async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
await authClient.signOut();
await refetch();
}
}, [authClient, refetch]);
const onUnauthorizedRef = useRef(handleUnauthorized);
onUnauthorizedRef.current = handleUnauthorized;
const [queryClient] = useState(() =>
createAppQueryClient(() => {
void onUnauthorizedRef.current();
}),
);
const [trpcClient] = useState(() => const [trpcClient] = useState(() =>
api.createClient({ api.createClient({
links: [ links: [
@@ -42,7 +49,7 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
return ( return (
<api.Provider client={trpcClient} queryClient={queryClient}> <api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> {children}
</api.Provider> </api.Provider>
); );
} }
+2
View File
@@ -17,6 +17,7 @@
"better-auth": "^1.6.19", "better-auth": "^1.6.19",
"expo": "~56.0.12", "expo": "~56.0.12",
"expo-blur": "~56.0.3", "expo-blur": "~56.0.3",
"expo-build-properties": "^56.0.19",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~56.0.20",
"expo-font": "~56.0.7", "expo-font": "~56.0.7",
@@ -25,6 +26,7 @@
"expo-linking": "~56.0.14", "expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4", "expo-local-authentication": "~56.0.4",
"expo-network": "^56.0.5", "expo-network": "^56.0.5",
"expo-notifications": "^56.0.18",
"expo-router": "~56.2.11", "expo-router": "~56.2.11",
"expo-secure-store": "^56.0.4", "expo-secure-store": "^56.0.4",
"expo-splash-screen": "~56.0.10", "expo-splash-screen": "~56.0.10",
@@ -0,0 +1,36 @@
import AppIntents
@available(iOS 16.0, *)
struct BeenVoiceShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
[
AppShortcut(
intent: ClockInIntent(),
phrases: [
"Clock in with \(.applicationName)",
"Start timer in \(.applicationName)",
],
shortTitle: "Clock In",
systemImageName: "play.circle.fill"
),
AppShortcut(
intent: ClockOutIntent(),
phrases: [
"Clock out in \(.applicationName)",
"Stop timer in \(.applicationName)",
],
shortTitle: "Clock Out",
systemImageName: "stop.circle.fill"
),
AppShortcut(
intent: OpenTimerIntent(),
phrases: [
"Open time clock in \(.applicationName)",
"Open timer in \(.applicationName)",
],
shortTitle: "Time Clock",
systemImageName: "timer"
),
]
}
}
+33
View File
@@ -0,0 +1,33 @@
import AppIntents
import UIKit
@available(iOS 16.0, *)
struct ClockInIntent: AppIntent {
static var title: LocalizedStringResource = "Clock In"
static var description = IntentDescription("Start the beenvoice time clock with your last client.")
static var openAppWhenRun: Bool = false
@Parameter(title: "Title")
var title: String?
func perform() async throws -> some IntentResult {
var components = URLComponents()
components.scheme = "beenvoice"
components.host = "shortcuts"
components.path = "/clock-in"
if let title, !title.isEmpty {
components.queryItems = [URLQueryItem(name: "title", value: title)]
}
guard let url = components.url else {
return .result()
}
await MainActor.run {
UIApplication.shared.open(url)
}
return .result()
}
}
+21
View File
@@ -0,0 +1,21 @@
import AppIntents
import UIKit
@available(iOS 16.0, *)
struct ClockOutIntent: AppIntent {
static var title: LocalizedStringResource = "Clock Out"
static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult {
guard let url = URL(string: "beenvoice://shortcuts/clock-out") else {
return .result()
}
await MainActor.run {
UIApplication.shared.open(url)
}
return .result()
}
}
+21
View File
@@ -0,0 +1,21 @@
import AppIntents
import UIKit
@available(iOS 16.0, *)
struct OpenTimerIntent: AppIntent {
static var title: LocalizedStringResource = "Open Time Clock"
static var description = IntentDescription("Open the beenvoice time clock.")
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult {
guard let url = URL(string: "beenvoice://timer") else {
return .result()
}
await MainActor.run {
UIApplication.shared.open(url)
}
return .result()
}
}
+76
View File
@@ -0,0 +1,76 @@
// @ts-check
const {
withDangerousMod,
withXcodeProject,
IOSConfig,
} = require("@expo/config-plugins");
const fs = require("fs");
const path = require("path");
const SWIFT_FILES = [
"ClockInIntent.swift",
"ClockOutIntent.swift",
"OpenTimerIntent.swift",
"BeenVoiceShortcuts.swift",
];
/** @type {import('@expo/config-plugins').ConfigPlugin} */
function withAppIntents(config) {
const appIntentsSource = path.join(
config._internal?.projectRoot ?? process.cwd(),
"plugins",
"app-intents",
);
config = withDangerousMod(config, [
"ios",
async (config) => {
const platformRoot = config.modRequest.platformProjectRoot;
const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
const targetDir = path.join(platformRoot, projectName);
fs.mkdirSync(targetDir, { recursive: true });
for (const file of SWIFT_FILES) {
fs.copyFileSync(path.join(appIntentsSource, file), path.join(targetDir, file));
}
return config;
},
]);
return withXcodeProject(config, (config) => {
const project = config.modResults;
const platformRoot = config.modRequest.platformProjectRoot;
const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
for (const file of SWIFT_FILES) {
const filepath = `${projectName}/${file}`;
const absolutePath = path.join(platformRoot, filepath);
if (!fs.existsSync(absolutePath)) continue;
const fileRef = project.pbxFileReferenceSection();
const alreadyLinked = Object.values(fileRef).some(
(entry) =>
entry &&
typeof entry === "object" &&
"path" in entry &&
entry.path === file,
);
if (!alreadyLinked) {
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
filepath,
groupName: projectName,
project,
verbose: true,
});
}
}
return config;
});
}
module.exports = withAppIntents;