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:
+51
-19
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "_");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user