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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { enqueueShortcut } from "@/lib/shortcut-queue";
|
||||
import { parseShortcutUrl } from "@/lib/shortcuts";
|
||||
|
||||
/**
|
||||
* Captures shortcut deep links as early as possible (before auth / tabs mount).
|
||||
* Mounted at the app root inside AppServices.
|
||||
*/
|
||||
export function ShortcutLinkCapture() {
|
||||
useEffect(() => {
|
||||
function capture(url: string | null | undefined) {
|
||||
const parsed = parseShortcutUrl(url);
|
||||
if (parsed) {
|
||||
void enqueueShortcut(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
void Linking.getInitialURL().then(capture);
|
||||
|
||||
const subscription = Linking.addEventListener("url", ({ url }) => {
|
||||
capture(url);
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
|
||||
export type InvoiceEditorSection = "edit" | "preview";
|
||||
|
||||
type InvoiceEditorSectionTabsProps = {
|
||||
value: InvoiceEditorSection;
|
||||
onChange: (value: InvoiceEditorSection) => void;
|
||||
editLabel?: string;
|
||||
previewLabel?: string;
|
||||
};
|
||||
|
||||
export function InvoiceEditorSectionTabs({
|
||||
value,
|
||||
onChange,
|
||||
editLabel = "Edit",
|
||||
previewLabel = "PDF preview",
|
||||
}: InvoiceEditorSectionTabsProps) {
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<FilterChip
|
||||
label={editLabel}
|
||||
active={value === "edit"}
|
||||
onPress={() => onChange("edit")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={previewLabel}
|
||||
active={value === "preview"}
|
||||
onPress={() => onChange("preview")}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
canPreviewPdfInput,
|
||||
type InvoicePdfPreviewInput,
|
||||
} from "@/lib/invoice-pdf-input";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type InvoicePdfPreviewProps = {
|
||||
input: InvoicePdfPreviewInput | null;
|
||||
height?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
function buildPdfHtml(contentType: string, base64: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #525659; }
|
||||
embed { width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<embed src="data:${contentType};base64,${base64}" type="application/pdf" />
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function InvoicePdfPreview({
|
||||
input,
|
||||
height = 560,
|
||||
style,
|
||||
}: InvoicePdfPreviewProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createPreviewStyles);
|
||||
const enabled = canPreviewPdfInput(input);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } =
|
||||
api.invoices.previewPdf.useQuery(input!, {
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const html = useMemo(() => {
|
||||
if (!data?.base64) return null;
|
||||
return buildPdfHtml(data.contentType, data.base64);
|
||||
}, [data]);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<View style={[styles.frame, { height }, style]}>
|
||||
<Text style={styles.placeholder}>
|
||||
Select a client and add a description to every line item to preview the
|
||||
PDF.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !html) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.centered, { height }, style]}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Generating preview…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.centered, { height }, style]}>
|
||||
<Text style={styles.errorText}>{error.message}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => void refetch()}>
|
||||
<Text style={[styles.retry, { color: colors.primary }]}>Try again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.centered, { height }, style]}>
|
||||
<Text style={styles.placeholder}>PDF preview will appear here.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, style]}>
|
||||
{isFetching ? (
|
||||
<View style={styles.refreshing}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[styles.frame, { height }]}>
|
||||
<WebView
|
||||
originWhitelist={["*"]}
|
||||
source={{ html }}
|
||||
style={styles.webview}
|
||||
scrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createPreviewStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
frame: {
|
||||
overflow: "hidden",
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
centered: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
placeholder: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.mutedForeground,
|
||||
textAlign: "center",
|
||||
padding: spacing.lg,
|
||||
},
|
||||
loadingText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
errorText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
retry: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
refreshing: {
|
||||
position: "absolute",
|
||||
top: spacing.sm,
|
||||
right: spacing.sm,
|
||||
zIndex: 2,
|
||||
borderRadius: radii.pill,
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type InvoiceTotalsProps = {
|
||||
subtotal: string;
|
||||
taxLabel?: string;
|
||||
taxAmount?: string;
|
||||
total: string;
|
||||
};
|
||||
|
||||
export function InvoiceTotals({
|
||||
subtotal,
|
||||
taxLabel,
|
||||
taxAmount,
|
||||
total,
|
||||
}: InvoiceTotalsProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.totals, { borderTopColor: colors.border }]}>
|
||||
<TotalRow label="Subtotal" value={subtotal} />
|
||||
{taxLabel && taxAmount ? <TotalRow label={taxLabel} value={taxAmount} /> : null}
|
||||
<TotalRow label="Total" value={total} bold />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalRow({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: colors.mutedForeground },
|
||||
bold && styles.bold,
|
||||
bold && { color: colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.value,
|
||||
{ color: colors.foreground },
|
||||
bold && styles.bold,
|
||||
]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
gap: 6,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
},
|
||||
bold: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { StepperInput } from "@/components/ui/StepperInput";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { CompactDateField } from "@/components/ui/CompactDateField";
|
||||
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { formatCurrency, formatShortDate } from "@/lib/format";
|
||||
|
||||
export type EditableLineItem = {
|
||||
id?: string;
|
||||
@@ -18,178 +17,258 @@ export type EditableLineItem = {
|
||||
|
||||
type LineItemEditorProps = {
|
||||
item: EditableLineItem;
|
||||
index: number;
|
||||
currency: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onChange: (patch: Partial<EditableLineItem>) => void;
|
||||
onRemove: () => void;
|
||||
readOnly?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export function LineItemsTableHeader() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}>
|
||||
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
|
||||
Description
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
|
||||
Date
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
|
||||
Hrs
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
|
||||
Rate
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
|
||||
Amt
|
||||
</Text>
|
||||
<View style={headerStyles.spacer} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineItemEditor({
|
||||
item,
|
||||
index,
|
||||
currency,
|
||||
expanded,
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
readOnly = false,
|
||||
isLast = false,
|
||||
}: LineItemEditorProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const hours = Number(item.hours) || 0;
|
||||
const rate = Number(item.rate) || 0;
|
||||
const amount = hours * rate;
|
||||
const borderStyle = { borderTopColor: colors.border };
|
||||
|
||||
if (!expanded || readOnly) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={readOnly ? undefined : onToggle}
|
||||
disabled={readOnly}
|
||||
style={({ pressed }) => [styles.row, borderStyle, pressed && !readOnly && styles.rowPressed]}
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
|
||||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
||||
<View style={styles.descCol}>
|
||||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}>
|
||||
{item.description.trim() || "Untitled line"}
|
||||
</Text>
|
||||
<Text style={[styles.rowSub, { color: colors.mutedForeground }]}>
|
||||
{formatDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.rowAmount, { color: colors.foreground }]}>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
{!readOnly ? (
|
||||
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.expanded, borderStyle]}>
|
||||
<View style={styles.expandedHeader}>
|
||||
<Text style={[styles.expandedLabel, { color: colors.mutedForeground }]}>Line item</Text>
|
||||
<Pressable accessibilityRole="button" onPress={onToggle} hitSlop={8}>
|
||||
<Ionicons name="chevron-up" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
<View
|
||||
style={[
|
||||
styles.editBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.editTop}>
|
||||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
||||
<TextInput
|
||||
value={item.description}
|
||||
onChangeText={(description) => onChange({ description })}
|
||||
placeholder="What was done?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.descriptionInput,
|
||||
{
|
||||
color: colors.foreground,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Description"
|
||||
value={item.description}
|
||||
onChangeText={(description) => onChange({ description })}
|
||||
placeholder="What was done"
|
||||
/>
|
||||
|
||||
<View style={styles.inlineRow}>
|
||||
<View style={styles.inlineField}>
|
||||
<StepperInput
|
||||
label="Hours"
|
||||
value={item.hours}
|
||||
onChangeText={(hours) => onChange({ hours })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inlineField}>
|
||||
<Input
|
||||
label="Rate"
|
||||
<View style={styles.metricsRow}>
|
||||
<CompactDateField
|
||||
value={item.date}
|
||||
onChange={(date) => onChange({ date })}
|
||||
style={styles.dateField}
|
||||
/>
|
||||
<CompactStepperInput
|
||||
value={item.hours}
|
||||
onChangeText={(hours) => onChange({ hours })}
|
||||
step={0.25}
|
||||
style={styles.hoursField}
|
||||
/>
|
||||
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}>
|
||||
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
||||
<TextInput
|
||||
value={item.rate}
|
||||
onChangeText={(rate) => onChange({ rate })}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.rateInput, { color: colors.foreground }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<DateTimeField
|
||||
label="Date"
|
||||
mode="date"
|
||||
value={item.date}
|
||||
onChange={(date) => onChange({ date })}
|
||||
/>
|
||||
|
||||
<View style={styles.expandedFooter}>
|
||||
<Text style={[styles.lineTotal, { color: colors.foreground }]}>
|
||||
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" onPress={onRemove} style={styles.removeButton}>
|
||||
<Ionicons name="trash-outline" size={16} color={colors.destructive} />
|
||||
<Text style={[styles.removeLabel, { color: colors.destructive }]}>Remove</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Remove line item"
|
||||
onPress={onRemove}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const headerStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingBottom: spacing.xs,
|
||||
marginBottom: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
cell: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
desc: { flex: 1, paddingLeft: 22 },
|
||||
date: { width: 72 },
|
||||
hours: { width: 88, textAlign: "center" },
|
||||
rate: { width: 72, textAlign: "center" },
|
||||
amt: { width: 64, textAlign: "right" },
|
||||
spacer: { width: 32 },
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.9,
|
||||
editBlock: {
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
rowMain: {
|
||||
editTop: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
index: {
|
||||
width: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
},
|
||||
descCol: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
rowTitle: {
|
||||
readTitle: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
},
|
||||
rowSub: {
|
||||
readSub: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 11,
|
||||
},
|
||||
descriptionInput: {
|
||||
flex: 1,
|
||||
minHeight: 36,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingLeft: 22,
|
||||
},
|
||||
dateField: {
|
||||
width: 72,
|
||||
},
|
||||
hoursField: {
|
||||
width: 88,
|
||||
},
|
||||
rateField: {
|
||||
width: 72,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
minHeight: 36,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
ratePrefix: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
rateInput: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
paddingVertical: 4,
|
||||
textAlign: "right",
|
||||
},
|
||||
amount: {
|
||||
width: 64,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
textAlign: "right",
|
||||
},
|
||||
amountEdit: {
|
||||
fontSize: 12,
|
||||
},
|
||||
rowAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
expanded: {
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
expandedHeader: {
|
||||
flexDirection: "row",
|
||||
remove: {
|
||||
width: 32,
|
||||
height: 36,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "center",
|
||||
},
|
||||
expandedLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
inlineRow: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
},
|
||||
inlineField: {
|
||||
flex: 1,
|
||||
},
|
||||
expandedFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
lineTotal: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 16,
|
||||
},
|
||||
removeButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
removeLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
removePressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import DateTimePicker, {
|
||||
type DateTimePickerEvent,
|
||||
} from "@react-native-community/datetimepicker";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
|
||||
import { fonts, radii } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatShortDate } from "@/lib/format";
|
||||
|
||||
type CompactDateFieldProps = {
|
||||
value: Date;
|
||||
onChange: (date: Date) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
maximumDate?: Date;
|
||||
minimumDate?: Date;
|
||||
};
|
||||
|
||||
export function CompactDateField({
|
||||
value,
|
||||
onChange,
|
||||
style,
|
||||
maximumDate = new Date(2100, 0, 1),
|
||||
minimumDate,
|
||||
}: CompactDateFieldProps) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
function applyDate(next: Date) {
|
||||
const clamped =
|
||||
next.getTime() > maximumDate.getTime()
|
||||
? maximumDate
|
||||
: minimumDate && next.getTime() < minimumDate.getTime()
|
||||
? minimumDate
|
||||
: next;
|
||||
onChange(clamped);
|
||||
}
|
||||
|
||||
function handleChange(event: DateTimePickerEvent, selected?: Date) {
|
||||
if (Platform.OS === "android") {
|
||||
setOpen(false);
|
||||
if (event.type === "set" && selected) applyDate(selected);
|
||||
return;
|
||||
}
|
||||
if (selected) setDraft(selected);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Change date"
|
||||
onPress={() => {
|
||||
setDraft(value);
|
||||
setOpen(true);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.trigger,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
pressed && styles.pressed,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.value, { color: colors.foreground }]} numberOfLines={1}>
|
||||
{formatShortDate(value)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={12} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS === "ios" ? (
|
||||
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||
<Pressable
|
||||
style={[styles.sheet, { backgroundColor: colors.card }]}
|
||||
onPress={(event) => event.stopPropagation()}
|
||||
>
|
||||
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||
<Pressable onPress={() => setOpen(false)}>
|
||||
<Text style={[styles.action, { color: colors.mutedForeground }]}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Date</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
applyDate(draft);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.action, { color: colors.primary }]}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={draft}
|
||||
mode="date"
|
||||
display="spinner"
|
||||
maximumDate={maximumDate}
|
||||
minimumDate={minimumDate}
|
||||
themeVariant={isDark ? "dark" : "light"}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
) : open ? (
|
||||
<DateTimePicker
|
||||
value={draft}
|
||||
mode="date"
|
||||
maximumDate={maximumDate}
|
||||
minimumDate={minimumDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
trigger: {
|
||||
minHeight: 36,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 8,
|
||||
gap: 2,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
value: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 12,
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.45)",
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: radii.lg,
|
||||
borderTopRightRadius: radii.lg,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
sheetTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
action: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, TextInput, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
import { fonts, radii } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type CompactStepperInputProps = {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
step?: number;
|
||||
min?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export function CompactStepperInput({
|
||||
value,
|
||||
onChangeText,
|
||||
step = 0.25,
|
||||
min = 0,
|
||||
style,
|
||||
}: CompactStepperInputProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
function adjust(delta: number) {
|
||||
const current = Number.parseFloat(value) || 0;
|
||||
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
|
||||
onChangeText(Number.isInteger(next) ? String(next) : String(next));
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.field,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Decrease hours"
|
||||
hitSlop={4}
|
||||
onPress={() => adjust(-step)}
|
||||
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Ionicons name="remove" size={14} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.input, { color: colors.foreground }]}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Increase hours"
|
||||
hitSlop={4}
|
||||
onPress={() => adjust(step)}
|
||||
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
|
||||
>
|
||||
<Ionicons name="add" size={14} color={colors.foreground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
field: {
|
||||
minHeight: 36,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
stepButton: {
|
||||
width: 28,
|
||||
height: 36,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user