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)
|
||||
|
||||
## Deep links
|
||||
## Deep links & Shortcuts
|
||||
|
||||
| Scheme | Screen |
|
||||
|--------|--------|
|
||||
| URL | Action |
|
||||
|-----|--------|
|
||||
| `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
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"bundleIdentifier": "com.beenvoice.app",
|
||||
"icon": "./assets/beenvoice.icon",
|
||||
"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": {
|
||||
@@ -35,6 +36,14 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-dev-client",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"buildReactNativeFromSource": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
@@ -58,7 +67,16 @@
|
||||
"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": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Platform } from "react-native";
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { AppLockProvider } from "@/contexts/AppLockContext";
|
||||
|
||||
@@ -71,6 +73,8 @@ export default function AppLayout() {
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
<InvoiceReminderSync />
|
||||
<ShortcutHandler />
|
||||
<AppLockOverlay />
|
||||
</AppLockProvider>
|
||||
);
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function DashboardScreen() {
|
||||
: "No change vs last month";
|
||||
|
||||
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
|
||||
const sendReminderDue = stats.sendReminderDue ?? [];
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
@@ -118,6 +119,26 @@ export default function DashboardScreen() {
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{sendReminderDue.length > 0 ? (
|
||||
<GlassSurface style={styles.alertGlass}>
|
||||
<View style={styles.alertBanner}>
|
||||
<Text style={styles.alertTitle}>
|
||||
{sendReminderDue.length} draft{" "}
|
||||
{sendReminderDue.length === 1 ? "invoice" : "invoices"} ready to send
|
||||
</Text>
|
||||
<Text style={styles.alertText}>
|
||||
{sendReminderDue
|
||||
.slice(0, 2)
|
||||
.map(
|
||||
(inv) =>
|
||||
`${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.client?.name ?? "Client"})`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<View style={styles.quickActions}>
|
||||
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
|
||||
<Button
|
||||
|
||||
@@ -46,6 +46,14 @@ export default function InvoiceDetailScreen() {
|
||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||
});
|
||||
|
||||
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
||||
onSuccess: () => {
|
||||
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
@@ -96,6 +104,28 @@ export default function InvoiceDetailScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function promptPaymentReminder() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending payment reminders.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
"Send payment reminder",
|
||||
`Email a payment reminder to ${clientEmail}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Send",
|
||||
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function promptStatusChange(current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
@@ -165,6 +195,16 @@ export default function InvoiceDetailScreen() {
|
||||
{invoice.taxRate > 0 ? (
|
||||
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
|
||||
) : null}
|
||||
{invoice.status === "draft" && invoice.sendReminderAt ? (
|
||||
<DetailRow
|
||||
label="Send reminder"
|
||||
value={
|
||||
new Date(invoice.sendReminderAt) <= new Date()
|
||||
? "Due now"
|
||||
: formatDate(invoice.sendReminderAt)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
@@ -212,6 +252,14 @@ export default function InvoiceDetailScreen() {
|
||||
loading={sendInvoice.isPending}
|
||||
/>
|
||||
) : null}
|
||||
{status === "sent" || status === "overdue" ? (
|
||||
<Button
|
||||
title="Send payment reminder"
|
||||
variant="secondary"
|
||||
onPress={promptPaymentReminder}
|
||||
loading={sendPaymentReminder.isPending}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
title="Edit invoice"
|
||||
variant="secondary"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { validateLineItems } from "@/lib/form-validation";
|
||||
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
@@ -42,6 +43,7 @@ export default function InvoiceEditScreen() {
|
||||
|
||||
const [notes, setNotes] = useState("");
|
||||
const [dueDate, setDueDate] = useState(() => new Date());
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -51,6 +53,7 @@ export default function InvoiceEditScreen() {
|
||||
if (!invoice) return;
|
||||
setNotes(invoice.notes ?? "");
|
||||
setDueDate(new Date(invoice.dueDate));
|
||||
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
||||
setItems(
|
||||
invoice.items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -67,6 +70,7 @@ export default function InvoiceEditScreen() {
|
||||
onSuccess: () => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.invoices.getAll.invalidate({ status: "draft" });
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Saved", "Invoice updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
@@ -86,6 +90,7 @@ export default function InvoiceEditScreen() {
|
||||
});
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
const isDraft = invoice?.status === "draft";
|
||||
|
||||
const subtotal = useMemo(
|
||||
() =>
|
||||
@@ -101,8 +106,8 @@ export default function InvoiceEditScreen() {
|
||||
const taxAmount = subtotal * (taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const currency = invoice?.currency ?? "USD";
|
||||
const lineItemsError = validateLineItems(items);
|
||||
const canSave = !lineItemsError;
|
||||
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||
const canSave = isDraft ? !lineItemsError : true;
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
@@ -172,10 +177,20 @@ export default function InvoiceEditScreen() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
async function handleSave() {
|
||||
if (!canSave) return;
|
||||
setError(null);
|
||||
|
||||
if (isDraft && sendReminderAt) {
|
||||
const granted = await ensureNotificationPermissions();
|
||||
if (!granted) {
|
||||
Alert.alert(
|
||||
"Notifications disabled",
|
||||
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parsedItems: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
@@ -196,7 +211,12 @@ export default function InvoiceEditScreen() {
|
||||
id,
|
||||
notes,
|
||||
dueDate,
|
||||
items: parsedItems,
|
||||
sendReminderAt,
|
||||
...(isDraft
|
||||
? {
|
||||
items: parsedItems,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,6 +243,25 @@ export default function InvoiceEditScreen() {
|
||||
|
||||
<Card>
|
||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
||||
{isDraft ? (
|
||||
<>
|
||||
<DateTimeField
|
||||
label="Remind me to send"
|
||||
mode="date"
|
||||
value={sendReminderAt ?? dueDate}
|
||||
minimumDate={new Date()}
|
||||
maximumDate={new Date(2100, 0, 1)}
|
||||
onChange={setSendReminderAt}
|
||||
/>
|
||||
{sendReminderAt ? (
|
||||
<Pressable onPress={() => setSendReminderAt(null)}>
|
||||
<Text style={[styles.clearReminder, { color: colors.primary }]}>
|
||||
Clear send reminder
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Input
|
||||
label="Notes"
|
||||
value={notes}
|
||||
@@ -234,6 +273,12 @@ export default function InvoiceEditScreen() {
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
{!isDraft ? (
|
||||
<Text style={styles.lockedHint}>
|
||||
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
||||
screen to edit entries.
|
||||
</Text>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
<LineItemEditor
|
||||
key={item.id ?? `new-${index}`}
|
||||
@@ -245,12 +290,15 @@ export default function InvoiceEditScreen() {
|
||||
}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
readOnly={!isDraft}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add line</Text>
|
||||
</Pressable>
|
||||
{isDraft ? (
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add line</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.totals}>
|
||||
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
|
||||
@@ -369,6 +417,17 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
lockedHint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
color: colors.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
clearReminder: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
|
||||
+86
-39
@@ -19,6 +19,7 @@ import { useAppLock } from "@/contexts/AppLockContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||
@@ -37,6 +38,7 @@ export default function SettingsScreen() {
|
||||
apiUrl,
|
||||
switchAccount,
|
||||
removeAccount,
|
||||
refreshAccounts,
|
||||
clearActiveAccount,
|
||||
} = useAccounts();
|
||||
const { colors, colorMode, setColorMode } = useAppTheme();
|
||||
@@ -67,6 +69,31 @@ export default function SettingsScreen() {
|
||||
>(null);
|
||||
const [pendingPin, setPendingPin] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [refreshingAccounts, setRefreshingAccounts] = useState(false);
|
||||
|
||||
async function handleRefreshAccounts() {
|
||||
setRefreshingAccounts(true);
|
||||
try {
|
||||
await refreshAccounts();
|
||||
await profileQuery.refetch();
|
||||
} finally {
|
||||
setRefreshingAccounts(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveAccount(accountId: string, label: string) {
|
||||
confirmRemoveAccount(
|
||||
label,
|
||||
() => removeAccount(accountId),
|
||||
async (result) => {
|
||||
await finishAccountRemoval({
|
||||
result,
|
||||
clearActiveAccount,
|
||||
signOut: () => authClient.signOut(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut();
|
||||
@@ -77,18 +104,7 @@ export default function SettingsScreen() {
|
||||
function confirmSignOut() {
|
||||
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Sign out", style: "destructive", onPress: handleSignOut },
|
||||
]);
|
||||
}
|
||||
|
||||
function confirmRemoveAccount(accountId: string, label: string) {
|
||||
Alert.alert("Remove account", `Remove ${label} from this device?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Remove",
|
||||
style: "destructive",
|
||||
onPress: () => void removeAccount(accountId),
|
||||
},
|
||||
{ text: "Sign out", style: "destructive", onPress: () => void handleSignOut() },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -218,47 +234,64 @@ export default function SettingsScreen() {
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
return (
|
||||
<Pressable
|
||||
<View
|
||||
key={account.id}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void switchAccount(account.id)}
|
||||
onLongPress={() => confirmRemoveAccount(account.id, account.email)}
|
||||
style={({ pressed }) => [
|
||||
style={[
|
||||
styles.accountRow,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: isActive ? colors.muted : "transparent",
|
||||
},
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.accountMeta}>
|
||||
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||
{account.name || account.email}
|
||||
</Text>
|
||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||
{account.email}
|
||||
</Text>
|
||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive ? (
|
||||
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void switchAccount(account.id)}
|
||||
style={({ pressed }) => [styles.accountMain, pressed && styles.pressed]}
|
||||
>
|
||||
<View style={styles.accountMeta}>
|
||||
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||
{account.name || account.email}
|
||||
</Text>
|
||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||
{account.email}
|
||||
</Text>
|
||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive ? (
|
||||
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Remove ${account.name || account.email}`}
|
||||
hitSlop={8}
|
||||
onPress={() =>
|
||||
handleRemoveAccount(account.id, account.name || account.email)
|
||||
}
|
||||
style={({ pressed }) => [styles.removeButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
title={refreshingAccounts ? "Refreshing…" : "Refresh accounts"}
|
||||
variant="secondary"
|
||||
disabled={refreshingAccounts}
|
||||
onPress={() => void handleRefreshAccounts()}
|
||||
/>
|
||||
<Button
|
||||
title="Add another account"
|
||||
variant="secondary"
|
||||
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
|
||||
/>
|
||||
{accounts.length > 1 ? (
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Long-press an account to remove it from this device.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Tap an account to switch. Refresh updates names from saved sign-in data.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card title="Security">
|
||||
@@ -418,11 +451,25 @@ const styles = StyleSheet.create({
|
||||
accountRow: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.sm,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
accountMain: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
removeButton: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.92,
|
||||
|
||||
+7
-5
@@ -6,10 +6,12 @@ import { TabPage } from "@/components/TabPage";
|
||||
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
|
||||
|
||||
export default function TimerScreen() {
|
||||
const { clientId, invoiceId } = useLocalSearchParams<{
|
||||
clientId?: string;
|
||||
invoiceId?: string;
|
||||
const params = useLocalSearchParams<{
|
||||
clientId?: string | string[];
|
||||
invoiceId?: string | string[];
|
||||
}>();
|
||||
const clientId = Array.isArray(params.clientId) ? params.clientId[0] : params.clientId;
|
||||
const invoiceId = Array.isArray(params.invoiceId) ? params.invoiceId[0] : params.invoiceId;
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
@@ -21,8 +23,8 @@ export default function TimerScreen() {
|
||||
subtitle="Track billable hours and link them to invoices"
|
||||
/>
|
||||
}
|
||||
defaultClientId={clientId}
|
||||
defaultInvoiceId={invoiceId}
|
||||
defaultClientId={clientId ?? ""}
|
||||
defaultInvoiceId={invoiceId ?? ""}
|
||||
compact
|
||||
/>
|
||||
</TabPage>
|
||||
|
||||
+4
-2
@@ -19,6 +19,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
|
||||
import { BrandBackground } from "@/components/BrandBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { SessionSync } from "@/components/SessionSync";
|
||||
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
|
||||
import { AuthProvider, useSession } from "@/contexts/AuthContext";
|
||||
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
||||
@@ -34,6 +35,7 @@ function AppServices({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
|
||||
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
|
||||
<SessionSync />
|
||||
{children}
|
||||
</TRPCProvider>
|
||||
</AuthProvider>
|
||||
@@ -95,13 +97,13 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
function RootNavigator() {
|
||||
const { data: session, isPending } = useSession();
|
||||
const { data: session, isPending, error } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingScreen message="Checking session…" />;
|
||||
}
|
||||
|
||||
const isAuthenticated = Boolean(session?.user);
|
||||
const isAuthenticated = Boolean(session?.user) && !error;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"better-auth": "^1.6.19",
|
||||
"expo": "~56.0.12",
|
||||
"expo-blur": "~56.0.3",
|
||||
"expo-build-properties": "^56.0.19",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-font": "~56.0.7",
|
||||
@@ -27,6 +28,7 @@
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-local-authentication": "~56.0.4",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-notifications": "^56.0.18",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-secure-store": "^56.0.4",
|
||||
"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=="],
|
||||
|
||||
"badgin": ["badgin@1.2.3", "", {}, "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"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-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-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-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-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-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 { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -11,8 +12,9 @@ import {
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useSession } from "@/contexts/AuthContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
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. */
|
||||
export function AccountSwitcher() {
|
||||
const { colors } = useAppTheme();
|
||||
const authClient = useAuthClient();
|
||||
const { data: session } = useSession();
|
||||
const {
|
||||
accounts,
|
||||
activeAccount,
|
||||
activeAccountId,
|
||||
switchAccount,
|
||||
removeAccount,
|
||||
refreshAccounts,
|
||||
clearActiveAccount,
|
||||
} = useAccounts();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const label = displayName(
|
||||
activeAccount?.name ?? session?.user.name ?? "",
|
||||
@@ -67,6 +73,32 @@ export function AccountSwitcher() {
|
||||
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 (
|
||||
<>
|
||||
<Pressable
|
||||
@@ -100,9 +132,25 @@ export function AccountSwitcher() {
|
||||
>
|
||||
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
|
||||
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
||||
</Pressable>
|
||||
<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)}>
|
||||
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
@@ -138,9 +186,22 @@ export function AccountSwitcher() {
|
||||
{formatServerHost(account.instanceUrl)}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive ? (
|
||||
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||
) : null}
|
||||
<View style={styles.accountActions}>
|
||||
{isActive ? (
|
||||
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||
) : 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>
|
||||
);
|
||||
})}
|
||||
@@ -218,6 +279,17 @@ const styles = StyleSheet.create({
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 16,
|
||||
},
|
||||
sheetActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
},
|
||||
iconButton: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 28,
|
||||
minHeight: 28,
|
||||
},
|
||||
done: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 16,
|
||||
@@ -234,6 +306,11 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
accountActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
accountName: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
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;
|
||||
onChange: (patch: Partial<EditableLineItem>) => void;
|
||||
onRemove: () => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export function LineItemEditor({
|
||||
@@ -32,6 +33,7 @@ export function LineItemEditor({
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
readOnly = false,
|
||||
}: LineItemEditorProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const hours = Number(item.hours) || 0;
|
||||
@@ -39,12 +41,13 @@ export function LineItemEditor({
|
||||
const amount = hours * rate;
|
||||
const borderStyle = { borderTopColor: colors.border };
|
||||
|
||||
if (!expanded) {
|
||||
if (!expanded || readOnly) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onToggle}
|
||||
style={({ pressed }) => [styles.row, borderStyle, pressed && styles.rowPressed]}
|
||||
onPress={readOnly ? undefined : onToggle}
|
||||
disabled={readOnly}
|
||||
style={({ pressed }) => [styles.row, borderStyle, pressed && !readOnly && styles.rowPressed]}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
|
||||
@@ -57,7 +60,9 @@ export function LineItemEditor({
|
||||
<Text style={[styles.rowAmount, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
|
||||
{!readOnly ? (
|
||||
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,14 @@ import {
|
||||
type SavedAccount,
|
||||
} from "@/lib/accounts";
|
||||
import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
|
||||
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
|
||||
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
|
||||
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
|
||||
|
||||
export type RemoveAccountResult = {
|
||||
wasActive: boolean;
|
||||
remainingCount: number;
|
||||
};
|
||||
|
||||
type AccountsContextValue = {
|
||||
accounts: SavedAccount[];
|
||||
@@ -37,7 +44,8 @@ type AccountsContextValue = {
|
||||
email: string;
|
||||
name: string;
|
||||
}) => Promise<SavedAccount>;
|
||||
removeAccount: (accountId: string) => Promise<void>;
|
||||
removeAccount: (accountId: string) => Promise<RemoveAccountResult>;
|
||||
refreshAccounts: () => Promise<void>;
|
||||
clearActiveAccount: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -148,12 +156,17 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
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);
|
||||
setAccounts(nextAccounts);
|
||||
await saveAccounts(nextAccounts);
|
||||
|
||||
if (activeAccountId === accountId) {
|
||||
if (wasActive) {
|
||||
const fallback = nextAccounts[0] ?? null;
|
||||
await saveActiveAccountId(fallback?.id ?? null);
|
||||
setActiveAccountId(fallback?.id ?? null);
|
||||
@@ -162,10 +175,30 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
||||
setApiUrl(fallback.instanceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return { wasActive, remainingCount: nextAccounts.length };
|
||||
},
|
||||
[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 () => {
|
||||
await saveActiveAccountId(null);
|
||||
setActiveAccountId(null);
|
||||
@@ -184,6 +217,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
||||
switchAccount,
|
||||
registerAccount,
|
||||
removeAccount,
|
||||
refreshAccounts,
|
||||
clearActiveAccount,
|
||||
}),
|
||||
[
|
||||
@@ -195,6 +229,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
||||
switchAccount,
|
||||
registerAccount,
|
||||
removeAccount,
|
||||
refreshAccounts,
|
||||
clearActiveAccount,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -15,11 +15,13 @@ function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient
|
||||
return createAuthClient({
|
||||
baseURL: apiUrl,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: "beenvoice",
|
||||
storagePrefix,
|
||||
storage: SecureStore,
|
||||
}),
|
||||
expoClient({
|
||||
scheme: "beenvoice",
|
||||
storagePrefix,
|
||||
storage: SecureStore,
|
||||
// Avoid showing a cached session when cookies have already expired.
|
||||
disableCache: true,
|
||||
}),
|
||||
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}`);
|
||||
}
|
||||
|
||||
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> {
|
||||
const value = await SecureStore.getItemAsync(fromKey);
|
||||
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> {
|
||||
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")}`;
|
||||
}
|
||||
|
||||
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: {
|
||||
outcome: ClockOutOutcome;
|
||||
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 { createTRPCReact } from "@trpc/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
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";
|
||||
|
||||
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 }) {
|
||||
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(() =>
|
||||
api.createClient({
|
||||
links: [
|
||||
@@ -42,7 +49,7 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
|
||||
|
||||
return (
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
{children}
|
||||
</api.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"better-auth": "^1.6.19",
|
||||
"expo": "~56.0.12",
|
||||
"expo-blur": "~56.0.3",
|
||||
"expo-build-properties": "^56.0.19",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-font": "~56.0.7",
|
||||
@@ -25,6 +26,7 @@
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-local-authentication": "~56.0.4",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-notifications": "^56.0.18",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-secure-store": "^56.0.4",
|
||||
"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