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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user