Fix Live Activity lock screen rendering and polish multi-account auth.

Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 01:23:36 -04:00
co-authored by Cursor
parent e6ea3d7c5d
commit 32ffe782ea
35 changed files with 1659 additions and 442 deletions
+51 -19
View File
@@ -1,44 +1,76 @@
import * as SecureStore from "expo-secure-store";
const ENABLED_KEY = "beenvoice_app_lock_enabled";
const PIN_KEY = "beenvoice_app_lock_pin";
const BIOMETRIC_KEY = "beenvoice_app_lock_biometric";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
export async function getAppLockEnabled(): Promise<boolean> {
const value = await SecureStore.getItemAsync(ENABLED_KEY);
function lockKey(accountId: string, field: "enabled" | "pin" | "biometric") {
return normalizeSecureStoreKey(`beenvoice.app-lock.${accountId}.${field}`);
}
const LEGACY_ENABLED_KEY = "beenvoice_app_lock_enabled";
const LEGACY_PIN_KEY = "beenvoice_app_lock_pin";
const LEGACY_BIOMETRIC_KEY = "beenvoice_app_lock_biometric";
async function migrateLegacyLockIfNeeded(accountId: string): Promise<void> {
const [legacyEnabled, legacyPin, legacyBiometric, accountEnabled] = await Promise.all([
SecureStore.getItemAsync(LEGACY_ENABLED_KEY),
SecureStore.getItemAsync(LEGACY_PIN_KEY),
SecureStore.getItemAsync(LEGACY_BIOMETRIC_KEY),
SecureStore.getItemAsync(lockKey(accountId, "enabled")),
]);
if (accountEnabled != null || legacyEnabled !== "1") return;
if (legacyPin) {
await setStoredPin(accountId, legacyPin);
}
await setAppLockEnabled(accountId, true);
if (legacyBiometric === "1") {
await setBiometricEnabled(accountId, true);
}
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_ENABLED_KEY),
SecureStore.deleteItemAsync(LEGACY_PIN_KEY),
SecureStore.deleteItemAsync(LEGACY_BIOMETRIC_KEY),
]);
}
export async function getAppLockEnabled(accountId: string): Promise<boolean> {
await migrateLegacyLockIfNeeded(accountId);
const value = await SecureStore.getItemAsync(lockKey(accountId, "enabled"));
return value === "1";
}
export async function setAppLockEnabled(enabled: boolean): Promise<void> {
export async function setAppLockEnabled(accountId: string, enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(ENABLED_KEY, "1");
await SecureStore.setItemAsync(lockKey(accountId, "enabled"), "1");
} else {
await SecureStore.deleteItemAsync(ENABLED_KEY);
await SecureStore.deleteItemAsync(lockKey(accountId, "enabled"));
}
}
export async function getStoredPin(): Promise<string | null> {
return SecureStore.getItemAsync(PIN_KEY);
export async function getStoredPin(accountId: string): Promise<string | null> {
return SecureStore.getItemAsync(lockKey(accountId, "pin"));
}
export async function setStoredPin(pin: string): Promise<void> {
await SecureStore.setItemAsync(PIN_KEY, pin);
export async function setStoredPin(accountId: string, pin: string): Promise<void> {
await SecureStore.setItemAsync(lockKey(accountId, "pin"), pin);
}
export async function clearStoredPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_KEY);
export async function clearStoredPin(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(lockKey(accountId, "pin"));
}
export async function getBiometricEnabled(): Promise<boolean> {
const value = await SecureStore.getItemAsync(BIOMETRIC_KEY);
export async function getBiometricEnabled(accountId: string): Promise<boolean> {
const value = await SecureStore.getItemAsync(lockKey(accountId, "biometric"));
return value === "1";
}
export async function setBiometricEnabled(enabled: boolean): Promise<void> {
export async function setBiometricEnabled(accountId: string, enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(BIOMETRIC_KEY, "1");
await SecureStore.setItemAsync(lockKey(accountId, "biometric"), "1");
} else {
await SecureStore.deleteItemAsync(BIOMETRIC_KEY);
await SecureStore.deleteItemAsync(lockKey(accountId, "biometric"));
}
}
+71
View File
@@ -0,0 +1,71 @@
import * as SecureStore from "expo-secure-store";
import { authStoragePrefix, buildAccountId } from "@/lib/accounts";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
export const GUEST_AUTH_STORAGE_PREFIX = "beenvoice:guest";
const CHUNK_MARKER = "\u0001ba-chunks:";
const AUTH_STORAGE_SUFFIXES = ["_cookie", "_session_data", "_last_login_method"] as const;
function storageKeyForPrefix(prefix: string, suffix: (typeof AUTH_STORAGE_SUFFIXES)[number]) {
return normalizeSecureStoreKey(`${prefix}${suffix}`);
}
async function copySecureStoreEntry(fromKey: string, toKey: string): Promise<void> {
const value = await SecureStore.getItemAsync(fromKey);
if (value == null) return;
await SecureStore.setItemAsync(toKey, value);
if (!value.startsWith(CHUNK_MARKER)) return;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return;
for (let i = 0; i < count; i += 1) {
const chunk = await SecureStore.getItemAsync(`${fromKey}.${i}`);
if (chunk != null) {
await SecureStore.setItemAsync(`${toKey}.${i}`, chunk);
}
}
}
export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise<void> {
if (fromPrefix === toPrefix) return;
await Promise.all(
AUTH_STORAGE_SUFFIXES.map((suffix) =>
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
),
);
}
export async function finalizeAuthenticatedAccount(input: {
apiUrl: string;
userId: string;
email: string;
name: string;
activeAccountId: string | null;
registerAccount: (input: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<unknown>;
}): Promise<void> {
const accountId = buildAccountId(input.apiUrl, input.userId);
const targetPrefix = authStoragePrefix(accountId);
const sourcePrefix = input.activeAccountId
? authStoragePrefix(input.activeAccountId)
: GUEST_AUTH_STORAGE_PREFIX;
await migrateAuthStorage(sourcePrefix, targetPrefix);
await input.registerAccount({
instanceUrl: input.apiUrl,
userId: input.userId,
email: input.email,
name: input.name,
});
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
export function useFieldVisibility() {
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [submitted, setSubmitted] = useState(false);
const touch = useCallback((field: string) => {
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
}, []);
const visible = useCallback(
(field: string) => submitted || Boolean(touched[field]),
[submitted, touched],
);
const markSubmitted = useCallback(() => setSubmitted(true), []);
return { touch, visible, markSubmitted };
}
export function isRequiredString(value: string): boolean {
return value.trim().length > 0;
}
export function isValidEmail(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
}
export function isValidPassword(value: string): boolean {
return value.length >= 8;
}
/** Parses a non-negative decimal, or null if empty/invalid. */
export function parseNonNegativeNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
if (Number.isNaN(n) || n < 0) return null;
return n;
}
export function isValidTaxRate(value: string): boolean {
const n = parseNonNegativeNumber(value);
if (n === null) return false;
return n <= 100;
}
export type LineItemInput = {
description: string;
hours: string;
rate: string;
};
export function validateLineItems(items: LineItemInput[]): string | null {
if (items.length === 0) return "Add at least one line item";
for (const item of items) {
if (!isRequiredString(item.description)) return "Each line needs a description";
if (parseNonNegativeNumber(item.hours) === null) return "Hours must be a valid number";
if (parseNonNegativeNumber(item.rate) === null) return "Rate must be a valid number";
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
/**
* expo-secure-store keys must be non-empty and match [A-Za-z0-9._-]+
* @see https://docs.expo.dev/versions/latest/sdk/securestore/
*/
export function normalizeSecureStoreKey(key: string): string {
return key.replace(/[^A-Za-z0-9._-]/g, "_");
}
+35
View File
@@ -0,0 +1,35 @@
import { DEFAULT_API_URL } from "@/lib/config";
import { normalizeInstanceUrl } from "@/lib/instance-url";
export type ServerMode = "official" | "self-hosted";
export const SERVER_MODE_OPTIONS: { value: ServerMode; label: string }[] = [
{ value: "official", label: "Official" },
{ value: "self-hosted", label: "Self-hosted" },
];
export function isOfficialServerUrl(url: string): boolean {
return url.replace(/\/$/, "") === DEFAULT_API_URL.replace(/\/$/, "");
}
export function resolveServerMode(url: string): ServerMode {
return isOfficialServerUrl(url) ? "official" : "self-hosted";
}
export function formatServerHost(url: string): string {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
}
export function isServerConfigValid(mode: ServerMode, selfHostedUrl: string): boolean {
if (mode === "official") return true;
return normalizeInstanceUrl(selfHostedUrl) !== null;
}
export function resolveServerUrl(mode: ServerMode, selfHostedUrl: string): string | null {
if (mode === "official") return DEFAULT_API_URL;
return normalizeInstanceUrl(selfHostedUrl);
}
+8 -10
View File
@@ -1,12 +1,11 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import { Platform } from "react-native";
import { formatElapsedHoursMinutes, formatElapsedSeconds } from "@/lib/time-clock";
import { formatElapsedHoursMinutes, formatElapsedSeconds, resolveClockDescription } 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;
startedAt: Date | string;
client?: { name: string } | null;
invoice?: { invoicePrefix: string | null; invoiceNumber: string } | null;
};
@@ -55,21 +54,19 @@ export function buildTimeClockActivityProps(
elapsedSeconds: number,
): TimeClockActivityProps {
const invoice = running.invoice;
const brand = getWidgetBrandAssetUris();
return {
startedAtMs: new Date(running.startedAt).getTime(),
elapsed: formatElapsedSeconds(elapsedSeconds),
elapsedShort: formatElapsedHoursMinutes(elapsedSeconds),
clockTime: new Date().toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
}),
description: running.description,
description: resolveClockDescription(running.description),
clientName: running.client?.name ?? "",
invoiceLabel: invoice
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
: "",
markImageUri: brand?.markUri,
logoImageUri: brand?.logoUri,
};
}
@@ -86,7 +83,6 @@ export async function syncTimeClockLiveActivity(
}
try {
await ensureWidgetBrandAssets();
const props = buildTimeClockActivityProps(running, elapsedSeconds);
const instances = factory.getInstances();
@@ -96,8 +92,10 @@ export async function syncTimeClockLiveActivity(
}
factory.start(props, "beenvoice://timer");
} catch {
// Native module can disappear between checks (e.g. hot reload in Expo Go).
} catch (error) {
if (__DEV__) {
console.warn("[LiveActivity] sync failed:", error);
}
factoryCache = undefined;
}
}
+3 -5
View File
@@ -1,5 +1,7 @@
export type TimeClockActivityProps = {
/** Full elapsed timer, e.g. 01:23:45 */
/** Unix ms when the timer started — drives native live-updating Text timers */
startedAtMs: number;
/** Full elapsed timer, e.g. 01:23:45 (updated on sync) */
elapsed: string;
/** Hours:minutes only for compact chrome, e.g. 1:23 */
elapsedShort: string;
@@ -8,8 +10,4 @@ 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;
};
+7
View File
@@ -4,6 +4,13 @@ export type ClockOutOutcome =
| "saved_no_client"
| "zero_hours";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
return trimmed || DEFAULT_CLOCK_DESCRIPTION;
}
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
-55
View File
@@ -1,55 +0,0 @@
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;
}