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:
2026-06-23 01:08:20 -04:00
parent 06bc91ac13
commit 355b14faef
35 changed files with 1915 additions and 502 deletions
@@ -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,
},
});
+180
View File
@@ -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,
},
});
+90
View File
@@ -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,
},
});
+195 -116
View File
@@ -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,
},
});