Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing fixes for distribution export. Add mobile invoice PDF preview, compact line items, and more reliable shortcut deep-link handling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,71 +1,79 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } 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 {
|
||||
clearPendingShortcut,
|
||||
peekPendingShortcut,
|
||||
subscribeShortcutQueue,
|
||||
} from "@/lib/shortcut-queue";
|
||||
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 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.
|
||||
* Executes queued shortcut actions once the user is signed in, unlocked, and data is ready.
|
||||
*/
|
||||
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 [pending, setPending] = useState<ParsedShortcut | null>(null);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
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;
|
||||
let cancelled = false;
|
||||
|
||||
async function refresh() {
|
||||
const next = await peekPendingShortcut();
|
||||
if (!cancelled) setPending(next);
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return subscribeShortcutQueue(() => {
|
||||
void refresh();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseShortcutUrl(url);
|
||||
if (parsed) pendingRef.current = parsed;
|
||||
}, [url]);
|
||||
if (!pending || !activeAccountId || isLocked) return;
|
||||
if (clientsQuery.isLoading || runningQuery.isLoading) return;
|
||||
if (processingRef.current) return;
|
||||
|
||||
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;
|
||||
processingRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
if (pending.action === "open-timer") {
|
||||
router.push("/(app)/timer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.action === "clock-out") {
|
||||
if (!runningQuery.data) {
|
||||
try {
|
||||
if (pending.action === "open-timer") {
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
router.push("/(app)/timer");
|
||||
if (Platform.OS === "ios") {
|
||||
Alert.alert("No timer running", "There is nothing to clock out.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pending.action === "clock-out") {
|
||||
if (!runningQuery.data) {
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
router.push("/(app)/timer");
|
||||
if (Platform.OS === "ios") {
|
||||
Alert.alert("No timer running", "There is nothing to clock out.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await clockOut.mutateAsync({});
|
||||
await Promise.all([
|
||||
utils.timeEntries.getRunning.invalidate(),
|
||||
@@ -73,56 +81,60 @@ export function ShortcutHandler() {
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
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;
|
||||
}
|
||||
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.");
|
||||
if (pending.action === "clock-in") {
|
||||
if (runningQuery.data) {
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
router.push("/(app)/timer");
|
||||
if (Platform.OS === "ios") {
|
||||
Alert.alert("Timer already running", "Stop the current timer before clocking in again.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId =
|
||||
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
|
||||
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;
|
||||
}
|
||||
if (!clientId) {
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
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);
|
||||
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.",
|
||||
);
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
router.push("/(app)/timer");
|
||||
}
|
||||
} catch (err) {
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
Alert.alert(
|
||||
pending.action === "clock-out" ? "Clock out failed" : "Clock in failed",
|
||||
err instanceof Error ? err.message : "Something went wrong.",
|
||||
);
|
||||
router.push("/(app)/timer");
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
@@ -132,6 +144,7 @@ export function ShortcutHandler() {
|
||||
clientsQuery.data,
|
||||
clientsQuery.isLoading,
|
||||
isLocked,
|
||||
pending,
|
||||
runningQuery.data,
|
||||
runningQuery.isLoading,
|
||||
utils,
|
||||
|
||||
Reference in New Issue
Block a user