+ var nodes: [String: Any]?
+
+ var body: some View {
+ if let nodes, let node = nodes["banner"] as? [String: Any] {
+ WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
+ } else {
+ EmptyView()
+ }
+ }
+}
+`;
+
+function patchWidgetLiveActivitySwift(source) {
+ const target = ` if #available(iOS 18.0, *) {
+ LiveActivityBanner(context: context, nodes: nodes)
+ } else if let node = nodes["banner"] as? [String: Any] {
+ WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
+ } else {
+ EmptyView()
+ }`;
+ const framedTarget = ` if #available(iOS 18.0, *) {
+ LiveActivityBanner(context: context, nodes: nodes)
+ } else if let node = nodes["banner"] as? [String: Any] {
+ WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ EmptyView()
+ }`;
+ const hardcodedBanner = ` if #available(iOS 18.0, *) {
+ LiveActivityBanner(context: context, nodes: nodes)
+ } else {
+ TimeClockNativeActivityBanner(context: context)
+ }`;
+
+ if (source.includes(target)) {
+ return source;
+ }
+
+ return source.replace(framedTarget, target).replace(hardcodedBanner, target);
+}
+
+/** @type {import('@expo/config-plugins').ConfigPlugin} */
+function withLiveActivityBannerFrame(config) {
+ return withDangerousMod(config, [
+ "ios",
+ async (config) => {
+ const sourceDir = path.join(config.modRequest.projectRoot, widgetsPath);
+ const bannerPath = path.join(sourceDir, "LiveActivityBanner.swift");
+ const activityPath = path.join(sourceDir, "WidgetLiveActivity.swift");
+
+ if (!fs.existsSync(bannerPath) || !fs.existsSync(activityPath)) {
+ throw new Error(`Could not find expo-widgets live activity sources in ${sourceDir}`);
+ }
+
+ fs.writeFileSync(bannerPath, liveActivityBannerSource);
+
+ const activitySource = fs.readFileSync(activityPath, "utf8");
+ const patchedActivitySource = patchWidgetLiveActivitySwift(activitySource);
+ if (patchedActivitySource !== activitySource) {
+ fs.writeFileSync(activityPath, patchedActivitySource);
+ }
+
+ return config;
+ },
+ ]);
+}
+
+module.exports = withLiveActivityBannerFrame;
diff --git a/apps/mobile/plugins/withStableWidgetsChildIdentity.js b/apps/mobile/plugins/withStableWidgetsChildIdentity.js
new file mode 100644
index 0000000..1f9ded2
--- /dev/null
+++ b/apps/mobile/plugins/withStableWidgetsChildIdentity.js
@@ -0,0 +1,256 @@
+// @ts-check
+const fs = require("fs");
+const path = require("path");
+const { withDangerousMod } = require("@expo/config-plugins");
+
+const dynamicViewPath = path.join(
+ "node_modules",
+ "expo-widgets",
+ "ios",
+ "Widgets",
+ "DynamicView.swift",
+);
+
+// expo-widgets rebuilds every WidgetsDynamicView child with a brand-new random UUID
+// on every single render (see the original "Hack to satisfy ExpoSwiftUI.AnyChild"
+// comment), so the `ForEach(props.children, id: \.id)` that HStack/VStack use to
+// render their children sees a totally different identity set on each re-render.
+// SwiftUI then treats every child as removed-and-reinserted instead of updated in
+// place, which can make children silently fail to render whenever the parent's
+// layout is recomputed (e.g. after a `.frame(maxWidth:)` change). This patch gives
+// each child a stable identity derived from its structural position, cached across
+// renders, so ForEach can correctly diff instead of thrashing.
+const patchedSource = `import SwiftUI
+import ExpoModulesCore
+import ExpoUI
+
+// TODO(@jakex7): Hack to satisfy ExpoSwiftUI.AnyChild with random UUID value
+class NodeIdentityWrapper {
+ let id: UUID
+ init(id: UUID) {
+ self.id = id
+ }
+}
+
+// Reuses the same NodeIdentityWrapper for a given structural key across re-renders,
+// so ForEach(id: \\.id) sees a stable identity instead of a fresh UUID every render.
+private final class NodeIdentityCache {
+ static let shared = NodeIdentityCache()
+ private var wrappers: [String: NodeIdentityWrapper] = [:]
+
+ func wrapper(for key: String) -> NodeIdentityWrapper {
+ if let existing = wrappers[key] {
+ return existing
+ }
+ let created = NodeIdentityWrapper(id: UUID())
+ wrappers[key] = created
+ return created
+ }
+}
+
+extension ObjectIdentifier: @retroactive Encodable {
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(String(describing: self))
+ }
+}
+
+public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild {
+ let node: [String: Any]
+ let name: String
+ let kind: WidgetsKind
+ let entryIndex: Int?
+ let environmentString: String?
+ let childKey: String?
+
+ private var uuid: NodeIdentityWrapper {
+ guard let childKey else {
+ return NodeIdentityWrapper(id: UUID())
+ }
+ return NodeIdentityCache.shared.wrapper(for: childKey)
+ }
+ public var id: ObjectIdentifier {
+ ObjectIdentifier(uuid)
+ }
+
+ public init(name: String, kind: WidgetsKind, node: [String: Any]) {
+ self.name = name
+ self.kind = kind
+ self.node = node
+ self.entryIndex = nil
+ self.environmentString = nil
+ self.childKey = node["type"] as? String
+ }
+
+ public init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?) {
+ self.name = name
+ self.kind = kind
+ self.node = node
+ self.entryIndex = entryIndex
+ self.environmentString = environmentString
+ self.childKey = node["type"] as? String
+ }
+
+ private init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?, childKey: String) {
+ self.name = name
+ self.kind = kind
+ self.node = node
+ self.entryIndex = entryIndex
+ self.environmentString = environmentString
+ self.childKey = childKey
+ }
+
+ @ViewBuilder
+ public var body: some View {
+ switch node["type"] as? String {
+ case "TextView":
+ render(TextView.self, TextViewProps.self, updateProps: updateChildren)
+ case "HStackView":
+ render(HStackView.self, HStackViewProps.self, updateProps: updateChildren)
+ case "VStackView":
+ render(VStackView.self, VStackViewProps.self, updateProps: updateChildren)
+ case "ZStackView":
+ render(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren)
+ case "RectangleView":
+ render(RectangleView.self, RectangleViewProps.self)
+ case "RoundedRectangleView":
+ render(RoundedRectangleView.self, RoundedRectangleViewProps.self)
+ case "CapsuleView":
+ render(CapsuleView.self, CapsuleViewProps.self)
+ case "CircleView":
+ render(CircleView.self, CircleViewProps.self)
+ case "ImageView":
+ render(ImageView.self, ImageViewProps.self)
+ case "AccessoryWidgetBackgroundView":
+ render(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self)
+ case "DividerView":
+ render(DividerView.self, DividerProps.self)
+ case "EllipseView":
+ render(EllipseView.self, EllipseViewProps.self)
+ case "LabelView":
+ render(LabelView.self, LabelViewProps.self)
+ case "ProgressView":
+ render(ProgressView.self, ProgressViewProps.self)
+ case "SpacerView":
+ render(SpacerView.self, SpacerViewProps.self)
+ case "UnevenRoundedRectangleView":
+ render(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self)
+ case "GaugeView":
+ render(GaugeView.self, GaugeProps.self)
+ case "ChartView":
+ render(ChartView.self, ChartProps.self)
+ case "Button":
+ if #available(iOS 17.0, *) {
+ switch kind {
+ case .widget:
+ render(WidgetButtonView.self, ButtonProps.self) { buttonProps in
+ try updateChildren(buttonProps)
+ buttonProps.source = name
+ buttonProps.entryIndex = entryIndex
+ buttonProps.environmentString = environmentString
+ }
+ case .liveActivity:
+ render(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in
+ try updateChildren(buttonProps)
+ buttonProps.source = name
+ }
+ }
+ } else {
+ render(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren)
+ }
+ case "react.fragment":
+ render(FragmentView.self, FragmentProps.self, updateProps: updateChildren)
+ case "LinkView":
+ render(LinkView.self, LinkViewProps.self, updateProps: updateChildren)
+#if DEBUG
+ case "RedBoxView":
+ render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in
+ redBoxProps.source = name
+ redBoxProps.kind = kind
+ }
+ default:
+ ZStack {
+ Color.red.opacity(0.5)
+ Text("Unable to get the view for: \\(node["type"] as? String ?? "undefined")")
+ }
+#else
+ default:
+ EmptyView()
+#endif
+ }
+ }
+
+ // MARK: - Render Method
+
+ @ViewBuilder
+ private func render(_ viewType: V.Type, _ propsType: P.Type, updateProps: ((_ initialProps: P) throws -> Void)? = nil) -> some View
+ where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P {
+ // immediately invoked closure {}() here because we can't use 'do-catch' inside @ViewBuilder
+ {
+ do {
+ if let rawProps = node["props"] as? [String: Any] {
+ let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context)
+ try updateProps?(props)
+ return AnyView(UIBaseView
(props: props).transition(.identity))
+ }
+ return AnyView(EmptyView())
+ } catch {
+ return AnyView(EmptyView())
+ }
+ }()
+ }
+
+ // MARK: - Function that sets children as DynamicView
+
+ private func updateChildren
(_ initialProps: P) throws
+ where P: UIBaseViewProps {
+ let baseKey = childKey ?? (node["type"] as? String ?? "root")
+ if let props = node["props"] as? [String: Any] {
+ if let children = props["children"] as? [Any] {
+ let validChildren = children.compactMap { $0 as? [String: Any] }
+ initialProps.children = validChildren.enumerated().map { index, childNode in
+ let childType = childNode["type"] as? String ?? "unknown"
+ return WidgetsDynamicView(
+ name: name,
+ kind: kind,
+ node: childNode,
+ entryIndex: entryIndex,
+ environmentString: environmentString,
+ childKey: "\\(baseKey).\\(index).\\(childType)"
+ )
+ }
+ } else if let child = props["children"] as? [String: Any] {
+ let childType = child["type"] as? String ?? "unknown"
+ initialProps.children = [WidgetsDynamicView(
+ name: name,
+ kind: kind,
+ node: child,
+ entryIndex: entryIndex,
+ environmentString: environmentString,
+ childKey: "\\(baseKey).0.\\(childType)"
+ )]
+ }
+ }
+ }
+}
+`;
+
+/** @type {import('@expo/config-plugins').ConfigPlugin} */
+function withStableWidgetsChildIdentity(config) {
+ return withDangerousMod(config, [
+ "ios",
+ async (config) => {
+ const filePath = path.join(config.modRequest.projectRoot, dynamicViewPath);
+
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`Could not find expo-widgets DynamicView.swift at ${filePath}`);
+ }
+
+ fs.writeFileSync(filePath, patchedSource);
+
+ return config;
+ },
+ ]);
+}
+
+module.exports = withStableWidgetsChildIdentity;
diff --git a/apps/mobile/plugins/withSyncWidgetVersions.js b/apps/mobile/plugins/withSyncWidgetVersions.js
new file mode 100644
index 0000000..dd606bf
--- /dev/null
+++ b/apps/mobile/plugins/withSyncWidgetVersions.js
@@ -0,0 +1,34 @@
+// @ts-check
+const { withXcodeProject } = require("@expo/config-plugins");
+
+/**
+ * expo-widgets seeds ExpoWidgetsTarget with hardcoded MARKETING_VERSION/CURRENT_PROJECT_VERSION
+ * and does not update them on later prebuilds. App Store Connect warns when the extension
+ * does not match the containing app.
+ */
+/** @type {import('@expo/config-plugins').ConfigPlugin} */
+function withSyncWidgetVersions(config) {
+ return withXcodeProject(config, (config) => {
+ const marketingVersion = config.version ?? "1.0.0";
+ const buildNumber = config.ios?.buildNumber ?? "1";
+ const project = config.modResults;
+ const configurations = project.pbxXCBuildConfigurationSection();
+
+ for (const key of Object.keys(configurations)) {
+ const buildConfig = configurations[key];
+ if (!buildConfig?.buildSettings) continue;
+
+ const bundleId = buildConfig.buildSettings.PRODUCT_BUNDLE_IDENTIFIER;
+ if (!bundleId || !String(bundleId).includes("ExpoWidgetsTarget")) {
+ continue;
+ }
+
+ buildConfig.buildSettings.MARKETING_VERSION = marketingVersion;
+ buildConfig.buildSettings.CURRENT_PROJECT_VERSION = buildNumber;
+ }
+
+ return config;
+ });
+}
+
+module.exports = withSyncWidgetVersions;
diff --git a/apps/mobile/scripts/configure-ios-signing.js b/apps/mobile/scripts/configure-ios-signing.js
new file mode 100644
index 0000000..0897fd5
--- /dev/null
+++ b/apps/mobile/scripts/configure-ios-signing.js
@@ -0,0 +1,20 @@
+// @ts-check
+const fs = require("fs");
+const path = require("path");
+const xcode = require("xcode");
+const { configureReleaseSigning } = require("../plugins/withAppStoreSigning");
+
+const projectPath = process.argv[2];
+if (!projectPath) {
+ throw new Error(
+ "Usage: node scripts/configure-ios-signing.js ",
+ );
+}
+
+const resolvedPath = path.resolve(projectPath);
+const project = xcode.project(resolvedPath);
+project.parseSync();
+configureReleaseSigning(project);
+fs.writeFileSync(resolvedPath, project.writeSync());
+
+console.log("Configured manual App Store signing for iOS release targets.");
diff --git a/apps/mobile/scripts/ios-release.sh b/apps/mobile/scripts/ios-release.sh
new file mode 100755
index 0000000..9eebf18
--- /dev/null
+++ b/apps/mobile/scripts/ios-release.sh
@@ -0,0 +1,387 @@
+#!/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
+ )
+
+ if [[ -f "$ROOT/$PROJECT/project.pbxproj" ]]; then
+ node "$ROOT/scripts/configure-ios-signing.js" "$ROOT/$PROJECT/project.pbxproj"
+ 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.app}"
+ 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 \
+ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
+ "${API_AUTH_ARGS[@]}" \
+ archive
+
+ local signing_identity
+ signing_identity="$(
+ plutil -extract ApplicationProperties.SigningIdentity raw "$ARCHIVE_PATH/Info.plist" 2>/dev/null || true
+ )"
+ if [[ -n "$signing_identity" && "$signing_identity" != *"Distribution"* ]]; then
+ echo "Note: archive signed with \"$signing_identity\" (export re-signs for App Store)."
+ fi
+
+ echo "Archive: $ARCHIVE_PATH (signed: ${signing_identity:-unknown})"
+}
+
+export_ipa() {
+ require_var APPLE_TEAM_ID
+ check_distribution_signing
+ load_api_auth_args
+ mkdir -p "$EXPORT_DIR"
+ local export_plist="$DIST_DIR/ExportOptions.plist"
+ write_export_plist "$SCRIPT_DIR/ExportOptions.appstore.plist" "$export_plist"
+
+ echo "==> Exporting App Store IPA…"
+ set +e
+ local export_log
+ export_log="$(mktemp)"
+ xcodebuild \
+ -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_DIR" \
+ -exportOptionsPlist "$export_plist" \
+ -allowProvisioningUpdates \
+ "${API_AUTH_ARGS[@]}" 2>&1 | tee "$export_log"
+ local export_status=${PIPESTATUS[0]}
+ set -e
+
+ if [[ "$export_status" -ne 0 ]]; then
+ if grep -q "doesn't include signing certificate" "$export_log" \
+ || grep -q "Cloud signing permission error" "$export_log"; then
+ print_profile_mismatch_help
+ fi
+ rm -f "$export_log"
+ exit "$export_status"
+ fi
+ rm -f "$export_log"
+
+ local ipa
+ ipa="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' | head -1)"
+ if [[ -z "$ipa" ]]; then
+ echo "Error: export finished but no .ipa was found in $EXPORT_DIR" >&2
+ exit 1
+ fi
+ echo "IPA: $ipa"
+ UPLOAD_IPA="$ipa"
+}
+
+upload_to_connect() {
+ load_api_auth_args
+ if [[ ${#API_AUTH_ARGS[@]} -eq 0 ]]; then
+ echo "Error: Upload requires App Store Connect API credentials in .ios-release.env" >&2
+ echo " APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_ISSUER_ID, APP_STORE_CONNECT_API_KEY_PATH" >&2
+ exit 1
+ fi
+
+ if [[ -z "${UPLOAD_IPA:-}" || ! -f "$UPLOAD_IPA" ]]; then
+ echo "Error: No IPA to upload. Run export first." >&2
+ exit 1
+ fi
+
+ local ipa_build
+ ipa_build="$(read_ipa_build_number "$UPLOAD_IPA" || true)"
+ if [[ -n "$ipa_build" ]]; then
+ echo "Uploading build ${ipa_build}..."
+ fi
+
+ echo "==> Uploading IPA to App Store Connect (altool)…"
+ set +e
+ local upload_log
+ upload_log="$(mktemp)"
+ xcrun altool --upload-app \
+ --type ios \
+ --file "$UPLOAD_IPA" \
+ --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
+ --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
+ --apiKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" 2>&1 | tee "$upload_log"
+ local upload_status=${PIPESTATUS[0]}
+ set -e
+
+ if [[ "$upload_status" -ne 0 ]]; then
+ if grep -q "bundle version must be higher" "$upload_log"; then
+ echo "" >&2
+ echo "Upload failed: build ${ipa_build:-?} was already uploaded." >&2
+ echo "Bump expo.ios.buildNumber in app.json (now 6+), then re-run a full release:" >&2
+ echo " bun run ios:release:upload" >&2
+ echo "" >&2
+ echo "Do not use --export-only — that re-exports an old archive with the same build number." >&2
+ fi
+ rm -f "$upload_log"
+ exit "$upload_status"
+ fi
+ rm -f "$upload_log"
+
+ echo "Upload complete. Processing continues in App Store Connect → TestFlight."
+}
+
+main() {
+ require_var APPLE_TEAM_ID
+
+ if [[ "$EXPORT_ONLY" != "1" ]]; then
+ bump_build_number
+ prepare_native_project
+ archive_app
+ else
+ resolve_xcode_workspace
+ if [[ ! -d "$ARCHIVE_PATH" ]]; then
+ echo "Error: archive not found at $ARCHIVE_PATH" >&2
+ exit 1
+ fi
+ fi
+
+ if [[ "$ARCHIVE_ONLY" == "1" ]]; then
+ echo "Done (archive only)."
+ exit 0
+ fi
+
+ if [[ "$DO_UPLOAD" == "1" ]]; then
+ export_ipa
+ upload_to_connect
+ else
+ export_ipa
+ echo ""
+ echo "Next: bun run ios:release:upload --export-only"
+ echo " or open Transporter and drop the IPA from $EXPORT_DIR"
+ fi
+
+ echo "Done."
+}
+
+main
diff --git a/apps/mobile/scripts/ios-release/ExportOptions.appstore.plist b/apps/mobile/scripts/ios-release/ExportOptions.appstore.plist
new file mode 100644
index 0000000..7f043c7
--- /dev/null
+++ b/apps/mobile/scripts/ios-release/ExportOptions.appstore.plist
@@ -0,0 +1,20 @@
+
+
+
+
+
+ method
+ app-store-connect
+ signingStyle
+ automatic
+ teamID
+ __TEAM_ID__
+ uploadSymbols
+
+ manageAppVersionAndBuildNumber
+
+
+
diff --git a/apps/mobile/scripts/ios-release/ExportOptions.upload.plist b/apps/mobile/scripts/ios-release/ExportOptions.upload.plist
new file mode 100644
index 0000000..ee70528
--- /dev/null
+++ b/apps/mobile/scripts/ios-release/ExportOptions.upload.plist
@@ -0,0 +1,22 @@
+
+
+
+
+
+ method
+ app-store-connect
+ destination
+ upload
+ signingStyle
+ automatic
+ teamID
+ __TEAM_ID__
+ uploadSymbols
+
+ manageAppVersionAndBuildNumber
+
+
+
diff --git a/apps/mobile/simulator-screenshot.png b/apps/mobile/simulator-screenshot.png
new file mode 100644
index 0000000..bc3a901
Binary files /dev/null and b/apps/mobile/simulator-screenshot.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/README.md b/apps/mobile/store-assets/screenshots/v1.0/README.md
new file mode 100644
index 0000000..b954154
--- /dev/null
+++ b/apps/mobile/store-assets/screenshots/v1.0/README.md
@@ -0,0 +1,9 @@
+# App Store screenshots — v1.0
+
+Generated from the current Expo app using the seeded local review workspace.
+
+- `final/iphone-69`: five 1320×2868 screenshots uploaded to the App Store Connect `APP_IPHONE_67` set.
+- `final/ipad-129`: one 2064×2752 screenshot uploaded to the App Store Connect `APP_IPAD_PRO_3GEN_129` set.
+- `raw`: unframed simulator captures used to create the final artwork.
+
+The final artwork uses the Black Titanium iPhone 16 Pro Max and Space Black iPad Pro 13-inch (M5) PNG frames supplied in `~/Downloads`. The frame source files are not duplicated in this repository.
diff --git a/apps/mobile/store-assets/screenshots/v1.0/final/ipad-129/01-dashboard.png b/apps/mobile/store-assets/screenshots/v1.0/final/ipad-129/01-dashboard.png
new file mode 100644
index 0000000..2f88712
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/final/ipad-129/01-dashboard.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/01-dashboard.png b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/01-dashboard.png
new file mode 100644
index 0000000..37a4a4a
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/01-dashboard.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/02-timer.png b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/02-timer.png
new file mode 100644
index 0000000..99d0d05
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/02-timer.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/03-entities.png b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/03-entities.png
new file mode 100644
index 0000000..baa98c4
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/03-entities.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/04-invoices.png b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/04-invoices.png
new file mode 100644
index 0000000..a060e86
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/04-invoices.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/05-more.png b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/05-more.png
new file mode 100644
index 0000000..5ef72a8
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/final/iphone-69/05-more.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/raw/ipad-129/01-dashboard.png b/apps/mobile/store-assets/screenshots/v1.0/raw/ipad-129/01-dashboard.png
new file mode 100644
index 0000000..76c9eea
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/raw/ipad-129/01-dashboard.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/01-dashboard.png b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/01-dashboard.png
new file mode 100644
index 0000000..ef7ece5
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/01-dashboard.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/02-timer.png b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/02-timer.png
new file mode 100644
index 0000000..1269556
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/02-timer.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/03-entities.png b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/03-entities.png
new file mode 100644
index 0000000..ae025e9
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/03-entities.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/04-invoices.png b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/04-invoices.png
new file mode 100644
index 0000000..ee15e5a
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/04-invoices.png differ
diff --git a/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/05-more.png b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/05-more.png
new file mode 100644
index 0000000..e682b58
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/v1.0/raw/iphone-69/05-more.png differ
diff --git a/apps/mobile/svg.d.ts b/apps/mobile/svg.d.ts
new file mode 100644
index 0000000..b6c59ef
--- /dev/null
+++ b/apps/mobile/svg.d.ts
@@ -0,0 +1,6 @@
+declare module "*.svg" {
+ import type { FC } from "react";
+ import type { SvgProps } from "react-native-svg";
+ const content: FC;
+ export default content;
+}
diff --git a/apps/mobile/tests/cross-client-parity.test.ts b/apps/mobile/tests/cross-client-parity.test.ts
new file mode 100644
index 0000000..ad9333e
--- /dev/null
+++ b/apps/mobile/tests/cross-client-parity.test.ts
@@ -0,0 +1,104 @@
+///
+
+import { afterEach, describe, expect, test } from "bun:test";
+
+import { fetchAuthCapabilities } from "../lib/auth-capabilities";
+import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
+import { getInvoiceStatus } from "../lib/invoice-status";
+import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
+import { EXPENSE_CATEGORIES as webExpenseCategories } from "../../beenvoice-web/src/lib/expense-categories";
+import { getEffectiveInvoiceStatus } from "../../beenvoice-web/src/lib/invoice-status";
+import { safeCallbackPath } from "../../beenvoice-web/src/lib/safe-callback-url";
+import {
+ formatElapsedSeconds as formatWebElapsedSeconds,
+ normalizeOptionalId,
+} from "../../beenvoice-web/src/lib/time-clock";
+
+const originalFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+describe("auth contract smoke checks", () => {
+ test("mobile reads the server capabilities payload", async () => {
+ globalThis.fetch = (async (input) => {
+ expect(String(input)).toBe("https://beenvoice.app/api/auth/capabilities");
+ return Response.json({ authentik: true, signupsDisabled: true });
+ }) as typeof fetch;
+
+ await expect(
+ fetchAuthCapabilities("https://beenvoice.app/"),
+ ).resolves.toEqual({
+ authentik: true,
+ signupsDisabled: true,
+ });
+ });
+
+ test("web auth callbacks stay on the same origin", () => {
+ expect(safeCallbackPath("/dashboard/invoices?status=sent")).toBe(
+ "/dashboard/invoices?status=sent",
+ );
+ expect(safeCallbackPath("https://attacker.example")).toBe("/dashboard");
+ expect(safeCallbackPath("//attacker.example")).toBe("/dashboard");
+ });
+});
+
+describe("invoice parity", () => {
+ test("web and mobile agree on draft, paid, sent, and overdue states", () => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const fixtures = [
+ {
+ stored: "draft" as const,
+ dueDate: yesterday,
+ expected: "draft" as const,
+ },
+ {
+ stored: "paid" as const,
+ dueDate: yesterday,
+ expected: "paid" as const,
+ },
+ { stored: "sent" as const, dueDate: today, expected: "sent" as const },
+ {
+ stored: "sent" as const,
+ dueDate: yesterday,
+ expected: "overdue" as const,
+ },
+ ];
+
+ for (const fixture of fixtures) {
+ expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
+ fixture.expected,
+ );
+ expect(
+ getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }),
+ ).toBe(fixture.expected);
+ }
+ });
+});
+
+describe("timer parity", () => {
+ test("elapsed time formatting is identical", () => {
+ for (const seconds of [0, 59, 60, 3_661, 86_399]) {
+ expect(formatAppElapsedSeconds(seconds)).toBe(
+ formatWebElapsedSeconds(seconds),
+ );
+ }
+ });
+
+ test("server normalizes optional client identifiers", () => {
+ expect(normalizeOptionalId(undefined)).toBeNull();
+ expect(normalizeOptionalId(" ")).toBeNull();
+ expect(normalizeOptionalId(" client-1 ")).toBe("client-1");
+ });
+});
+
+describe("expense parity", () => {
+ test("web and mobile expose the same category vocabulary", () => {
+ expect([...appExpenseCategories]).toEqual([...webExpenseCategories]);
+ });
+});
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
new file mode 100644
index 0000000..eab282a
--- /dev/null
+++ b/apps/mobile/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "skipLibCheck": true,
+ "paths": {
+ "@/*": ["./*"],
+ "~/*": ["../beenvoice-web/src/*"],
+ "src/*": ["../beenvoice-web/src/*"],
+ "beenvoice/*": ["../beenvoice-web/src/*"]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".expo/types/**/*.ts",
+ "expo-env.d.ts"
+ ]
+}
diff --git a/apps/mobile/widgets/TimeClockActivity.tsx b/apps/mobile/widgets/TimeClockActivity.tsx
new file mode 100644
index 0000000..9b19963
--- /dev/null
+++ b/apps/mobile/widgets/TimeClockActivity.tsx
@@ -0,0 +1,175 @@
+import { HStack, Image, Text, VStack } from "@expo/ui/swift-ui";
+import {
+ frame,
+ font,
+ foregroundStyle,
+ layoutPriority,
+ lineLimit,
+ minimumScaleFactor,
+ monospacedDigit,
+ padding,
+ truncationMode,
+ widgetAccentedRenderingMode,
+} from "@expo/ui/swift-ui/modifiers";
+import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
+
+import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
+
+function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
+ "widget";
+
+ const island = "#FFFFFF";
+ const businessLabel = props.businessName.trim();
+ const clientLabel = props.clientName.trim();
+ const title = clientLabel || businessLabel || "Clock In";
+
+ const timerMods = [
+ font({ weight: "bold", size: 17 }),
+ foregroundStyle({ type: "hierarchical", style: "primary" }),
+ lineLimit(1),
+ minimumScaleFactor(0.75),
+ layoutPriority(2),
+ frame({ width: 100, alignment: "center" }),
+ ];
+ // A live timer Text reserves a large ideal width (for the H:MM:SS format at
+ // the Dynamic Island's font), so with no width cap it inflates the compact
+ // pill to full width. Pin it to a snug fixed width sized for the common
+ // MM:SS case; minimumScaleFactor lets the occasional H:MM:SS scale down to
+ // fit instead of widening the pill. This keeps the pill tight with no gap.
+ const compactTimerMods = [
+ font({ design: "monospaced", weight: "semibold", size: 11 }),
+ monospacedDigit(),
+ foregroundStyle(island),
+ lineLimit(1),
+ minimumScaleFactor(0.6),
+ frame({ width: 40, alignment: "center" }),
+ ];
+ const clientMods = [
+ font({ weight: "bold", size: 17 }),
+ foregroundStyle({ type: "hierarchical", style: "primary" }),
+ lineLimit(1),
+ truncationMode("tail"),
+ ];
+ // Avoid greedy layout primitives here: the live timerInterval Text can
+ // collapse when paired with Spacer/maxWidth. Fixed centered boxes keep the
+ // content stable without forcing left/right edge alignment.
+ const titleRowMods = [
+ layoutPriority(1),
+ frame({ width: 140, alignment: "center" }),
+ ];
+ const sideZoneMods = [
+ frame({ width: 100, alignment: "center" }),
+ ];
+ const bannerRowMods = [
+ padding({ horizontal: 10, vertical: 10 }),
+ frame({ maxWidth: Infinity, alignment: "center" }),
+ ];
+
+ const startedAt = new Date(props.startedAtMs);
+ // Bounded to 24h so SwiftUI reserves width for the H:MM:SS format only.
+ // (An open-ended `.timer`/`.date` style reserves width for a huge duration,
+ // which blows the compact Dynamic Island pill out to full width.)
+ const timerRange = {
+ lower: startedAt,
+ upper: new Date(props.startedAtMs + 24 * 60 * 60 * 1000),
+ };
+
+ // Banner (Notification Center / Lock Screen): timerInterval inside a fixed
+ // 100pt box, which absorbs the reserved width.
+ const bannerTimer = (
+
+ );
+ // Dynamic Island (compact): same bounded timerInterval, sized intrinsically —
+ // no fixed/minWidth frame or layoutPriority, so the pill hugs the reserved
+ // H:MM:SS width instead of being padded out or stretched.
+ const compactTimer = (
+
+ );
+ const logoLarge = (
+
+ );
+ const logoSmall = (
+
+ );
+
+ // Apple Watch / CarPlay (`bannerSmall`). The strip is only ~150-180pt wide,
+ // so it cannot use the iPhone banner's fixed 100/140/100 columns (those
+ // overflow and push the timer off-screen, leaving just its first digit).
+ // Instead: fixed-size logo, a flexible client name that truncates to fill
+ // the leftover space, and a compact fixed-width timer that stays fully
+ // visible (fixed width — not maxWidth — so the live timer text doesn't
+ // collapse).
+ const watchRowMods = [
+ padding({ horizontal: 10, vertical: 6 }),
+ frame({ maxWidth: Infinity, alignment: "center" }),
+ ];
+ const watchTitleMods = [
+ font({ weight: "semibold", size: 14 }),
+ foregroundStyle({ type: "hierarchical", style: "primary" }),
+ lineLimit(1),
+ truncationMode("tail"),
+ frame({ maxWidth: Infinity, alignment: "leading" }),
+ ];
+ const watchTimerMods = [
+ font({ design: "monospaced", weight: "bold", size: 14 }),
+ monospacedDigit(),
+ foregroundStyle({ type: "hierarchical", style: "primary" }),
+ lineLimit(1),
+ minimumScaleFactor(0.6),
+ frame({ width: 58, alignment: "trailing" }),
+ ];
+ const watchLogo = (
+
+ );
+ const watchTimer = (
+
+ );
+ return {
+ banner: (
+
+
+ {logoLarge}
+
+
+ {title}
+
+ {bannerTimer}
+
+ ),
+ bannerSmall: (
+
+ {watchLogo}
+ {title}
+ {watchTimer}
+
+ ),
+ compactLeading: logoSmall,
+ compactTrailing: compactTimer,
+ minimal: logoSmall,
+ };
+}
+
+export default createLiveActivity("TimeClockActivity", TimeClockActivity);