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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
# beenvoice API base URL (no trailing slash)
|
# 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
|
# 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
|
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ yarn-error.*
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
|
|||||||
+8
-1
@@ -12,7 +12,7 @@ import {
|
|||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
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 { AuthProvider, useSession } from "@/contexts/AuthContext";
|
||||||
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { TRPCProvider } from "@/lib/trpc";
|
import { TRPCProvider } from "@/lib/trpc";
|
||||||
|
import { ensureWidgetBrandAssets } from "@/lib/widget-brand-assets";
|
||||||
|
|
||||||
export { ErrorBoundary } from "expo-router";
|
export { ErrorBoundary } from "expo-router";
|
||||||
|
|
||||||
@@ -76,6 +77,12 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
void ensureWidgetBrandAssets();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Platform } from "react-native";
|
|||||||
|
|
||||||
import { formatElapsedHoursMinutes, formatElapsedSeconds } from "@/lib/time-clock";
|
import { formatElapsedHoursMinutes, formatElapsedSeconds } from "@/lib/time-clock";
|
||||||
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
|
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
|
||||||
|
import { ensureWidgetBrandAssets, getWidgetBrandAssetUris } from "@/lib/widget-brand-assets";
|
||||||
|
|
||||||
type RunningEntry = {
|
type RunningEntry = {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -54,6 +55,7 @@ export function buildTimeClockActivityProps(
|
|||||||
elapsedSeconds: number,
|
elapsedSeconds: number,
|
||||||
): TimeClockActivityProps {
|
): TimeClockActivityProps {
|
||||||
const invoice = running.invoice;
|
const invoice = running.invoice;
|
||||||
|
const brand = getWidgetBrandAssetUris();
|
||||||
return {
|
return {
|
||||||
elapsed: formatElapsedSeconds(elapsedSeconds),
|
elapsed: formatElapsedSeconds(elapsedSeconds),
|
||||||
elapsedShort: formatElapsedHoursMinutes(elapsedSeconds),
|
elapsedShort: formatElapsedHoursMinutes(elapsedSeconds),
|
||||||
@@ -66,6 +68,8 @@ export function buildTimeClockActivityProps(
|
|||||||
invoiceLabel: invoice
|
invoiceLabel: invoice
|
||||||
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
|
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
|
||||||
: "",
|
: "",
|
||||||
|
markImageUri: brand?.markUri,
|
||||||
|
logoImageUri: brand?.logoUri,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +86,7 @@ export async function syncTimeClockLiveActivity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureWidgetBrandAssets();
|
||||||
const props = buildTimeClockActivityProps(running, elapsedSeconds);
|
const props = buildTimeClockActivityProps(running, elapsedSeconds);
|
||||||
const instances = factory.getInstances();
|
const instances = factory.getInstances();
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,8 @@ export type TimeClockActivityProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
invoiceLabel: 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,61 @@
|
|||||||
import { HStack, Text, VStack } from "@expo/ui/swift-ui";
|
import { HStack, Image, Text, VStack } from "@expo/ui/swift-ui";
|
||||||
import { font, foregroundStyle, padding } from "@expo/ui/swift-ui/modifiers";
|
import {
|
||||||
|
font,
|
||||||
|
foregroundStyle,
|
||||||
|
frame,
|
||||||
|
monospacedDigit,
|
||||||
|
padding,
|
||||||
|
} from "@expo/ui/swift-ui/modifiers";
|
||||||
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
|
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
|
||||||
|
|
||||||
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
|
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 (
|
||||||
|
<Text
|
||||||
|
modifiers={[
|
||||||
|
font({ design: "monospaced", weight, size }),
|
||||||
|
monospacedDigit(),
|
||||||
|
foregroundStyle(TIMER_GREEN),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandMark({ uri, size }: { uri?: string; size: number }) {
|
||||||
|
if (uri) {
|
||||||
|
return <Image uiImage={uri} modifiers={[frame({ width: size, height: size })]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Image systemName="dollarsign" color="#FAFAFA" size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandLogo({ uri, height = 18 }: { uri?: string; height?: number }) {
|
||||||
|
if (uri) {
|
||||||
|
return <Image uiImage={uri} modifiers={[frame({ width: 132, height })]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 16 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
beenvoice
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
|
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
|
||||||
"widget";
|
"widget";
|
||||||
|
|
||||||
@@ -13,45 +65,25 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
|||||||
return {
|
return {
|
||||||
banner: (
|
banner: (
|
||||||
<HStack modifiers={[padding({ all: 14 })]}>
|
<HStack modifiers={[padding({ all: 14 })]}>
|
||||||
<Text modifiers={[font({ weight: "bold", size: 14 }), foregroundStyle("#FFFFFF")]}>
|
<BrandLogo uri={props.logoImageUri} />
|
||||||
beenvoice
|
<ElapsedText value={props.elapsedShort} size={20} weight="bold" />
|
||||||
</Text>
|
|
||||||
<Text modifiers={[font({ weight: "bold", size: 20 }), foregroundStyle("#FFFFFF")]}>
|
|
||||||
{props.elapsedShort}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
compactLeading: (
|
compactLeading: <BrandMark uri={props.markImageUri} size={18} />,
|
||||||
<Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle("#FFFFFF")]}>
|
compactTrailing: <ElapsedText value={props.elapsedShort} size={14} />,
|
||||||
bv
|
minimal: <ElapsedText value={props.elapsedShort} size={12} weight="bold" />,
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
compactTrailing: (
|
|
||||||
<Text modifiers={[font({ weight: "semibold", size: 14 }), foregroundStyle("#FFFFFF")]}>
|
|
||||||
{props.elapsedShort}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
minimal: (
|
|
||||||
<Text modifiers={[font({ weight: "bold", size: 12 }), foregroundStyle("#FFFFFF")]}>
|
|
||||||
{props.elapsedShort}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
expandedLeading: (
|
expandedLeading: (
|
||||||
<VStack modifiers={[padding({ all: 12 })]}>
|
<VStack modifiers={[padding({ all: 12 })]}>
|
||||||
<Text modifiers={[font({ weight: "bold", size: 16 }), foregroundStyle("#FFFFFF")]}>
|
<BrandLogo uri={props.logoImageUri} height={20} />
|
||||||
beenvoice
|
<Text modifiers={[font({ size: 12, design: "monospaced" }), foregroundStyle(SUBTLE_TEXT)]}>
|
||||||
</Text>
|
|
||||||
<Text modifiers={[font({ size: 12 }), foregroundStyle("#E5E5E5")]}>
|
|
||||||
{props.clockTime}
|
{props.clockTime}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
expandedTrailing: (
|
expandedTrailing: (
|
||||||
<VStack modifiers={[padding({ all: 12 })]}>
|
<VStack modifiers={[padding({ all: 12 })]}>
|
||||||
<Text modifiers={[font({ weight: "bold", size: 32 }), foregroundStyle("#FFFFFF")]}>
|
<ElapsedText value={props.elapsedShort} size={32} weight="bold" />
|
||||||
{props.elapsedShort}
|
<Text modifiers={[font({ size: 12 }), foregroundStyle(SUBTLE_TEXT)]}>elapsed</Text>
|
||||||
</Text>
|
|
||||||
<Text modifiers={[font({ size: 12 }), foregroundStyle("#E5E5E5")]}>elapsed</Text>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
expandedBottom: (
|
expandedBottom: (
|
||||||
@@ -60,11 +92,12 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text modifiers={[font({ size: 13 }), foregroundStyle("#E5E5E5")]}>{subtitle}</Text>
|
<Text modifiers={[font({ size: 13 }), foregroundStyle(SUBTLE_TEXT)]}>{subtitle}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Text modifiers={[font({ size: 12 }), foregroundStyle("#D4D4D4")]}>
|
<HStack>
|
||||||
{props.elapsed} total
|
<ElapsedText value={props.elapsed} size={12} weight="regular" />
|
||||||
</Text>
|
<Text modifiers={[font({ size: 12 }), foregroundStyle(MUTED_TEXT)]}> total</Text>
|
||||||
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user