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:
@@ -17,6 +17,8 @@ expo-env.d.ts
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
.ios-release.env
|
||||
dist/ios-release/
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
Executable
+384
@@ -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>
|
||||
Reference in New Issue
Block a user