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
+
),
};