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
+47 -87
View File
@@ -12,7 +12,13 @@ import {
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, LineItemsTableHeader, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -22,6 +28,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { getInvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import { validateLineItems } from "@/lib/form-validation";
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
@@ -45,7 +52,7 @@ export default function InvoiceEditScreen() {
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 [section, setSection] = useState<InvoiceEditorSection>("edit");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -63,7 +70,6 @@ export default function InvoiceEditScreen() {
rate: String(item.rate),
})),
);
setExpandedIndex(null);
}, [invoiceQuery.data]);
const updateInvoice = api.invoices.update.useMutation({
@@ -109,6 +115,23 @@ export default function InvoiceEditScreen() {
const lineItemsError = isDraft ? validateLineItems(items) : null;
const canSave = isDraft ? !lineItemsError : true;
const previewInput = useMemo(() => {
if (!invoice) return null;
return buildPreviewPdfInput({
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix,
businessId: invoice.businessId,
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate,
status: invoice.status as "draft" | "sent" | "paid",
notes,
taxRate,
currency,
items,
});
}, [invoice, dueDate, notes, taxRate, currency, items]);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
@@ -151,7 +174,6 @@ export default function InvoiceEditScreen() {
}
function addItem() {
const nextIndex = items.length;
setItems((prev) => [
...prev,
{
@@ -161,7 +183,6 @@ export default function InvoiceEditScreen() {
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
setExpandedIndex(nextIndex);
}
function removeItem(index: number) {
@@ -170,11 +191,6 @@ export default function InvoiceEditScreen() {
return;
}
setItems((prev) => prev.filter((_, i) => i !== index));
setExpandedIndex((current) => {
if (current === null) return null;
if (current === index) return null;
return current > index ? current - 1 : current;
});
}
async function handleSave() {
@@ -241,6 +257,14 @@ export default function InvoiceEditScreen() {
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
</View>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card>
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
{isDraft ? (
@@ -278,16 +302,16 @@ export default function InvoiceEditScreen() {
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
</Text>
) : null}
) : (
<LineItemsTableHeader />
)}
{items.map((item, index) => (
<LineItemEditor
key={item.id ?? `new-${index}`}
index={index}
item={item}
currency={currency}
expanded={expandedIndex === index}
onToggle={() =>
setExpandedIndex((current) => (current === index ? null : index))
}
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
readOnly={!isDraft}
@@ -300,16 +324,12 @@ export default function InvoiceEditScreen() {
</Pressable>
) : null}
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
{taxRate > 0 ? (
<TotalRow
label={`Tax (${taxRate}%)`}
value={formatCurrency(taxAmount, currency)}
/>
) : null}
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
</View>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={taxRate > 0 ? `Tax (${taxRate}%)` : undefined}
taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
@@ -331,67 +351,14 @@ export default function InvoiceEditScreen() {
/>
) : null}
</View>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</AppBackground>
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={totalStyles.row}>
<Text
style={[
totalStyles.label,
{ color: colors.mutedForeground },
bold && totalStyles.bold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
totalStyles.value,
{ color: colors.foreground },
bold && totalStyles.bold,
]}
>
{value}
</Text>
</View>
);
}
const totalStyles = StyleSheet.create({
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,
},
});
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
@@ -437,13 +404,6 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
fontSize: 14,
color: colors.primary,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 6,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,