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
+2
View File
@@ -17,6 +17,8 @@ expo-env.d.ts
*.p12
*.key
*.mobileprovision
.ios-release.env
dist/ios-release/
# Metro
.metro-health-check*
+28
View File
@@ -0,0 +1,28 @@
# Copy to .ios-release.env and fill in (file is gitignored).
# Used by: bun run ios:release
# Apple Developer team ID (10 chars, Membership details in developer.apple.com)
APPLE_TEAM_ID=
# Before export: create an Apple Distribution cert in Xcode
# (Settings → Accounts → your team → Manage Certificates → + → Apple Distribution)
#
# If export fails with "profile doesn't include signing certificate", regenerate App Store
# profiles at developer.apple.com for com.beenvoice.app and com.beenvoice.app.ExpoWidgetsTarget,
# then re-run the full release (not --export-only).
# Production API baked into the JS bundle (App Store / TestFlight)
EXPO_PUBLIC_API_URL=https://beenvoice.soconnor.dev
# App Store Connect API key (Users and Access → Integrations → App Store Connect API)
# Create a key with Developer role. Download the .p8 once — Apple won't show it again.
APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID=
# Path to AuthKey_XXXXXX.p8 (keep outside the repo or in a secrets folder)
APP_STORE_CONNECT_API_KEY_PATH=
# Optional: auto-increment CFBundleVersion before each archive (agvtool)
IOS_BUMP_BUILD=1
# Optional: skip `expo prebuild` when native project is already up to date
# IOS_SKIP_PREBUILD=1
+12 -3
View File
@@ -94,15 +94,24 @@ Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#mul
| `beenvoice://shortcuts/clock-in?title=…` | Clock in with title |
| `beenvoice://shortcuts/clock-out` | Clock out running timer |
**iOS Shortcuts / Siri** (dev build after `npx expo prebuild`):
**iOS Shortcuts / Siri** (requires native rebuild: `bunx expo prebuild --platform ios && bun run ios`):
- **Clock In** — starts the timer with your last client
- **Clock Out** — stops the running timer
- **Open Time Clock** — opens the timer tab
Try “Hey Siri, clock in with beenvoice” or add actions from the Shortcuts app under beenvoice.
1. Install a fresh build on a physical iPhone (iOS 16+).
2. Open the app once while signed in (registers shortcuts with the system).
3. Shortcuts app → search **beenvoice** → add actions, or say “Hey Siri, clock in with beenvoice”.
4. Pick a client once on the Timer tab before the first clock-in shortcut.
Rebuild iOS after pulling shortcut changes: `npx expo prebuild --platform ios && bun run ios`
**Test deep links:**
```bash
xcrun simctl openurl booted "beenvoice://shortcuts/clock-in"
xcrun simctl openurl booted "beenvoice://shortcuts/clock-out"
xcrun simctl openurl booted "beenvoice://timer"
```
## Project layout
+6 -1
View File
@@ -10,8 +10,10 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
"buildNumber": "7",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
}
@@ -40,6 +42,7 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "18.0",
"buildReactNativeFromSource": true
}
}
@@ -76,7 +79,9 @@
}
],
"@react-native-community/datetimepicker",
"./plugins/withAppIntents.js"
"./plugins/withAppIntents.js",
"./plugins/withAppStoreSigning.js",
"expo-sharing"
],
"experiments": {
"typedRoutes": true
+36 -72
View File
@@ -1,7 +1,14 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
@@ -12,6 +19,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
@@ -21,6 +29,7 @@ export default function InvoiceDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -81,6 +90,10 @@ export default function InvoiceDetailScreen() {
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const clientEmail = invoice.client?.email?.trim() ?? "";
const previewInput = useMemo(
() => buildPreviewPdfInputFromInvoice(invoice),
[invoice],
);
function promptSendInvoice() {
if (!clientEmail) {
@@ -188,6 +201,19 @@ export default function InvoiceDetailScreen() {
</Text>
</Card>
<InvoiceEditorSectionTabs
value={section}
onChange={setSection}
editLabel="Details"
previewLabel="PDF"
/>
{section === "preview" ? (
<Card title="PDF preview">
<InvoicePdfPreview input={previewInput} />
</Card>
) : (
<>
<Card title="Details">
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
@@ -222,20 +248,14 @@ export default function InvoiceDetailScreen() {
</Text>
</View>
))}
<View style={styles.totals}>
<TotalRow label="Subtotal" value={formatCurrency(subtotal, invoice.currency)} />
{invoice.taxRate > 0 ? (
<TotalRow
label={`Tax (${invoice.taxRate}%)`}
value={formatCurrency(taxAmount, invoice.currency)}
/>
) : null}
<TotalRow
label="Total"
value={formatCurrency(invoice.totalAmount, invoice.currency)}
bold
/>
</View>
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
taxAmount={
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
}
total={formatCurrency(invoice.totalAmount, invoice.currency)}
/>
</Card>
{invoice.notes ? (
@@ -281,6 +301,8 @@ export default function InvoiceDetailScreen() {
}
/>
</View>
</>
)}
</ScrollView>
</AppBackground>
);
@@ -296,41 +318,6 @@ function DetailRow({ label, value }: { label: string; value: string }) {
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.totalRow}>
<Text
style={[
detailStyles.totalLabel,
{ color: colors.mutedForeground },
bold && detailStyles.totalBold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
detailStyles.totalValue,
{ color: colors.foreground },
bold && detailStyles.totalBold,
]}
>
{value}
</Text>
</View>
);
}
const detailStyles = StyleSheet.create({
row: {
flexDirection: "row",
@@ -346,22 +333,6 @@ const detailStyles = StyleSheet.create({
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
},
totalLabel: {
fontFamily: fonts.body,
fontSize: 14,
},
totalValue: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
totalBold: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
});
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
@@ -427,13 +398,6 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
color: colors.foreground,
fontSize: 14,
},
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 4,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
+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,
+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,
+2
View File
@@ -20,6 +20,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { BrandBackground } from "@/components/BrandBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SessionSync } from "@/components/SessionSync";
import { ShortcutLinkCapture } from "@/components/ShortcutLinkCapture";
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
import { AuthProvider, useSession } from "@/contexts/AuthContext";
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
@@ -36,6 +37,7 @@ function AppServices({ children }: { children: ReactNode }) {
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
<SessionSync />
<ShortcutLinkCapture />
{children}
</TRPCProvider>
</AuthProvider>
+10 -5
View File
@@ -22,6 +22,7 @@
"expo-build-properties": "^56.0.19",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.7",
"expo-image": "^56.0.11",
"expo-linear-gradient": "~56.0.4",
@@ -31,6 +32,7 @@
"expo-notifications": "^56.0.18",
"expo-router": "~56.2.11",
"expo-secure-store": "^56.0.4",
"expo-sharing": "~56.0.18",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
@@ -44,6 +46,7 @@
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.8.3",
"superjson": "^2.2.6",
},
@@ -721,6 +724,8 @@
"expo-server": ["expo-server@56.0.5", "", {}, "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ=="],
"expo-sharing": ["expo-sharing@56.0.18", "", { "dependencies": { "@expo/config-plugins": "^56.0.9", "@expo/config-types": "^56.0.6", "@expo/plist": "^0.7.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ=="],
"expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="],
"expo-status-bar": ["expo-status-bar@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA=="],
@@ -1075,6 +1080,8 @@
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
"react-native-webview": ["react-native-webview@13.16.1", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw=="],
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
@@ -1279,7 +1286,7 @@
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -1309,8 +1316,6 @@
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"@expo/ws-tunnel/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -1381,8 +1386,6 @@
"plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
@@ -1407,6 +1410,8 @@
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+83 -70
View File
@@ -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,
+30
View File
@@ -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,
},
});
+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,
},
});
+174
View File
@@ -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,
},
});
+92
View File
@@ -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,
},
});
+4 -2
View File
@@ -22,6 +22,7 @@ import {
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
import { hasPendingShortcut } from "@/lib/shortcut-queue";
type AppLockContextValue = {
enabled: boolean;
@@ -68,7 +69,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
const accountId = activeAccountId;
async function hydrate() {
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes] =
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes, shortcutPending] =
await Promise.all([
getAppLockEnabled(accountId),
getStoredPin(accountId),
@@ -76,6 +77,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
hasPendingShortcut(),
]);
if (cancelled) return;
@@ -92,7 +94,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
? "Touch ID"
: "Biometrics",
);
setIsLocked(lockEnabled);
setIsLocked(lockEnabled && !shortcutPending);
hydrated.current = true;
}
+16 -2
View File
@@ -235,7 +235,21 @@ Optional: iPad 12.9" if `supportsTablet: true` — use iPad simulator or “Run
---
## Build & submit (EAS)
## Build & submit
### Option A — Local Xcode (no EAS)
See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide.
```bash
cd beenvoice-app
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
bun run ios:release:upload # archive + upload to TestFlight
```
Requires Xcode on macOS, Apple Developer membership, and an App Store Connect API key.
### Option B — EAS (Expo cloud build)
```bash
cd beenvoice-app
@@ -251,7 +265,7 @@ Prerequisites:
- Apple Developer Program membership
- App record created in App Store Connect with bundle ID `com.beenvoice.app`
- EAS credentials configured (`eas credentials`)
- EAS credentials configured (`eas credentials`) — Option B only
- Privacy Policy URL live and reachable
---
+88
View File
@@ -0,0 +1,88 @@
# Local iOS release (no EAS)
Archive and upload **beenvoice** to App Store Connect using Xcode on your Mac — no Expo Application Services (EAS) subscription required.
## Prerequisites
- macOS with **Xcode** (same major version you use for development)
- **Apple Developer Program** membership
- App record in [App Store Connect](https://appstoreconnect.apple.com) with bundle ID `com.beenvoice.app`
- **Distribution** signing set up in Xcode (automatic signing + team is enough for most cases)
- [App Store Connect API key](https://appstoreconnect.apple.com/access/integrations/api) (for upload only)
## One-time setup
```bash
cd beenvoice-app
cp .ios-release.env.example .ios-release.env
```
Edit `.ios-release.env`:
| Variable | Where to find it |
|----------|------------------|
| `APPLE_TEAM_ID` | [developer.apple.com/account](https://developer.apple.com/account) → Membership → Team ID |
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect → Users and Access → Integrations → Keys |
| `APP_STORE_CONNECT_API_ISSUER_ID` | Same page (Issuer ID at top) |
| `APP_STORE_CONNECT_API_KEY_PATH` | Path to downloaded `AuthKey_XXXXXX.p8` |
| `EXPO_PUBLIC_API_URL` | Production API URL baked into the release bundle |
Optional: store the `.p8` in `~/.appstoreconnect/private_keys/` (never commit it).
Open the iOS project once in Xcode and confirm **Signing & Capabilities** succeeds for targets **beenvoice** and **ExpoWidgetsTarget**.
## Commands
```bash
# Archive + export signed IPA to dist/ios-release/export/
bun run ios:release
# Archive + export + upload to App Store Connect (TestFlight)
bun run ios:release:upload
```
### Flags (pass through to the script)
```bash
bash scripts/ios-release.sh --archive-only # .xcarchive only
bash scripts/ios-release.sh --export-only --upload # re-upload existing archive
bash scripts/ios-release.sh --no-prebuild # skip expo prebuild
bash scripts/ios-release.sh --no-bump # don't increment build number
```
With `IOS_BUMP_BUILD=1` in `.ios-release.env`, each run bumps `CFBundleVersion` via `agvtool` (recommended for repeated TestFlight uploads).
## What the script does
1. `expo prebuild --platform ios` (unless `--no-prebuild`)
2. `pod install`
3. Optional build-number bump (`agvtool`)
4. `xcodebuild archive` (Release, generic iOS device)
5. `xcodebuild -exportArchive` → App Store IPA
6. `xcrun altool --upload-app` (with `--upload` only)
Artifacts land in `dist/ios-release/` (gitignored).
## After upload
1. App Store Connect → **TestFlight** — wait for “Processing” to finish
2. Smoke-test on device
3. Submit for App Store review when ready
See also [APP_STORE_CONNECT.md](./APP_STORE_CONNECT.md) for metadata, screenshots, and review notes.
## Notes
- **Dev client:** `expo-dev-client` is in the native project today. Store builds still work, but the binary includes the dev client shell. For a slimmer production binary, remove that plugin and re-run prebuild before release (or maintain a separate `app.config` variant).
- **Manual upload:** After `bun run ios:release`, drag the IPA into Apples [Transporter](https://apps.apple.com/app/transporter/id1450874784) app instead of using `--upload`.
- **CI:** Run the same script on a Mac runner (GitHub `macos-latest`, etc.) with secrets injected as env vars instead of `.ios-release.env`.
## Troubleshooting
| Issue | Fix |
|-------|-----|
| No signing certificate | Xcode → Settings → Accounts → Download Manual Profiles; or open project and enable automatic signing |
| `pod install` fails | `cd ios && pod repo update && pod install` |
| Upload auth error | Verify API key has **Developer** access; check Key ID, Issuer ID, and `.p8` path |
| Duplicate build number | Enable `IOS_BUMP_BUILD=1` or bump `CURRENT_PROJECT_VERSION` in Xcode |
| Widget extension signing | Both **beenvoice** and **ExpoWidgetsTarget** need the same team |
+7
View File
@@ -14,6 +14,13 @@ export function formatDate(date: Date | string) {
});
}
export function formatShortDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
export function formatDateTime(date: Date | string) {
return new Date(date).toLocaleString("en-US", {
month: "short",
+80
View File
@@ -0,0 +1,80 @@
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
export type InvoicePdfPreviewInput = inferRouterInputs<AppRouter>["invoices"]["previewPdf"];
type InvoiceDetail = NonNullable<inferRouterOutputs<AppRouter>["invoices"]["getById"]>;
export type InvoicePdfFormFields = {
invoiceNumber: string;
invoicePrefix?: string | null;
businessId?: string | null;
clientId: string;
issueDate: Date;
dueDate: Date;
status?: "draft" | "sent" | "paid";
notes?: string | null;
emailMessage?: string | null;
taxRate: number;
currency: string;
items: Array<{
date: Date;
description: string;
hours: number | string;
rate: number | string;
}>;
};
export function buildPreviewPdfInput(fields: InvoicePdfFormFields): InvoicePdfPreviewInput | null {
if (!fields.clientId.trim()) return null;
const items = fields.items.map((item) => ({
date: item.date,
description: item.description.trim() || "Service",
hours: Number(item.hours) || 0,
rate: Number(item.rate) || 0,
}));
if (items.length === 0) return null;
return {
invoiceNumber: fields.invoiceNumber.trim() || "DRAFT",
invoicePrefix: fields.invoicePrefix?.trim() || "#",
businessId: fields.businessId?.trim() || "",
clientId: fields.clientId,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
status: fields.status ?? "draft",
notes: fields.notes?.trim() ?? "",
emailMessage: fields.emailMessage?.trim() ?? "",
taxRate: fields.taxRate,
currency: fields.currency,
items,
};
}
export function buildPreviewPdfInputFromInvoice(invoice: InvoiceDetail): InvoicePdfPreviewInput {
return {
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
businessId: invoice.businessId ?? "",
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate: new Date(invoice.dueDate),
status: invoice.status as "draft" | "sent" | "paid",
notes: invoice.notes ?? "",
emailMessage: invoice.emailMessage ?? "",
taxRate: invoice.taxRate,
currency: invoice.currency ?? "USD",
items: invoice.items.map((item) => ({
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
})),
};
}
export function canPreviewPdfInput(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
if (!input?.clientId) return false;
return input.items.every((item) => item.description.trim().length > 0);
}
+49
View File
@@ -0,0 +1,49 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ParsedShortcut } from "@/lib/shortcuts";
const STORAGE_KEY = "beenvoice:pending-shortcut";
let memory: ParsedShortcut | null = null;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) {
listener();
}
}
export async function enqueueShortcut(shortcut: ParsedShortcut): Promise<void> {
memory = shortcut;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(shortcut));
notify();
}
export async function peekPendingShortcut(): Promise<ParsedShortcut | null> {
if (memory) return memory;
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
memory = JSON.parse(raw) as ParsedShortcut;
return memory;
} catch {
await AsyncStorage.removeItem(STORAGE_KEY);
return null;
}
}
export async function clearPendingShortcut(): Promise<void> {
memory = null;
await AsyncStorage.removeItem(STORAGE_KEY);
}
export function subscribeShortcutQueue(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export async function hasPendingShortcut(): Promise<boolean> {
return (await peekPendingShortcut()) != null;
}
+20 -8
View File
@@ -13,6 +13,25 @@ function queryParam(value: string | string[] | undefined): string {
return value ?? "";
}
function normalizeShortcutPath(hostname: string | null, path: string | null): string | null {
const host = (hostname ?? "").replace(/^\/+|\/+$/g, "");
const segment = (path ?? "").replace(/^\/+|\/+$/g, "");
if (host === "shortcuts" && segment) {
return segment;
}
const combined = [host, segment].filter(Boolean).join("/");
const match = combined.match(/(?:^|\/)shortcuts\/(clock-in|clock-out)$/);
if (match?.[1]) return match[1];
if (combined === "clock-in" || combined === "clock-out") {
return combined;
}
return null;
}
/** Parse `beenvoice://shortcuts/clock-in` and related URLs from Shortcuts / Siri. */
export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut | null {
if (!url) return null;
@@ -27,14 +46,7 @@ export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut
return { action: "open-timer", title: "", clientId: "" };
}
let shortcutAction: string | null = null;
if (host === "shortcuts" && path) {
shortcutAction = path;
} else {
const match = path.match(/^shortcuts\/(clock-in|clock-out)$/);
shortcutAction = match?.[1] ?? null;
}
const shortcutAction = normalizeShortcutPath(host, path);
if (shortcutAction === "clock-in" || shortcutAction === "clock-out") {
return {
action: shortcutAction,
+5
View File
@@ -20,6 +20,7 @@
"expo-build-properties": "^56.0.19",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.7",
"expo-image": "^56.0.11",
"expo-linear-gradient": "~56.0.4",
@@ -29,6 +30,7 @@
"expo-notifications": "^56.0.18",
"expo-router": "~56.2.11",
"expo-secure-store": "^56.0.4",
"expo-sharing": "~56.0.18",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
@@ -42,6 +44,7 @@
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.8.3",
"superjson": "^2.2.6"
},
@@ -54,6 +57,8 @@
"start": "expo start --dev-client --port 8082",
"android": "expo run:android --port 8082",
"ios": "expo run:ios --port 8082",
"ios:release": "bash scripts/ios-release.sh",
"ios:release:upload": "bash scripts/ios-release.sh --upload",
"web": "expo start --web"
},
"private": true,
@@ -0,0 +1,9 @@
import SwiftUI
@available(iOS 18.0, *)
enum BeenVoiceIntentHelpers {
@MainActor
static func openDeepLink(_ url: URL) {
EnvironmentValues().openURL(url)
}
}
+31 -30
View File
@@ -1,36 +1,37 @@
import AppIntents
@available(iOS 16.0, *)
@available(iOS 18.0, *)
struct BeenVoiceShortcuts: AppShortcutsProvider {
@AppShortcutsBuilder
static var appShortcuts: [AppShortcut] {
[
AppShortcut(
intent: ClockInIntent(),
phrases: [
"Clock in with \(.applicationName)",
"Start timer in \(.applicationName)",
],
shortTitle: "Clock In",
systemImageName: "play.circle.fill"
),
AppShortcut(
intent: ClockOutIntent(),
phrases: [
"Clock out in \(.applicationName)",
"Stop timer in \(.applicationName)",
],
shortTitle: "Clock Out",
systemImageName: "stop.circle.fill"
),
AppShortcut(
intent: OpenTimerIntent(),
phrases: [
"Open time clock in \(.applicationName)",
"Open timer in \(.applicationName)",
],
shortTitle: "Time Clock",
systemImageName: "timer"
),
]
AppShortcut(
intent: ClockInIntent(),
phrases: [
"Clock in with \(.applicationName)",
"Start timer in \(.applicationName)",
"Start tracking time in \(.applicationName)",
],
shortTitle: "Clock In",
systemImageName: "play.circle.fill"
)
AppShortcut(
intent: ClockOutIntent(),
phrases: [
"Clock out in \(.applicationName)",
"Stop timer in \(.applicationName)",
"Stop tracking time in \(.applicationName)",
],
shortTitle: "Clock Out",
systemImageName: "stop.circle.fill"
)
AppShortcut(
intent: OpenTimerIntent(),
phrases: [
"Open time clock in \(.applicationName)",
"Open timer in \(.applicationName)",
],
shortTitle: "Time Clock",
systemImageName: "timer"
)
}
}
+4 -6
View File
@@ -1,15 +1,15 @@
import AppIntents
import UIKit
@available(iOS 16.0, *)
@available(iOS 18.0, *)
struct ClockInIntent: AppIntent {
static var title: LocalizedStringResource = "Clock In"
static var description = IntentDescription("Start the beenvoice time clock with your last client.")
static var openAppWhenRun: Bool = false
static var openAppWhenRun: Bool = true
@Parameter(title: "Title")
var title: String?
@MainActor
func perform() async throws -> some IntentResult {
var components = URLComponents()
components.scheme = "beenvoice"
@@ -24,9 +24,7 @@ struct ClockInIntent: AppIntent {
return .result()
}
await MainActor.run {
UIApplication.shared.open(url)
}
BeenVoiceIntentHelpers.openDeepLink(url)
return .result()
}
+4 -6
View File
@@ -1,20 +1,18 @@
import AppIntents
import UIKit
@available(iOS 16.0, *)
@available(iOS 18.0, *)
struct ClockOutIntent: AppIntent {
static var title: LocalizedStringResource = "Clock Out"
static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
static var openAppWhenRun: Bool = false
static var openAppWhenRun: Bool = true
@MainActor
func perform() async throws -> some IntentResult {
guard let url = URL(string: "beenvoice://shortcuts/clock-out") else {
return .result()
}
await MainActor.run {
UIApplication.shared.open(url)
}
BeenVoiceIntentHelpers.openDeepLink(url)
return .result()
}
+4 -6
View File
@@ -1,20 +1,18 @@
import AppIntents
import UIKit
@available(iOS 16.0, *)
@available(iOS 18.0, *)
struct OpenTimerIntent: AppIntent {
static var title: LocalizedStringResource = "Open Time Clock"
static var description = IntentDescription("Open the beenvoice time clock.")
static var openAppWhenRun: Bool = false
static var openAppWhenRun: Bool = true
@MainActor
func perform() async throws -> some IntentResult {
guard let url = URL(string: "beenvoice://timer") else {
return .result()
}
await MainActor.run {
UIApplication.shared.open(url)
}
BeenVoiceIntentHelpers.openDeepLink(url)
return .result()
}
+45 -3
View File
@@ -8,6 +8,7 @@ const fs = require("fs");
const path = require("path");
const SWIFT_FILES = [
"BeenVoiceIntentHelpers.swift",
"ClockInIntent.swift",
"ClockOutIntent.swift",
"OpenTimerIntent.swift",
@@ -25,8 +26,11 @@ function withAppIntents(config) {
config = withDangerousMod(config, [
"ios",
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const platformRoot = config.modRequest.platformProjectRoot;
const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
const projectName =
config.modRequest.projectName ??
IOSConfig.XcodeUtils.getProjectName(projectRoot);
const targetDir = path.join(platformRoot, projectName);
fs.mkdirSync(targetDir, { recursive: true });
@@ -35,14 +39,50 @@ function withAppIntents(config) {
fs.copyFileSync(path.join(appIntentsSource, file), path.join(targetDir, file));
}
const appDelegatePath = path.join(targetDir, "AppDelegate.swift");
if (fs.existsSync(appDelegatePath)) {
let appDelegate = fs.readFileSync(appDelegatePath, "utf8");
const marker = "BeenVoiceShortcuts.updateAppShortcutParameters";
if (!appDelegate.includes("import AppIntents")) {
appDelegate = appDelegate.replace(
"internal import Expo",
"import AppIntents\ninternal import Expo",
);
}
if (appDelegate.includes(marker)) {
appDelegate = appDelegate.replace(
/if #available\(iOS 16\.0, \*\)/g,
"if #available(iOS 18.0, *)",
);
} else {
appDelegate = appDelegate.replace(
"return super.application(application, didFinishLaunchingWithOptions: launchOptions)",
`if #available(iOS 18.0, *) {
Task {
await BeenVoiceShortcuts.updateAppShortcutParameters()
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)`,
);
}
fs.writeFileSync(appDelegatePath, appDelegate);
}
return config;
},
]);
return withXcodeProject(config, (config) => {
const project = config.modResults;
const projectRoot = config.modRequest.projectRoot;
const platformRoot = config.modRequest.platformProjectRoot;
const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
const projectName =
config.modRequest.projectName ??
IOSConfig.XcodeUtils.getProjectName(projectRoot);
for (const file of SWIFT_FILES) {
const filepath = `${projectName}/${file}`;
@@ -56,7 +96,9 @@ function withAppIntents(config) {
entry &&
typeof entry === "object" &&
"path" in entry &&
entry.path === file,
(entry.path === file ||
entry.path === `${projectName}/${file}` ||
String(entry.path).endsWith(`/${file}`)),
);
if (!alreadyLinked) {
+42
View File
@@ -0,0 +1,42 @@
// @ts-check
const { withXcodeProject } = require("@expo/config-plugins");
const RELEASE_SIGN_KEY = '"CODE_SIGN_IDENTITY[sdk=iphoneos*]"';
/**
* RN / Expo sets Release CODE_SIGN_IDENTITY to "iPhone Developer", which forces
* development-signed archives. Remove it so automatic signing picks Distribution
* for App Store archives.
*/
/** @type {import('@expo/config-plugins').ConfigPlugin} */
function withAppStoreSigning(config) {
return withXcodeProject(config, (config) => {
const project = config.modResults;
const configurations = project.pbxXCBuildConfigurationSection();
for (const key of Object.keys(configurations)) {
const buildConfig = configurations[key];
if (!buildConfig || typeof buildConfig !== "object" || !buildConfig.buildSettings) {
continue;
}
if (buildConfig.name !== "Release") {
continue;
}
const identity = buildConfig.buildSettings[RELEASE_SIGN_KEY];
if (
identity === "iPhone Developer" ||
identity === '"iPhone Developer"' ||
identity === "Apple Distribution" ||
identity === '"Apple Distribution"'
) {
delete buildConfig.buildSettings[RELEASE_SIGN_KEY];
}
}
return config;
});
}
module.exports = withAppStoreSigning;
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env bash
# Archive beenvoice for iOS locally with Xcode and optionally upload to App Store Connect.
# No EAS or paid Expo build services required — only Apple Developer + Xcode on macOS.
#
# Usage:
# cp .ios-release.env.example .ios-release.env # once
# bun run ios:release # archive + export IPA
# bun run ios:release:upload # archive + upload to Connect
#
# Flags:
# --upload Upload to App Store Connect after export (needs API key in .ios-release.env)
# --archive-only Stop after .xcarchive (skip export/upload)
# --export-only Export/upload from an existing archive (IOS_ARCHIVE_PATH)
# --no-prebuild Skip `expo prebuild --platform ios`
# --no-bump Skip build-number increment even if IOS_BUMP_BUILD=1
# --help
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
SCHEME="${IOS_SCHEME:-beenvoice}"
WORKSPACE="${IOS_WORKSPACE:-ios/beenvoice.xcworkspace}"
PROJECT="${IOS_PROJECT:-ios/beenvoice.xcodeproj}"
CONFIGURATION="${IOS_CONFIGURATION:-Release}"
DIST_DIR="${IOS_DIST_DIR:-dist/ios-release}"
ARCHIVE_PATH="${IOS_ARCHIVE_PATH:-$DIST_DIR/beenvoice.xcarchive}"
EXPORT_DIR="${IOS_EXPORT_DIR:-$DIST_DIR/export}"
SCRIPT_DIR="$ROOT/scripts/ios-release"
DO_UPLOAD=0
ARCHIVE_ONLY=0
EXPORT_ONLY=0
SKIP_PREBUILD="${IOS_SKIP_PREBUILD:-0}"
NO_BUMP=0
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
}
for arg in "$@"; do
case "$arg" in
--upload) DO_UPLOAD=1 ;;
--archive-only) ARCHIVE_ONLY=1 ;;
--export-only) EXPORT_ONLY=1 ;;
--no-prebuild) SKIP_PREBUILD=1 ;;
--no-bump) NO_BUMP=1 ;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown option: $arg" >&2
usage >&2
exit 1
;;
esac
done
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: iOS release must run on macOS with Xcode installed." >&2
exit 1
fi
if ! command -v xcodebuild >/dev/null 2>&1; then
echo "Error: xcodebuild not found. Install Xcode from the App Store." >&2
exit 1
fi
if [[ -f "$ROOT/.ios-release.env" ]]; then
# shellcheck disable=SC1091
set -a
source "$ROOT/.ios-release.env"
set +a
fi
require_var() {
if [[ -z "${!1:-}" ]]; then
echo "Error: $1 is required. Set it in .ios-release.env or the environment." >&2
exit 1
fi
}
load_api_auth_args() {
API_AUTH_ARGS=()
if [[ -n "${APP_STORE_CONNECT_API_KEY_ID:-}" ]]; then
require_var APP_STORE_CONNECT_API_ISSUER_ID
require_var APP_STORE_CONNECT_API_KEY_PATH
if [[ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]]; then
echo "Error: API key not found at $APP_STORE_CONNECT_API_KEY_PATH" >&2
exit 1
fi
API_AUTH_ARGS=(
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH"
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID"
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
)
fi
}
write_export_plist() {
local template="$1"
local dest="$2"
require_var APPLE_TEAM_ID
sed "s/__TEAM_ID__/$APPLE_TEAM_ID/g" "$template" >"$dest"
}
check_distribution_signing() {
if security find-identity -v -p codesigning 2>/dev/null | grep -q 'Apple Distribution'; then
return 0
fi
echo "" >&2
echo "Error: No local \"Apple Distribution\" signing certificate found." >&2
echo "App Store export needs a distribution cert (development-only certs are not enough)." >&2
echo "" >&2
echo "Fix in Xcode:" >&2
echo " 1. Xcode → Settings → Accounts" >&2
echo " 2. Select your Apple ID → team $APPLE_TEAM_ID → Manage Certificates…" >&2
echo " 3. Click + → Apple Distribution" >&2
echo " 4. Re-run: bun run ios:release:upload" >&2
echo "" >&2
echo "If + is disabled, your Apple Developer role may not allow creating certs." >&2
echo "Ask the Account Holder to add you as Admin, or create the distribution cert for you." >&2
echo "" >&2
echo "Installed signing identities:" >&2
security find-identity -v -p codesigning 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
}
print_profile_mismatch_help() {
echo "" >&2
echo "Export failed: App Store provisioning profiles don't include your distribution certificate." >&2
echo "This usually happens right after creating a new Apple Distribution cert." >&2
echo "" >&2
echo "Fix (pick one):" >&2
echo "" >&2
echo "A) developer.apple.com → Profiles" >&2
echo " • Open each App Store profile for:" >&2
echo " - com.beenvoice.app" >&2
echo " - com.beenvoice.app.ExpoWidgetsTarget" >&2
echo " • Edit → select your current Apple Distribution certificate → Save"
echo " • Save (regenerates the profile)" >&2
echo "" >&2
echo "B) Xcode → Settings → Accounts → team $APPLE_TEAM_ID" >&2
echo " • Manage Certificates… → revoke duplicate/old Apple Distribution certs" >&2
echo " • Download Manual Profiles" >&2
echo "" >&2
echo "Then re-archive (export-only is not enough after cert/profile changes):" >&2
echo " bun run ios:release:upload" >&2
echo "" >&2
if [[ ${#API_AUTH_ARGS[@]} -eq 0 ]]; then
echo "Tip: set App Store Connect API credentials in .ios-release.env so export can" >&2
echo "refresh profiles via cloud signing (-allowProvisioningUpdates)." >&2
echo "" >&2
fi
}
resolve_xcode_workspace() {
if [[ -d "$ROOT/$WORKSPACE" ]]; then
XCODE_ARGS=(-workspace "$ROOT/$WORKSPACE")
elif [[ -d "$ROOT/$PROJECT" ]]; then
echo "Note: $WORKSPACE missing — using $PROJECT (run pod install if linking fails)."
XCODE_ARGS=(-project "$ROOT/$PROJECT")
else
echo "Error: No Xcode workspace or project under ios/. Run: bunx expo prebuild --platform ios" >&2
exit 1
fi
}
prepare_native_project() {
if [[ "$SKIP_PREBUILD" != "1" && "$EXPORT_ONLY" != "1" ]]; then
echo "==> Syncing native iOS project (expo prebuild)…"
bunx expo prebuild --platform ios
fi
echo "==> Installing CocoaPods…"
(
cd "$ROOT/ios"
if command -v pod >/dev/null 2>&1; then
pod install
else
bunx pod-install
fi
)
resolve_xcode_workspace
}
bump_build_number() {
if [[ "$NO_BUMP" == "1" ]]; then
return
fi
if [[ "${IOS_BUMP_BUILD:-0}" != "1" ]]; then
return
fi
echo "==> Incrementing iOS build number in app.json…"
local next_build
next_build="$(
node -e "
const fs = require('fs');
const path = '$ROOT/app.json';
const app = JSON.parse(fs.readFileSync(path, 'utf8'));
const ios = app.expo.ios ?? (app.expo.ios = {});
const current = Number.parseInt(ios.buildNumber ?? '0', 10);
const next = Number.isFinite(current) ? current + 1 : 1;
ios.buildNumber = String(next);
fs.writeFileSync(path, JSON.stringify(app, null, 2) + '\n');
process.stdout.write(String(next));
"
)"
echo "Build number: $next_build (expo.ios.buildNumber)"
}
read_ipa_build_number() {
local ipa="$1"
local tmp plist
tmp="$(mktemp -d)"
plist="$tmp/Info.plist"
if ! unzip -q -j "$ipa" "Payload/*.app/Info.plist" -d "$tmp" 2>/dev/null; then
rm -rf "$tmp"
return 1
fi
plutil -extract CFBundleVersion raw "$plist" 2>/dev/null
rm -rf "$tmp"
}
archive_app() {
mkdir -p "$(dirname "$ARCHIVE_PATH")"
export EXPO_PUBLIC_API_URL="${EXPO_PUBLIC_API_URL:-https://beenvoice.soconnor.dev}"
load_api_auth_args
echo "==> Archiving (EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL)…"
# Release archive for generic iOS devices (App Store).
xcodebuild \
"${XCODE_ARGS[@]}" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=iOS" \
-allowProvisioningUpdates \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
"${API_AUTH_ARGS[@]}" \
archive
local signing_identity
signing_identity="$(
plutil -extract ApplicationProperties.SigningIdentity raw "$ARCHIVE_PATH/Info.plist" 2>/dev/null || true
)"
if [[ -n "$signing_identity" && "$signing_identity" != *"Distribution"* ]]; then
echo "Note: archive signed with \"$signing_identity\" (export re-signs for App Store)."
fi
echo "Archive: $ARCHIVE_PATH (signed: ${signing_identity:-unknown})"
}
export_ipa() {
require_var APPLE_TEAM_ID
check_distribution_signing
load_api_auth_args
mkdir -p "$EXPORT_DIR"
local export_plist="$DIST_DIR/ExportOptions.plist"
write_export_plist "$SCRIPT_DIR/ExportOptions.appstore.plist" "$export_plist"
echo "==> Exporting App Store IPA…"
set +e
local export_log
export_log="$(mktemp)"
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$export_plist" \
-allowProvisioningUpdates \
"${API_AUTH_ARGS[@]}" 2>&1 | tee "$export_log"
local export_status=${PIPESTATUS[0]}
set -e
if [[ "$export_status" -ne 0 ]]; then
if grep -q "doesn't include signing certificate" "$export_log" \
|| grep -q "Cloud signing permission error" "$export_log"; then
print_profile_mismatch_help
fi
rm -f "$export_log"
exit "$export_status"
fi
rm -f "$export_log"
local ipa
ipa="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' | head -1)"
if [[ -z "$ipa" ]]; then
echo "Error: export finished but no .ipa was found in $EXPORT_DIR" >&2
exit 1
fi
echo "IPA: $ipa"
UPLOAD_IPA="$ipa"
}
upload_to_connect() {
load_api_auth_args
if [[ ${#API_AUTH_ARGS[@]} -eq 0 ]]; then
echo "Error: Upload requires App Store Connect API credentials in .ios-release.env" >&2
echo " APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_ISSUER_ID, APP_STORE_CONNECT_API_KEY_PATH" >&2
exit 1
fi
if [[ -z "${UPLOAD_IPA:-}" || ! -f "$UPLOAD_IPA" ]]; then
echo "Error: No IPA to upload. Run export first." >&2
exit 1
fi
local ipa_build
ipa_build="$(read_ipa_build_number "$UPLOAD_IPA" || true)"
if [[ -n "$ipa_build" ]]; then
echo "Uploading build ${ipa_build}..."
fi
echo "==> Uploading IPA to App Store Connect (altool)…"
set +e
local upload_log
upload_log="$(mktemp)"
xcrun altool --upload-app \
--type ios \
--file "$UPLOAD_IPA" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
--apiKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" 2>&1 | tee "$upload_log"
local upload_status=${PIPESTATUS[0]}
set -e
if [[ "$upload_status" -ne 0 ]]; then
if grep -q "bundle version must be higher" "$upload_log"; then
echo "" >&2
echo "Upload failed: build ${ipa_build:-?} was already uploaded." >&2
echo "Bump expo.ios.buildNumber in app.json (now 6+), then re-run a full release:" >&2
echo " bun run ios:release:upload" >&2
echo "" >&2
echo "Do not use --export-only — that re-exports an old archive with the same build number." >&2
fi
rm -f "$upload_log"
exit "$upload_status"
fi
rm -f "$upload_log"
echo "Upload complete. Processing continues in App Store Connect → TestFlight."
}
main() {
require_var APPLE_TEAM_ID
if [[ "$EXPORT_ONLY" != "1" ]]; then
bump_build_number
prepare_native_project
archive_app
else
resolve_xcode_workspace
if [[ ! -d "$ARCHIVE_PATH" ]]; then
echo "Error: archive not found at $ARCHIVE_PATH" >&2
exit 1
fi
fi
if [[ "$ARCHIVE_ONLY" == "1" ]]; then
echo "Done (archive only)."
exit 0
fi
if [[ "$DO_UPLOAD" == "1" ]]; then
export_ipa
upload_to_connect
else
export_ipa
echo ""
echo "Next: bun run ios:release:upload --export-only"
echo " or open Transporter and drop the IPA from $EXPORT_DIR"
fi
echo "Done."
}
main
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Template — scripts/ios-release.sh replaces __TEAM_ID__ at runtime.
method app-store-connect exports a signed IPA for App Store / TestFlight.
-->
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>__TEAM_ID__</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Template — scripts/ios-release.sh replaces __TEAM_ID__ at runtime.
Exports and uploads directly to App Store Connect (no separate IPA step).
-->
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>upload</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>__TEAM_ID__</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>