From d3b73464e4833d1d19c75266e21a8660f66004ba Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 17 Jun 2026 23:39:01 -0400 Subject: [PATCH] Polish Live Activity branding and add EAS build config. Use brand mark and wordmark images in the time clock Live Activity, migrate file copies to the modern expo-file-system File API, and add eas.json for TestFlight production builds. Co-authored-by: Cursor --- .env.example | 1 + .gitignore | 1 + app/_layout.tsx | 9 ++- eas.json | 30 ++++++++ lib/time-clock-live-activity.ts | 5 ++ lib/time-clock-live-activity.types.ts | 4 + lib/widget-brand-assets.ts | 55 ++++++++++++++ widgets/TimeClockActivity.tsx | 103 +++++++++++++++++--------- 8 files changed, 172 insertions(+), 36 deletions(-) create mode 100644 eas.json create mode 100644 lib/widget-brand-assets.ts diff --git a/.env.example b/.env.example index b06b31e..671d4e4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ # beenvoice API base URL (no trailing slash) +# Omit or leave unset in production builds — app defaults to https://beenvoice.soconnor.dev # Local dev on physical iPhone: use your Mac's LAN IP, e.g. http://192.168.1.42:3000 EXPO_PUBLIC_API_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index d914c32..da9569c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ yarn-error.* *.pem # local env files +.env .env*.local # typescript diff --git a/app/_layout.tsx b/app/_layout.tsx index e4f179f..b7eed02 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,7 +12,7 @@ import { import { useFonts } from "expo-font"; import * as SplashScreen from "expo-splash-screen"; import { useEffect, type ReactNode } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { StatusBar } from "expo-status-bar"; import "react-native-reanimated"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -23,6 +23,7 @@ import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext"; import { AuthProvider, useSession } from "@/contexts/AuthContext"; import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext"; import { TRPCProvider } from "@/lib/trpc"; +import { ensureWidgetBrandAssets } from "@/lib/widget-brand-assets"; export { ErrorBoundary } from "expo-router"; @@ -76,6 +77,12 @@ export default function RootLayout() { } }, [loaded]); + useEffect(() => { + if (Platform.OS === "ios") { + void ensureWidgetBrandAssets(); + } + }, []); + if (!loaded) { return null; } diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..60744e8 --- /dev/null +++ b/eas.json @@ -0,0 +1,30 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "ios": { + "simulator": false + } + }, + "preview": { + "distribution": "internal", + "ios": { + "simulator": false + } + }, + "production": { + "autoIncrement": true, + "ios": { + "resourceClass": "m-medium" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/lib/time-clock-live-activity.ts b/lib/time-clock-live-activity.ts index e0f344d..e3ca3a3 100644 --- a/lib/time-clock-live-activity.ts +++ b/lib/time-clock-live-activity.ts @@ -3,6 +3,7 @@ import { Platform } from "react-native"; import { formatElapsedHoursMinutes, formatElapsedSeconds } from "@/lib/time-clock"; import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types"; +import { ensureWidgetBrandAssets, getWidgetBrandAssetUris } from "@/lib/widget-brand-assets"; type RunningEntry = { description: string; @@ -54,6 +55,7 @@ export function buildTimeClockActivityProps( elapsedSeconds: number, ): TimeClockActivityProps { const invoice = running.invoice; + const brand = getWidgetBrandAssetUris(); return { elapsed: formatElapsedSeconds(elapsedSeconds), elapsedShort: formatElapsedHoursMinutes(elapsedSeconds), @@ -66,6 +68,8 @@ export function buildTimeClockActivityProps( invoiceLabel: invoice ? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}` : "", + markImageUri: brand?.markUri, + logoImageUri: brand?.logoUri, }; } @@ -82,6 +86,7 @@ export async function syncTimeClockLiveActivity( } try { + await ensureWidgetBrandAssets(); const props = buildTimeClockActivityProps(running, elapsedSeconds); const instances = factory.getInstances(); diff --git a/lib/time-clock-live-activity.types.ts b/lib/time-clock-live-activity.types.ts index cc83127..b79d2a6 100644 --- a/lib/time-clock-live-activity.types.ts +++ b/lib/time-clock-live-activity.types.ts @@ -8,4 +8,8 @@ export type TimeClockActivityProps = { description: string; clientName: string; invoiceLabel: string; + /** file:// URI to square dollar mark in the app-group widgets folder */ + markImageUri?: string; + /** file:// URI to wordmark PNG in the app-group widgets folder */ + logoImageUri?: string; }; diff --git a/lib/widget-brand-assets.ts b/lib/widget-brand-assets.ts new file mode 100644 index 0000000..b2d3c0a --- /dev/null +++ b/lib/widget-brand-assets.ts @@ -0,0 +1,55 @@ +import { Asset } from "expo-asset"; +import { File } from "expo-file-system"; +import { widgetsDirectory } from "expo-widgets"; +import { Platform } from "react-native"; + +const MARK_FILE = "beenvoice-live-mark.png"; +const LOGO_FILE = "beenvoice-live-logo.png"; + +let cachedUris: { markUri: string; logoUri: string } | null = null; +let copyPromise: Promise<{ markUri: string; logoUri: string } | null> | null = null; + +async function copyBrandFile(fromUri: string, toUri: string) { + await new File(fromUri).copy(new File(toUri), { overwrite: true }); +} + +/** Copy brand PNGs into the app-group folder so the widget extension can read them. */ +export async function ensureWidgetBrandAssets(): Promise<{ + markUri: string; + logoUri: string; +} | null> { + if (cachedUris) return cachedUris; + if (copyPromise) return copyPromise; + + copyPromise = (async () => { + if (Platform.OS !== "ios" || !widgetsDirectory) return null; + + const base = widgetsDirectory.endsWith("/") ? widgetsDirectory : `${widgetsDirectory}/`; + const markUri = `${base}${MARK_FILE}`; + const logoUri = `${base}${LOGO_FILE}`; + + const markAsset = Asset.fromModule(require("@/assets/images/icon.png")); + const logoAsset = Asset.fromModule(require("@/assets/images/beenvoice-logo-dark.png")); + await Promise.all([markAsset.downloadAsync(), logoAsset.downloadAsync()]); + + if (!markAsset.localUri || !logoAsset.localUri) return null; + + await Promise.all([ + copyBrandFile(markAsset.localUri, markUri), + copyBrandFile(logoAsset.localUri, logoUri), + ]); + + cachedUris = { markUri, logoUri }; + return cachedUris; + })(); + + try { + return await copyPromise; + } finally { + copyPromise = null; + } +} + +export function getWidgetBrandAssetUris() { + return cachedUris; +} diff --git a/widgets/TimeClockActivity.tsx b/widgets/TimeClockActivity.tsx index 0f10223..5c5c556 100644 --- a/widgets/TimeClockActivity.tsx +++ b/widgets/TimeClockActivity.tsx @@ -1,9 +1,61 @@ -import { HStack, Text, VStack } from "@expo/ui/swift-ui"; -import { font, foregroundStyle, padding } from "@expo/ui/swift-ui/modifiers"; +import { HStack, Image, Text, VStack } from "@expo/ui/swift-ui"; +import { + font, + foregroundStyle, + frame, + monospacedDigit, + padding, +} from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets"; import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types"; +const TIMER_GREEN = "#4ADE80"; +const SUBTLE_TEXT = "#E5E5E5"; +const MUTED_TEXT = "#D4D4D4"; + +function ElapsedText({ + value, + size, + weight = "semibold", +}: { + value: string; + size: number; + weight?: "regular" | "medium" | "semibold" | "bold"; +}) { + return ( + + {value} + + ); +} + +function BrandMark({ uri, size }: { uri?: string; size: number }) { + if (uri) { + return ; + } + + return ; +} + +function BrandLogo({ uri, height = 18 }: { uri?: string; height?: number }) { + if (uri) { + return ; + } + + return ( + + beenvoice + + ); +} + function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) { "widget"; @@ -13,45 +65,25 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi return { banner: ( - - beenvoice - - - {props.elapsedShort} - + + ), - compactLeading: ( - - bv - - ), - compactTrailing: ( - - {props.elapsedShort} - - ), - minimal: ( - - {props.elapsedShort} - - ), + compactLeading: , + compactTrailing: , + minimal: , expandedLeading: ( - - beenvoice - - + + {props.clockTime} ), expandedTrailing: ( - - {props.elapsedShort} - - elapsed + + elapsed ), expandedBottom: ( @@ -60,11 +92,12 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi {title} {subtitle ? ( - {subtitle} + {subtitle} ) : null} - - {props.elapsed} total - + + + total + ), };