diff --git a/.gitignore b/.gitignore index da9569c..87712f0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ expo-env.d.ts *.p12 *.key *.mobileprovision +.ios-release.env +dist/ios-release/ # Metro .metro-health-check* diff --git a/.ios-release.env.example b/.ios-release.env.example new file mode 100644 index 0000000..aabdcf2 --- /dev/null +++ b/.ios-release.env.example @@ -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 diff --git a/README.md b/README.md index 6e9d76b..d7d3168 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.json b/app.json index ef74f72..bc12550 100644 --- a/app.json +++ b/app.json @@ -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 diff --git a/app/(app)/invoices/[id].tsx b/app/(app)/invoices/[id].tsx index 243140e..162fb2d 100644 --- a/app/(app)/invoices/[id].tsx +++ b/app/(app)/invoices/[id].tsx @@ -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("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() { + + + {section === "preview" ? ( + + + + ) : ( + <> @@ -222,20 +248,14 @@ export default function InvoiceDetailScreen() { ))} - - - {invoice.taxRate > 0 ? ( - - ) : null} - - + 0 ? `Tax (${invoice.taxRate}%)` : undefined} + taxAmount={ + invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined + } + total={formatCurrency(invoice.totalAmount, invoice.currency)} + /> {invoice.notes ? ( @@ -281,6 +301,8 @@ export default function InvoiceDetailScreen() { } /> + + )} ); @@ -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 ( - - - {label} - - - {value} - - - ); -} - 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, diff --git a/app/(app)/invoices/edit/[id].tsx b/app/(app)/invoices/edit/[id].tsx index f1221b5..4c0ae00 100644 --- a/app/(app)/invoices/edit/[id].tsx +++ b/app/(app)/invoices/edit/[id].tsx @@ -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(null); const [items, setItems] = useState([]); - const [expandedIndex, setExpandedIndex] = useState(null); + const [section, setSection] = useState("edit"); const [error, setError] = useState(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 ; } @@ -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() { {invoice.client?.name ?? "Client"} + + + {section === "preview" ? ( + + + + ) : ( + <> {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. - ) : null} + ) : ( + + )} {items.map((item, index) => ( - 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() { ) : null} - - - {taxRate > 0 ? ( - - ) : null} - - + 0 ? `Tax (${taxRate}%)` : undefined} + taxAmount={taxRate > 0 ? formatCurrency(taxAmount, currency) : undefined} + total={formatCurrency(total, currency)} + /> {lineItemsError ? {lineItemsError} : null} @@ -331,67 +351,14 @@ export default function InvoiceEditScreen() { /> ) : null} + + )} ); } -function TotalRow({ - label, - value, - bold, -}: { - label: string; - value: string; - bold?: boolean; -}) { - const { colors } = useAppTheme(); - return ( - - - {label} - - - {value} - - - ); -} - -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, diff --git a/app/(app)/invoices/new.tsx b/app/(app)/invoices/new.tsx index 871af58..0ab41f0 100644 --- a/app/(app)/invoices/new.tsx +++ b/app/(app)/invoices/new.tsx @@ -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(0); + const [section, setSection] = useState("edit"); const [error, setError] = useState(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" > + + + {section === "preview" ? ( + + + + ) : ( + <> {clientOptions.length === 0 ? ( @@ -262,15 +285,14 @@ export default function NewInvoiceScreen() { + {items.map((item, index) => ( - 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() { + Add line - - - {parsedTaxRate > 0 ? ( - - ) : null} - - + 0 ? `Tax (${parsedTaxRate}%)` : undefined} + taxAmount={ + parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined + } + total={formatCurrency(total, currency)} + /> {lineItemsError ? {lineItemsError} : null} @@ -301,67 +321,14 @@ export default function NewInvoiceScreen() { disabled={!canCreate} onPress={handleCreate} /> + + )} ); } -function TotalRow({ - label, - value, - bold, -}: { - label: string; - value: string; - bold?: boolean; -}) { - const { colors } = useAppTheme(); - return ( - - - {label} - - - {value} - - - ); -} - -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, diff --git a/app/_layout.tsx b/app/_layout.tsx index dc49358..37c9956 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 }) { + {children} diff --git a/bun.lock b/bun.lock index 9b95e38..08d2bad 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/components/ShortcutHandler.tsx b/components/ShortcutHandler.tsx index 672c5e4..47af4e7 100644 --- a/components/ShortcutHandler.tsx +++ b/components/ShortcutHandler.tsx @@ -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(null); - const pendingRef = useRef(null); + const [pending, setPending] = useState(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, diff --git a/components/ShortcutLinkCapture.tsx b/components/ShortcutLinkCapture.tsx new file mode 100644 index 0000000..4dff311 --- /dev/null +++ b/components/ShortcutLinkCapture.tsx @@ -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; +} diff --git a/components/invoices/InvoiceEditorSectionTabs.tsx b/components/invoices/InvoiceEditorSectionTabs.tsx new file mode 100644 index 0000000..3220a72 --- /dev/null +++ b/components/invoices/InvoiceEditorSectionTabs.tsx @@ -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 ( + + + onChange("edit")} + /> + onChange("preview")} + /> + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + gap: spacing.sm, + paddingVertical: spacing.xs, + }, +}); diff --git a/components/invoices/InvoicePdfPreview.tsx b/components/invoices/InvoicePdfPreview.tsx new file mode 100644 index 0000000..0ca175b --- /dev/null +++ b/components/invoices/InvoicePdfPreview.tsx @@ -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; +}; + +function buildPdfHtml(contentType: string, base64: string) { + return ` + + + + + + + + +`; +} + +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 ( + + + Select a client and add a description to every line item to preview the + PDF. + + + ); + } + + if (isLoading && !html) { + return ( + + + Generating preview… + + ); + } + + if (error) { + return ( + + {error.message} + void refetch()}> + Try again + + + ); + } + + if (!html) { + return ( + + PDF preview will appear here. + + ); + } + + return ( + + {isFetching ? ( + + + + ) : null} + + + + + ); +} + +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, + }, + }); diff --git a/components/invoices/InvoiceTotals.tsx b/components/invoices/InvoiceTotals.tsx new file mode 100644 index 0000000..85571e7 --- /dev/null +++ b/components/invoices/InvoiceTotals.tsx @@ -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 ( + + + {taxLabel && taxAmount ? : null} + + + ); +} + +function TotalRow({ + label, + value, + bold, +}: { + label: string; + value: string; + bold?: boolean; +}) { + const { colors } = useAppTheme(); + + return ( + + + {label} + + + {value} + + + ); +} + +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, + }, +}); diff --git a/components/invoices/LineItemEditor.tsx b/components/invoices/LineItemEditor.tsx index 8f860f4..3ba4572 100644 --- a/components/invoices/LineItemEditor.tsx +++ b/components/invoices/LineItemEditor.tsx @@ -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) => void; onRemove: () => void; readOnly?: boolean; + isLast?: boolean; }; +export function LineItemsTableHeader() { + const { colors } = useAppTheme(); + + return ( + + + Description + + + Date + + + Hrs + + + Rate + + + Amt + + + + ); +} + 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 ( - [styles.row, borderStyle, pressed && !readOnly && styles.rowPressed]} + - - + {index + 1} + + {item.description.trim() || "Untitled line"} - - {formatDate(item.date)} · {hours}h × {formatCurrency(rate, currency)} + + {formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)} - + {formatCurrency(amount, currency)} - {!readOnly ? ( - - ) : null} - + ); } return ( - - - Line item - - - + + + {index + 1} + onChange({ description })} + placeholder="What was done?" + placeholderTextColor={colors.mutedForeground} + style={[ + styles.descriptionInput, + { + color: colors.foreground, + borderColor: colors.border, + backgroundColor: colors.cardGlass, + }, + ]} + /> - onChange({ description })} - placeholder="What was done" - /> - - - - onChange({ hours })} - placeholder="0" - /> - - - + onChange({ date })} + style={styles.dateField} + /> + onChange({ hours })} + step={0.25} + style={styles.hoursField} + /> + + $ + onChange({ rate })} keyboardType="decimal-pad" placeholder="0" + placeholderTextColor={colors.mutedForeground} + style={[styles.rateInput, { color: colors.foreground }]} /> - - - onChange({ date })} - /> - - - + {formatCurrency(amount, currency)} - - - Remove + [styles.remove, pressed && styles.removePressed]} + > + ); } +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, }, }); diff --git a/components/ui/CompactDateField.tsx b/components/ui/CompactDateField.tsx new file mode 100644 index 0000000..8275ae7 --- /dev/null +++ b/components/ui/CompactDateField.tsx @@ -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; + 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 ( + <> + { + setDraft(value); + setOpen(true); + }} + style={({ pressed }) => [ + styles.trigger, + { + borderColor: colors.border, + backgroundColor: colors.cardGlass, + }, + pressed && styles.pressed, + style, + ]} + > + + {formatShortDate(value)} + + + + + {Platform.OS === "ios" ? ( + setOpen(false)}> + setOpen(false)}> + event.stopPropagation()} + > + + setOpen(false)}> + Cancel + + Date + { + applyDate(draft); + setOpen(false); + }} + > + Done + + + + + + + ) : open ? ( + + ) : 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, + }, +}); diff --git a/components/ui/CompactStepperInput.tsx b/components/ui/CompactStepperInput.tsx new file mode 100644 index 0000000..4deb9b9 --- /dev/null +++ b/components/ui/CompactStepperInput.tsx @@ -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; +}; + +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 ( + + adjust(-step)} + style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]} + > + + + + adjust(step)} + style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]} + > + + + + ); +} + +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, + }, +}); diff --git a/contexts/AppLockContext.tsx b/contexts/AppLockContext.tsx index e0ee414..9c64f85 100644 --- a/contexts/AppLockContext.tsx +++ b/contexts/AppLockContext.tsx @@ -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; } diff --git a/docs/APP_STORE_CONNECT.md b/docs/APP_STORE_CONNECT.md index d6c8c4b..4b76496 100644 --- a/docs/APP_STORE_CONNECT.md +++ b/docs/APP_STORE_CONNECT.md @@ -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 --- diff --git a/docs/IOS_LOCAL_RELEASE.md b/docs/IOS_LOCAL_RELEASE.md new file mode 100644 index 0000000..2b00c8b --- /dev/null +++ b/docs/IOS_LOCAL_RELEASE.md @@ -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 Apple’s [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 | diff --git a/lib/format.ts b/lib/format.ts index fd645ce..103d21b 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -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", diff --git a/lib/invoice-pdf-input.ts b/lib/invoice-pdf-input.ts new file mode 100644 index 0000000..3473370 --- /dev/null +++ b/lib/invoice-pdf-input.ts @@ -0,0 +1,80 @@ +import type { AppRouter } from "beenvoice/server/api/root"; +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; + +export type InvoicePdfPreviewInput = inferRouterInputs["invoices"]["previewPdf"]; +type InvoiceDetail = NonNullable["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); +} diff --git a/lib/shortcut-queue.ts b/lib/shortcut-queue.ts new file mode 100644 index 0000000..e922163 --- /dev/null +++ b/lib/shortcut-queue.ts @@ -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 { + memory = shortcut; + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(shortcut)); + notify(); +} + +export async function peekPendingShortcut(): Promise { + 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 { + 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 { + return (await peekPendingShortcut()) != null; +} diff --git a/lib/shortcuts.ts b/lib/shortcuts.ts index a9a2387..87544e3 100644 --- a/lib/shortcuts.ts +++ b/lib/shortcuts.ts @@ -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, diff --git a/package.json b/package.json index 209ebed..c41c8e0 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/plugins/app-intents/BeenVoiceIntentHelpers.swift b/plugins/app-intents/BeenVoiceIntentHelpers.swift new file mode 100644 index 0000000..b8df8ab --- /dev/null +++ b/plugins/app-intents/BeenVoiceIntentHelpers.swift @@ -0,0 +1,9 @@ +import SwiftUI + +@available(iOS 18.0, *) +enum BeenVoiceIntentHelpers { + @MainActor + static func openDeepLink(_ url: URL) { + EnvironmentValues().openURL(url) + } +} diff --git a/plugins/app-intents/BeenVoiceShortcuts.swift b/plugins/app-intents/BeenVoiceShortcuts.swift index 371a6ad..a7b686a 100644 --- a/plugins/app-intents/BeenVoiceShortcuts.swift +++ b/plugins/app-intents/BeenVoiceShortcuts.swift @@ -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" + ) } } diff --git a/plugins/app-intents/ClockInIntent.swift b/plugins/app-intents/ClockInIntent.swift index 71132da..f6be119 100644 --- a/plugins/app-intents/ClockInIntent.swift +++ b/plugins/app-intents/ClockInIntent.swift @@ -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() } diff --git a/plugins/app-intents/ClockOutIntent.swift b/plugins/app-intents/ClockOutIntent.swift index f0dff9c..8d350a7 100644 --- a/plugins/app-intents/ClockOutIntent.swift +++ b/plugins/app-intents/ClockOutIntent.swift @@ -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() } diff --git a/plugins/app-intents/OpenTimerIntent.swift b/plugins/app-intents/OpenTimerIntent.swift index 75c3925..30dc04a 100644 --- a/plugins/app-intents/OpenTimerIntent.swift +++ b/plugins/app-intents/OpenTimerIntent.swift @@ -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() } diff --git a/plugins/withAppIntents.js b/plugins/withAppIntents.js index 3ad33f7..593273f 100644 --- a/plugins/withAppIntents.js +++ b/plugins/withAppIntents.js @@ -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) { diff --git a/plugins/withAppStoreSigning.js b/plugins/withAppStoreSigning.js new file mode 100644 index 0000000..aa22971 --- /dev/null +++ b/plugins/withAppStoreSigning.js @@ -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; diff --git a/scripts/ios-release.sh b/scripts/ios-release.sh new file mode 100755 index 0000000..f632d29 --- /dev/null +++ b/scripts/ios-release.sh @@ -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 diff --git a/scripts/ios-release/ExportOptions.appstore.plist b/scripts/ios-release/ExportOptions.appstore.plist new file mode 100644 index 0000000..7f043c7 --- /dev/null +++ b/scripts/ios-release/ExportOptions.appstore.plist @@ -0,0 +1,20 @@ + + + + + + method + app-store-connect + signingStyle + automatic + teamID + __TEAM_ID__ + uploadSymbols + + manageAppVersionAndBuildNumber + + + diff --git a/scripts/ios-release/ExportOptions.upload.plist b/scripts/ios-release/ExportOptions.upload.plist new file mode 100644 index 0000000..ee70528 --- /dev/null +++ b/scripts/ios-release/ExportOptions.upload.plist @@ -0,0 +1,22 @@ + + + + + + method + app-store-connect + destination + upload + signingStyle + automatic + teamID + __TEAM_ID__ + uploadSymbols + + manageAppVersionAndBuildNumber + + +