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
+45 -85
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";
@@ -23,6 +29,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
import {
isRequiredString,
isValidTaxRate,
@@ -55,7 +62,7 @@ export default function NewInvoiceScreen() {
rate: "0",
},
]);
const [expandedIndex, setExpandedIndex] = useState<number | null>(0);
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [error, setError] = useState<string | null>(null);
const clientOptions = useMemo(
@@ -109,6 +116,21 @@ export default function NewInvoiceScreen() {
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const previewInput = useMemo(
() =>
buildPreviewPdfInput({
invoiceNumber,
clientId,
issueDate,
dueDate,
taxRate: parsedTaxRate,
currency,
notes,
items,
}),
[invoiceNumber, clientId, issueDate, dueDate, parsedTaxRate, currency, notes, items],
);
const clientError = clientId ? undefined : "Select a client";
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
@@ -131,7 +153,6 @@ export default function NewInvoiceScreen() {
}
function addItem() {
const nextIndex = items.length;
setItems((prev) => [
...prev,
{
@@ -141,7 +162,6 @@ export default function NewInvoiceScreen() {
rate: prev[prev.length - 1]?.rate ?? "0",
},
]);
setExpandedIndex(nextIndex);
}
function removeItem(index: number) {
@@ -150,11 +170,6 @@ export default function NewInvoiceScreen() {
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;
});
}
function handleCreate() {
@@ -203,6 +218,14 @@ export default function NewInvoiceScreen() {
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card title="Details">
{clientOptions.length === 0 ? (
<View style={styles.noClients}>
@@ -262,15 +285,14 @@ export default function NewInvoiceScreen() {
</Card>
<Card title="Line items">
<LineItemsTableHeader />
{items.map((item, index) => (
<LineItemEditor
key={`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)}
/>
@@ -280,16 +302,14 @@ export default function NewInvoiceScreen() {
<Text style={styles.addLineText}>+ Add line</Text>
</Pressable>
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
{parsedTaxRate > 0 ? (
<TotalRow
label={`Tax (${parsedTaxRate}%)`}
value={formatCurrency(taxAmount, currency)}
/>
) : null}
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
</View>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
@@ -301,67 +321,14 @@ export default function NewInvoiceScreen() {
disabled={!canCreate}
onPress={handleCreate}
/>
</>
)}
</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 createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
@@ -391,13 +358,6 @@ const createNewInvoiceStyles = (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,