Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
import { router } from "expo-router";
import { Alert } from "react-native";
import type { createAuthClient } from "better-auth/react";
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
import { performAuthReset } from "@/lib/auth-session";
type AuthClient = ReturnType<typeof createAuthClient>;
type FinishAccountRemovalInput = {
result: RemoveAccountResult;
authClient: AuthClient;
clearActiveAccount: () => Promise<void>;
activeAccountId: string | null;
};
/** Navigate to sign-in when the last saved account was removed. */
export async function finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
}: FinishAccountRemovalInput): Promise<void> {
if (result.remainingCount > 0) return;
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in");
}
export function confirmRemoveAccount(
label: string,
onRemove: () => Promise<RemoveAccountResult>,
onFinished: (result: RemoveAccountResult) => Promise<void>,
) {
Alert.alert("Remove account", `Remove ${label} from this device?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => {
void (async () => {
const result = await onRemove();
await onFinished(result);
})();
},
},
]);
}
+84
View File
@@ -0,0 +1,84 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const ACCOUNTS_KEY = "beenvoice:accounts";
const ACTIVE_ACCOUNT_KEY = "beenvoice:active-account-id";
const DRAFT_INSTANCE_URL_KEY = "beenvoice:draft-instance-url";
export type SavedAccount = {
id: string;
instanceUrl: string;
userId: string;
email: string;
name: string;
lastUsedAt: number;
};
function isSavedAccount(value: unknown): value is SavedAccount {
if (!value || typeof value !== "object") return false;
const account = value as Partial<SavedAccount>;
return (
typeof account.id === "string" &&
account.id.length > 0 &&
typeof account.instanceUrl === "string" &&
account.instanceUrl.length > 0 &&
typeof account.userId === "string" &&
account.userId.length > 0 &&
typeof account.email === "string" &&
typeof account.name === "string" &&
typeof account.lastUsedAt === "number"
);
}
export function buildAccountId(instanceUrl: string, userId: string) {
const host = instanceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
return `${host}::${userId}`;
}
export function authStoragePrefix(accountId: string) {
return `beenvoice:auth:${accountId}`;
}
export async function loadAccounts(): Promise<SavedAccount[]> {
const raw = await AsyncStorage.getItem(ACCOUNTS_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter(isSavedAccount) : [];
} catch {
return [];
}
}
export async function saveAccounts(accounts: SavedAccount[]) {
await AsyncStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
}
export async function loadActiveAccountId(): Promise<string | null> {
return AsyncStorage.getItem(ACTIVE_ACCOUNT_KEY);
}
export async function saveActiveAccountId(accountId: string | null) {
if (accountId) {
await AsyncStorage.setItem(ACTIVE_ACCOUNT_KEY, accountId);
} else {
await AsyncStorage.removeItem(ACTIVE_ACCOUNT_KEY);
}
}
export async function loadDraftInstanceUrl(): Promise<string | null> {
return AsyncStorage.getItem(DRAFT_INSTANCE_URL_KEY);
}
export async function saveDraftInstanceUrl(url: string | null) {
if (url) {
await AsyncStorage.setItem(DRAFT_INSTANCE_URL_KEY, url);
} else {
await AsyncStorage.removeItem(DRAFT_INSTANCE_URL_KEY);
}
}
export async function hasConfiguredInstanceUrl(): Promise<boolean> {
const [accounts, draft] = await Promise.all([loadAccounts(), loadDraftInstanceUrl()]);
return accounts.length > 0 || Boolean(draft);
}
+12
View File
@@ -0,0 +1,12 @@
import { router } from "expo-router";
import { prepareForAdditionalSignIn } from "@/lib/auth-storage";
/** Switch to guest mode and open sign-in without wiping other saved accounts. */
export async function startAdditionalAccountSignIn(
clearActiveAccount: () => Promise<void>,
) {
await clearActiveAccount();
await prepareForAdditionalSignIn();
router.replace("/(auth)/sign-in");
}
+79
View File
@@ -0,0 +1,79 @@
import * as SecureStore from "expo-secure-store";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
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(accountId: string, enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(lockKey(accountId, "enabled"), "1");
} else {
await SecureStore.deleteItemAsync(lockKey(accountId, "enabled"));
}
}
export async function getStoredPin(accountId: string): Promise<string | null> {
return SecureStore.getItemAsync(lockKey(accountId, "pin"));
}
export async function setStoredPin(accountId: string, pin: string): Promise<void> {
await SecureStore.setItemAsync(lockKey(accountId, "pin"), pin);
}
export async function clearStoredPin(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(lockKey(accountId, "pin"));
}
export async function getBiometricEnabled(accountId: string): Promise<boolean> {
const value = await SecureStore.getItemAsync(lockKey(accountId, "biometric"));
return value === "1";
}
export async function setBiometricEnabled(accountId: string, enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(lockKey(accountId, "biometric"), "1");
} else {
await SecureStore.deleteItemAsync(lockKey(accountId, "biometric"));
}
}
export function isValidPin(pin: string): boolean {
return /^\d{4,6}$/.test(pin);
}
+46
View File
@@ -0,0 +1,46 @@
import { getApiUrl } from "@/lib/config";
import { readHttpErrorMessage } from "@/lib/trpc-errors";
export async function registerAccount(input: {
firstName: string;
lastName: string;
email: string;
password: string;
}) {
const res = await fetch(`${getApiUrl()}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(await readHttpErrorMessage(res));
}
}
export async function requestPasswordReset(email: string) {
const res = await fetch(`${getApiUrl()}/api/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error(await readHttpErrorMessage(res));
}
const data = (await res.json()) as { message?: string };
return data.message ?? "Check your email for reset instructions.";
}
export async function resetPassword(token: string, password: string) {
const res = await fetch(`${getApiUrl()}/api/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
throw new Error(await readHttpErrorMessage(res));
}
}
+21
View File
@@ -0,0 +1,21 @@
export type AuthCapabilities = {
authentik: boolean;
signupsDisabled: boolean;
};
const DEFAULT_CAPABILITIES: AuthCapabilities = {
authentik: false,
signupsDisabled: false,
};
export async function fetchAuthCapabilities(apiUrl: string): Promise<AuthCapabilities> {
const base = apiUrl.replace(/\/$/, "");
try {
const response = await fetch(`${base}/api/auth/capabilities`);
if (!response.ok) return DEFAULT_CAPABILITIES;
return (await response.json()) as AuthCapabilities;
} catch {
return DEFAULT_CAPABILITIES;
}
}
+127
View File
@@ -0,0 +1,127 @@
import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import type { createAuthClient } from "better-auth/react";
import { GUEST_AUTH_STORAGE_PREFIX } from "@/lib/auth-storage";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
type AuthClient = ReturnType<typeof createAuthClient>;
const CHUNK_MARKER = "\u0001ba-chunks:";
const SESSION_TOKEN_COOKIE_PART =
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
function readSecureStoreValueSync(key: string): string | null {
const value = SecureStore.getItem(key);
if (value == null) return null;
if (!value.startsWith(CHUNK_MARKER)) return value;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return null;
let assembled = "";
for (let index = 0; index < count; index += 1) {
const chunk = SecureStore.getItem(`${key}.${index}`);
if (chunk == null) return null;
assembled += chunk;
}
return assembled;
}
function readStoredCookie(storagePrefix: string): string | null {
const raw = readSecureStoreValueSync(
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
);
if (!raw || raw === "{}") return null;
const cookie = serializeStoredCookies(raw);
return cookie.trim() || null;
}
function cookieNames(cookie: string): string[] {
return cookie
.split(";")
.map((part) => part.trim().split("=", 1)[0])
.filter(Boolean);
}
/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
export function getAuthCookie(
authClient: AuthClient,
storagePrefix: string,
): string | null {
const fromClient = (
authClient as AuthClient & { getCookie?: () => string }
).getCookie?.();
if (fromClient?.trim()) {
const cookie = fromClient.trim();
if (AUTH_COOKIE_DEBUG) {
console.info("[auth-cookie] using client cookie", {
storagePrefix,
length: cookie.length,
names: cookieNames(cookie),
});
}
return cookie;
}
const fromPrefix = readStoredCookie(storagePrefix);
if (fromPrefix) {
if (AUTH_COOKIE_DEBUG) {
console.info("[auth-cookie] using stored cookie", {
storagePrefix,
length: fromPrefix.length,
names: cookieNames(fromPrefix),
});
}
return fromPrefix;
}
const fromGuest =
storagePrefix === GUEST_AUTH_STORAGE_PREFIX
? null
: readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
if (AUTH_COOKIE_DEBUG) {
console.info("[auth-cookie] resolved tRPC cookie", {
storagePrefix,
fallbackPrefix:
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
? GUEST_AUTH_STORAGE_PREFIX
: null,
hasCookie: Boolean(fromGuest),
length: fromGuest?.length ?? 0,
names: fromGuest ? cookieNames(fromGuest) : [],
});
}
return fromGuest;
}
export function getAuthCookieHeaders(
authClient: AuthClient,
storagePrefix: string,
): Record<string, string> {
const cookie = getAuthCookie(authClient, storagePrefix);
if (!cookie) {
if (AUTH_COOKIE_DEBUG) {
console.info("[auth-cookie] no tRPC auth cookie", { storagePrefix });
}
return {};
}
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
if (AUTH_COOKIE_DEBUG) {
console.info("[auth-cookie] sending tRPC auth headers", {
storagePrefix,
cookieLength: cookie.length,
cookieNames: cookieNames(cookie),
hasSessionTokenHeader: Boolean(sessionToken),
});
}
return {
cookie,
Cookie: cookie,
"x-beenvoice-auth-cookie": cookie,
...(sessionToken ? { "x-beenvoice-session-token": sessionToken } : {}),
};
}
+15
View File
@@ -0,0 +1,15 @@
import type { createAuthClient } from "better-auth/react";
type AuthClient = ReturnType<typeof createAuthClient>;
type OAuth2SignIn = (input: {
providerId: string;
callbackURL: string;
}) => Promise<{ error?: { message?: string } | null }>;
export async function signInWithAuthentik(authClient: AuthClient, callbackURL: string) {
return (authClient.signIn as unknown as { oauth2: OAuth2SignIn }).oauth2({
providerId: "authentik",
callbackURL,
});
}
+60
View File
@@ -0,0 +1,60 @@
import type { createAuthClient } from "better-auth/react";
import { authStoragePrefix } from "@/lib/accounts";
import {
clearAuthStorage,
GUEST_AUTH_STORAGE_PREFIX,
prepareForAdditionalSignIn,
} from "@/lib/auth-storage";
type AuthClient = ReturnType<typeof createAuthClient>;
type PerformAuthResetInput = {
authClient: AuthClient;
clearActiveAccount: () => Promise<void>;
activeAccountId?: string | null;
refetchSession?: () => Promise<unknown>;
};
/** Sign out, wipe local auth storage, and return to guest mode for a clean sign-in screen. */
export async function performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession,
}: PerformAuthResetInput): Promise<void> {
const accountPrefix = activeAccountId ? authStoragePrefix(activeAccountId) : null;
try {
await authClient.signOut();
} catch {
// Continue clearing local state even when the server session is already gone.
}
if (accountPrefix) {
await clearAuthStorage(accountPrefix);
}
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
await clearActiveAccount();
await refetchSession?.();
}
/**
* When the auth stack is shown, discard stale SecureStore sessions so sign-in starts clean.
* Expired account sessions switch back to guest storage; orphaned guest copies are cleared.
*/
export async function prepareAuthScreenSession(
authClient: AuthClient,
activeAccountId: string | null,
clearActiveAccount: () => Promise<void>,
): Promise<void> {
const session = await authClient.getSession();
if (session.data?.user) return;
if (activeAccountId) {
await clearAuthStorage(authStoragePrefix(activeAccountId));
await clearActiveAccount();
}
await prepareForAdditionalSignIn();
}
+146
View File
@@ -0,0 +1,146 @@
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 readSecureStoreValue(key: string): Promise<string | null> {
const value = await SecureStore.getItemAsync(key);
if (value == null) return null;
if (!value.startsWith(CHUNK_MARKER)) return value;
const count = Number(value.slice(CHUNK_MARKER.length));
if (!Number.isInteger(count) || count < 1) return null;
const chunks = await Promise.all(
Array.from({ length: count }, (_, index) => SecureStore.getItemAsync(`${key}.${index}`)),
);
return chunks.map((chunk) => chunk ?? "").join("");
}
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 readStoredSessionUser(prefix: string): Promise<{
id?: string;
name?: string;
email?: string;
} | null> {
const raw = await readSecureStoreValue(storageKeyForPrefix(prefix, "_session_data"));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as {
user?: { id?: string; name?: string; email?: string };
session?: { user?: { id?: string; name?: string; email?: string } };
};
const user = parsed.user ?? parsed.session?.user;
if (!user) return null;
return { id: user.id, name: user.name, email: user.email };
} catch {
return null;
}
}
export async function migrateAuthStorage(
fromPrefix: string,
toPrefix: string,
options: { clearSource?: boolean } = {},
): Promise<void> {
if (fromPrefix === toPrefix) return;
const clearSource = options.clearSource ?? true;
await Promise.all(
AUTH_STORAGE_SUFFIXES.map((suffix) =>
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
),
);
if (clearSource) {
await clearAuthStorage(fromPrefix);
}
}
export async function clearAuthStorage(prefix: string): Promise<void> {
await Promise.all(
AUTH_STORAGE_SUFFIXES.map(async (suffix) => {
const key = storageKeyForPrefix(prefix, suffix);
const value = await SecureStore.getItemAsync(key);
if (value?.startsWith(CHUNK_MARKER)) {
const count = Number(value.slice(CHUNK_MARKER.length));
if (Number.isInteger(count) && count > 0) {
await Promise.all(
Array.from({ length: count }, (_, index) =>
SecureStore.deleteItemAsync(`${key}.${index}`),
),
);
}
}
await SecureStore.deleteItemAsync(key);
}),
);
}
/** Clears guest auth storage before signing into an additional account. */
export async function prepareForAdditionalSignIn(): Promise<void> {
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
}
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;
if (sourcePrefix !== targetPrefix) {
await clearAuthStorage(targetPrefix);
}
await migrateAuthStorage(sourcePrefix, targetPrefix, { clearSource: false });
await input.registerAccount({
instanceUrl: input.apiUrl,
userId: input.userId,
email: input.email,
name: input.name,
});
}
+111
View File
@@ -0,0 +1,111 @@
/**
* beenvoice mobile theme — derived from `beenvoice/src/styles/globals.css`
* and root layout `brand-background` + `components/ui/card.tsx`.
*
* Default: data-interface-theme="beenvoice", data-radius="xl", data-color-theme="slate"
*/
/** hsl(0 0% 100%) */
export const background = "#FFFFFF";
/** hsl(240 10% 3.9%) */
export const foreground = "#09090B";
/** hsl(240 5.9% 10%) */
export const primary = "#18181B";
/** hsl(0 0% 98%) */
export const primaryForeground = "#FAFAFA";
/** hsl(240 4.8% 95.9%) */
export const muted = "#F4F4F5";
/** hsl(240 3.8% 46.1%) */
export const mutedForeground = "#71717A";
/** hsl(240 5.9% 90%) */
export const border = "#E4E4E7";
/** hsl(240 5.9% 90% / 0.5) — `border-border/50` on cards */
export const border50 = "rgba(228, 228, 231, 0.5)";
/** `bg-background/80` on glass surfaces */
export const surface80 = "rgba(255, 255, 255, 0.8)";
/** `bg-background/80` on chrome (tab bar, headers) — `backdrop-blur-md` */
export const chrome80 = "rgba(255, 255, 255, 0.8)";
/** brand-background grid: `#80808012` → alpha 0x12 / 255 */
export const gridLine = "rgba(128, 128, 128, 0.0706)";
export const gridSize = 24;
/** brand-background blob: `bg-neutral-400/40` = #a3a3a3 @ 40% */
export const blobCore = "rgba(163, 163, 163, 0.4)";
/** dark mode blob: neutral-500/30 — kept for future */
export const blobCoreDark = "rgba(115, 115, 115, 0.3)";
export const blobDiameter = 800;
/**
* Tailwind blur scale (approx px):
* blur-md = 12, blur-xl = 24, blur-3xl = 64
*/
export const blur = {
md: 12,
xl: 24,
blob: 64,
} as const;
/**
* expo-blur intensity is not 1:1 with CSS px — tuned to visually match.
* backdrop-blur-xl ≈ 24px, backdrop-blur-md ≈ 12px
*/
export const blurIntensity = {
card: 45,
chrome: 28,
} as const;
/**
* Radius: beenvoice `[data-slot=card] { border-radius: var(--radius-lg) }`
* with `--radius: 1rem` (xl preference) → 16px.
* Auth/marketing cards use the same glass card component.
*/
export const radius = {
sm: 4,
md: 8,
lg: 16,
xl: 20,
button: 12,
pill: 999,
} as const;
/** shadow-sm on default card */
export const shadowSm = {
shadowColor: "#000000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
} as const;
/** shadow-md on stats cards */
export const shadowMd = {
shadowColor: "#000000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
} as const;
/** @keyframes blob — 7s ease infinite */
export const blobAnimation = {
durationMs: 7000,
keyframes: [
{ translateX: 0, translateY: 0, scale: 1 },
{ translateX: 30, translateY: -50, scale: 1.1 },
{ translateX: -20, translateY: 20, scale: 0.9 },
{ translateX: 0, translateY: 0, scale: 1 },
],
} as const;
+34
View File
@@ -0,0 +1,34 @@
import type { createAuthClient } from "better-auth/react";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
type AuthClient = ReturnType<typeof createAuthClient>;
export async function completeSignInAfterAuth(
authClient: AuthClient,
input: {
apiUrl: string;
activeAccountId: string | null;
registerAccount: (account: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<unknown>;
},
): Promise<boolean> {
const session = await authClient.getSession();
const user = session.data?.user;
if (!user) return false;
await finalizeAuthenticatedAccount({
apiUrl: input.apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId: input.activeAccountId,
registerAccount: input.registerAccount,
});
return true;
}
+33
View File
@@ -0,0 +1,33 @@
import Constants from "expo-constants";
/** Production API used by default (App Store review + production builds). */
export const DEFAULT_API_URL = "https://beenvoice.app";
export const OFFICIAL_SERVER_HOST = new URL(DEFAULT_API_URL).host;
export const OFFICIAL_SERVER_PLACEHOLDER = `${OFFICIAL_SERVER_HOST} or localhost:3000`;
export function invalidServerUrlMessage(): string {
return `Enter a valid server URL (e.g. ${OFFICIAL_SERVER_PLACEHOLDER})`;
}
let runtimeOverride: string | null = null;
export function setRuntimeApiUrl(url: string | null) {
runtimeOverride = url?.replace(/\/$/, "") ?? null;
}
export function getApiUrl() {
if (runtimeOverride) return runtimeOverride;
const fromEnv = process.env.EXPO_PUBLIC_API_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
const hostUri = Constants.expoConfig?.hostUri;
if (hostUri && __DEV__) {
const host = hostUri.split(":")[0];
if (host) return `http://${host}:3000`;
}
return DEFAULT_API_URL;
}
+11
View File
@@ -0,0 +1,11 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+71
View File
@@ -0,0 +1,71 @@
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 null;
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;
}
export function validateLineItemsForSend(items: LineItemInput[]): string | null {
if (items.length === 0) return "Add at least one line item before sending";
return validateLineItems(items);
}
+41
View File
@@ -0,0 +1,41 @@
export function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(amount);
}
export function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatShortDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
export function formatDateTime(date: Date | string) {
return new Date(date).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function formatDuration(seconds: number) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
+44
View File
@@ -0,0 +1,44 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { invalidServerUrlMessage } from "@/lib/config";
const STORAGE_KEY = "beenvoice:instance-url";
export function normalizeInstanceUrl(input: string): string | null {
const trimmed = input.trim().replace(/\/$/, "");
if (!trimmed) return null;
let url = trimmed;
if (!/^https?:\/\//i.test(url)) {
const isLocal =
/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(url);
url = `${isLocal ? "http" : "https"}://${url}`;
}
try {
const parsed = new URL(url);
if (!parsed.hostname) return null;
return `${parsed.protocol}//${parsed.host}`;
} catch {
return null;
}
}
export async function loadStoredInstanceUrl(): Promise<string | null> {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (!stored) return null;
return normalizeInstanceUrl(stored) ?? stored.replace(/\/$/, "");
}
export async function saveStoredInstanceUrl(url: string): Promise<string> {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
throw new Error(invalidServerUrlMessage());
}
await AsyncStorage.setItem(STORAGE_KEY, normalized);
return normalized;
}
export async function clearStoredInstanceUrl(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEY);
}
+17
View File
@@ -0,0 +1,17 @@
type BusinessOption = {
id: string;
isDefault?: boolean | null;
};
export function pickDefaultBusinessId(businesses: BusinessOption[] | undefined): string {
if (!businesses?.length) return "";
return businesses.find((business) => business.isDefault)?.id ?? businesses[0]!.id;
}
export function resolveInvoiceBusinessId(
explicitId: string | null | undefined,
businesses: BusinessOption[] | undefined,
): string {
if (explicitId?.trim()) return explicitId.trim();
return pickDefaultBusinessId(businesses);
}
+11
View File
@@ -0,0 +1,11 @@
/** Matches web invoice-form default numbering. */
export function generateInvoiceNumber(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
return `INV-${date}-${String(Date.now()).slice(-6)}`;
}
export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
}
+80
View File
@@ -0,0 +1,80 @@
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
export type InvoicePdfPreviewInput = inferRouterInputs<AppRouter>["invoices"]["previewPdf"];
type InvoiceDetail = NonNullable<inferRouterOutputs<AppRouter>["invoices"]["getById"]>;
export type InvoicePdfFormFields = {
invoiceNumber: string;
invoicePrefix?: string | null;
businessId?: string | null;
clientId: string;
issueDate: Date;
dueDate: Date;
status?: "draft" | "sent" | "paid";
notes?: string | null;
emailMessage?: string | null;
taxRate: number;
currency: string;
items: Array<{
date: Date;
description: string;
hours: number | string;
rate: number | string;
}>;
};
export function buildPreviewPdfInput(fields: InvoicePdfFormFields): InvoicePdfPreviewInput | null {
if (!fields.clientId.trim()) return null;
const items = fields.items.map((item) => ({
date: item.date,
description: item.description.trim() || "Service",
hours: Number(item.hours) || 0,
rate: Number(item.rate) || 0,
}));
if (items.length === 0) return null;
return {
invoiceNumber: fields.invoiceNumber.trim() || "DRAFT",
invoicePrefix: fields.invoicePrefix?.trim() || "#",
businessId: fields.businessId?.trim() || "",
clientId: fields.clientId,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
status: fields.status ?? "draft",
notes: fields.notes?.trim() ?? "",
emailMessage: fields.emailMessage?.trim() ?? "",
taxRate: fields.taxRate,
currency: fields.currency,
items,
};
}
export function buildPreviewPdfInputFromInvoice(invoice: InvoiceDetail): InvoicePdfPreviewInput {
return {
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
businessId: invoice.businessId ?? invoice.business?.id ?? "",
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate: new Date(invoice.dueDate),
status: invoice.status as "draft" | "sent" | "paid",
notes: invoice.notes ?? "",
emailMessage: invoice.emailMessage ?? "",
taxRate: invoice.taxRate,
currency: invoice.currency ?? "USD",
items: invoice.items.map((item) => ({
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
})),
};
}
export function canPreviewPdfInput(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
if (!input?.clientId) return false;
return input.items.every((item) => item.description.trim().length > 0);
}
+151
View File
@@ -0,0 +1,151 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
const REMINDER_PREFIX = "invoice-send-reminder:";
const FIRED_PREFIX = "invoice-reminder-fired:";
export type InvoiceSendReminderSource = {
id: string;
status: string;
invoiceNumber: string;
invoicePrefix: string | null;
sendReminderAt: Date | string | null | undefined;
client?: { name: string } | null;
};
export function invoiceSendReminderNotificationId(invoiceId: string) {
return `${REMINDER_PREFIX}${invoiceId}`;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
async function ensureAndroidChannel() {
if (Platform.OS !== "android") return;
await Notifications.setNotificationChannelAsync("invoice-reminders", {
name: "Invoice reminders",
importance: Notifications.AndroidImportance.HIGH,
sound: "default",
vibrationPattern: [0, 250, 250, 250],
});
}
export async function ensureNotificationPermissions(): Promise<boolean> {
if (Platform.OS === "web") return false;
await ensureAndroidChannel();
const { status: existing } = await Notifications.getPermissionsAsync();
if (existing === "granted") return true;
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: true,
},
});
return status === "granted";
}
function reminderContent(invoice: InvoiceSendReminderSource): Notifications.NotificationContentInput {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
const clientName = invoice.client?.name ?? "your client";
return {
title: "Time to send invoice",
body: `${label} for ${clientName} is ready to send.`,
data: {
invoiceId: invoice.id,
type: "invoice-send-reminder",
},
sound: true,
...(Platform.OS === "android" ? { channelId: "invoice-reminders" } : {}),
};
}
export async function syncInvoiceSendReminders(invoices: InvoiceSendReminderSource[]) {
if (Platform.OS === "web") return;
const granted = await ensureNotificationPermissions();
if (!granted) return;
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
const ourScheduled = new Set(
scheduled
.map((entry) => entry.identifier)
.filter((id): id is string => Boolean(id?.startsWith(REMINDER_PREFIX))),
);
const wanted = new Set<string>();
const now = Date.now();
for (const invoice of invoices) {
if (invoice.status !== "draft" || !invoice.sendReminderAt) continue;
const notificationId = invoiceSendReminderNotificationId(invoice.id);
wanted.add(notificationId);
const reminderAt = new Date(invoice.sendReminderAt);
const reminderMs = reminderAt.getTime();
if (Number.isNaN(reminderMs)) continue;
const firedKey = `${FIRED_PREFIX}${invoice.id}`;
const firedAt = await AsyncStorage.getItem(firedKey);
const content = reminderContent(invoice);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
if (reminderMs <= now) {
const alreadyFiredForThisDate = firedAt === reminderAt.toISOString();
if (alreadyFiredForThisDate) continue;
await Notifications.scheduleNotificationAsync({
identifier: notificationId,
content,
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 2,
},
});
await AsyncStorage.setItem(firedKey, reminderAt.toISOString());
continue;
}
if (firedAt && firedAt !== reminderAt.toISOString()) {
await AsyncStorage.removeItem(firedKey);
}
await Notifications.scheduleNotificationAsync({
identifier: notificationId,
content,
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: reminderAt,
},
});
}
for (const notificationId of ourScheduled) {
if (wanted.has(notificationId)) continue;
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
const invoiceId = notificationId.slice(REMINDER_PREFIX.length);
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
}
}
export async function cancelInvoiceSendReminder(invoiceId: string) {
if (Platform.OS === "web") return;
const notificationId = invoiceSendReminderNotificationId(invoiceId);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
}
+45
View File
@@ -0,0 +1,45 @@
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue";
export function getInvoiceStatus(invoice: {
status: string;
dueDate: Date | string;
}): InvoiceStatus {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
const today = new Date();
const due = new Date(invoice.dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
if (due < today) return "overdue";
return "sent";
}
export const statusLabels: Record<InvoiceStatus, string> = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
};
const lightStatusColors: Record<InvoiceStatus, string> = {
draft: "#6b7280",
sent: "#2563eb",
paid: "#16a34a",
overdue: "#dc2626",
};
const darkStatusColors: Record<InvoiceStatus, string> = {
draft: "#A1A1AA",
sent: "#60A5FA",
paid: "#4ADE80",
overdue: "#F87171",
};
/** @deprecated Use `getStatusColor` for theme-aware colors. */
export const statusColors = lightStatusColors;
export function getStatusColor(status: InvoiceStatus, isDark: boolean): string {
return (isDark ? darkStatusColors : lightStatusColors)[status];
}
+2
View File
@@ -0,0 +1,2 @@
/** @deprecated Use useTabBarScrollPadding from @/lib/tab-bar-insets */
export { useTabBarInset, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
function formatClockTime(date: Date) {
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export function useLiveClock() {
const [time, setTime] = useState(() => formatClockTime(new Date()));
useEffect(() => {
const tick = () => setTime(formatClockTime(new Date()));
tick();
const id = setInterval(tick, 15_000);
return () => clearInterval(id);
}, []);
return time;
}
+34
View File
@@ -0,0 +1,34 @@
import * as Network from "expo-network";
import { onlineManager } from "@tanstack/react-query";
function isOnline({
isConnected,
isInternetReachable,
}: Pick<Network.NetworkState, "isConnected" | "isInternetReachable">) {
if (isConnected === false) return false;
if (isInternetReachable === false) return false;
return true;
}
let configured = false;
export function configureReactQueryOnlineManager() {
if (configured) return;
configured = true;
void Network.getNetworkStateAsync()
.then((state) => {
onlineManager.setOnline(isOnline(state));
})
.catch(() => {
onlineManager.setOnline(true);
});
onlineManager.setEventListener((setOnline) => {
const subscription = Network.addNetworkStateListener((state) => {
setOnline(isOnline(state));
});
return () => subscription.remove();
});
}
+109
View File
@@ -0,0 +1,109 @@
import {
authStoragePrefix,
buildAccountId,
loadAccounts,
loadActiveAccountId,
loadDraftInstanceUrl,
saveAccounts,
saveActiveAccountId,
saveDraftInstanceUrl,
type SavedAccount,
} from "@/lib/accounts";
import { migrateAuthStorage } from "@/lib/auth-storage";
import { DEFAULT_API_URL } from "@/lib/config";
import { loadStoredInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import {
clearTimeClockPrefsForAccount,
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
const LEGACY_OFFICIAL_HOSTS = ["beenvoice.soconnor.dev"];
export function isLegacyOfficialUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return false;
try {
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
return LEGACY_OFFICIAL_HOSTS.includes(new URL(withProtocol).host);
} catch {
const host = trimmed.replace(/^https?:\/\//, "").replace(/\/$/, "").split("/")[0] ?? "";
return LEGACY_OFFICIAL_HOSTS.includes(host);
}
}
export function migrateOfficialUrl(url: string): string {
return isLegacyOfficialUrl(url) ? DEFAULT_API_URL : url;
}
export async function migrateStoredOfficialUrls(): Promise<{
accounts: SavedAccount[];
activeAccountId: string | null;
draftUrl: string | null;
}> {
const [accounts, activeId, draftUrl, storedInstanceUrl] = await Promise.all([
loadAccounts(),
loadActiveAccountId(),
loadDraftInstanceUrl(),
loadStoredInstanceUrl(),
]);
let nextActiveId = activeId;
const migratedAccounts: SavedAccount[] = [];
for (const account of accounts) {
if (!isLegacyOfficialUrl(account.instanceUrl)) {
migratedAccounts.push(account);
continue;
}
const newUrl = DEFAULT_API_URL;
const newId = buildAccountId(newUrl, account.userId);
const oldPrefix = authStoragePrefix(account.id);
const newPrefix = authStoragePrefix(newId);
if (oldPrefix !== newPrefix) {
await migrateAuthStorage(oldPrefix, newPrefix);
}
const lastClientId = await getLastTimeClockClientId(account.id);
if (lastClientId && account.id !== newId) {
await setLastTimeClockClientId(newId, lastClientId);
await clearTimeClockPrefsForAccount(account.id);
}
migratedAccounts.push({
...account,
id: newId,
instanceUrl: newUrl,
});
if (nextActiveId === account.id) {
nextActiveId = newId;
}
}
const newDraft = draftUrl && isLegacyOfficialUrl(draftUrl) ? DEFAULT_API_URL : draftUrl;
const accountsChanged = migratedAccounts.some(
(account, index) =>
account.id !== accounts[index]?.id || account.instanceUrl !== accounts[index]?.instanceUrl,
);
const activeChanged = nextActiveId !== activeId;
const draftChanged = newDraft !== draftUrl;
const instanceChanged =
storedInstanceUrl != null &&
isLegacyOfficialUrl(storedInstanceUrl) &&
storedInstanceUrl !== DEFAULT_API_URL;
if (accountsChanged) await saveAccounts(migratedAccounts);
if (activeChanged) await saveActiveAccountId(nextActiveId);
if (draftChanged) await saveDraftInstanceUrl(newDraft);
if (instanceChanged) await saveStoredInstanceUrl(DEFAULT_API_URL);
return {
accounts: migratedAccounts,
activeAccountId: nextActiveId,
draftUrl: newDraft,
};
}
+132
View File
@@ -0,0 +1,132 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
dehydrate,
hydrate,
type DehydratedState,
type QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
const CACHE_PREFIX = "beenvoice:query-cache:v1";
const CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7;
const SAVE_DEBOUNCE_MS = 1000;
type PersistedQueryCache = {
timestamp: number;
state: DehydratedState;
};
const SKIPPED_QUERY_KEY_PARTS = ["previewPdf"];
function cacheScopeKey(accountId: string | null, apiUrl: string) {
const scope = `${accountId ?? "guest"}:${apiUrl}`;
return encodeURIComponent(scope).replace(/[!'()*]/g, (char) =>
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function offlineQueryCacheKey(accountId: string | null, apiUrl: string) {
return `${CACHE_PREFIX}:${cacheScopeKey(accountId, apiUrl)}`;
}
export async function restoreOfflineQueryCache({
queryClient,
accountId,
apiUrl,
}: {
queryClient: QueryClient;
accountId: string | null;
apiUrl: string;
}) {
const key = offlineQueryCacheKey(accountId, apiUrl);
const cached = await AsyncStorage.getItem(key);
if (!cached) return;
try {
const persisted = SuperJSON.parse<PersistedQueryCache>(cached);
if (Date.now() - persisted.timestamp > CACHE_MAX_AGE_MS) {
await AsyncStorage.removeItem(key);
return;
}
hydrate(queryClient, persisted.state);
} catch {
await AsyncStorage.removeItem(key);
}
}
export function persistOfflineQueryCache({
queryClient,
accountId,
apiUrl,
}: {
queryClient: QueryClient;
accountId: string | null;
apiUrl: string;
}) {
const key = offlineQueryCacheKey(accountId, apiUrl);
let timeout: ReturnType<typeof setTimeout> | null = null;
const save = async () => {
const state = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
query.state.status === "success" &&
query.state.fetchStatus === "idle" &&
query.state.fetchFailureCount === 0 &&
!SKIPPED_QUERY_KEY_PARTS.some((part) =>
JSON.stringify(query.queryKey).includes(part),
),
});
if (state.queries.length === 0) return;
const existing = await AsyncStorage.getItem(key).then((cached) => {
if (!cached) return null;
try {
return SuperJSON.parse<PersistedQueryCache>(cached);
} catch {
return null;
}
});
const queriesByHash = new Map(
existing?.state.queries.map((query) => [query.queryHash, query]),
);
for (const query of state.queries) {
queriesByHash.set(query.queryHash, query);
}
const persisted: PersistedQueryCache = {
timestamp: Date.now(),
state: {
mutations: state.mutations,
queries: Array.from(queriesByHash.values()),
},
};
await AsyncStorage.setItem(key, SuperJSON.stringify(persisted));
};
const scheduleSave = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(save, SAVE_DEBOUNCE_MS);
};
const unsubscribe = queryClient.getQueryCache().subscribe(scheduleSave);
return () => {
unsubscribe();
if (timeout) {
clearTimeout(timeout);
void save();
}
};
}
export async function clearOfflineQueryCacheForAccount(accountId: string) {
const keys = await AsyncStorage.getAllKeys();
const accountPrefix = `${CACHE_PREFIX}:${encodeURIComponent(`${accountId}:`)}`;
const matchingKeys = keys.filter((key) => key.startsWith(accountPrefix));
if (matchingKeys.length > 0) {
await AsyncStorage.multiRemove(matchingKeys);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { isRateLimitError, isUnauthorizedError } from "@/lib/trpc-errors";
export function createAppQueryClient(onUnauthorized: () => void) {
const handleError = (error: unknown) => {
if (isUnauthorizedError(error)) {
onUnauthorized();
}
};
return new QueryClient({
queryCache: new QueryCache({ onError: handleError }),
mutationCache: new MutationCache({ onError: handleError }),
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 1000 * 60 * 60 * 24 * 7,
refetchOnReconnect: true,
retry: (failureCount, error) => {
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
return failureCount < 1;
},
},
mutations: {
retry: (failureCount, error) => {
if (isRateLimitError(error)) return false;
return failureCount < 1;
},
},
},
});
}
+50
View File
@@ -0,0 +1,50 @@
import { isSupported, recognizeText } from "expo-mlkit-ocr";
import { parseReceiptText, type ReceiptParseResult } from "@/lib/receipt-parse";
export type ReceiptOcrResult = ReceiptParseResult & {
rawText: string;
};
export function mlKitOcrAvailable(): boolean {
try {
return isSupported();
} catch {
return false;
}
}
function normalizeImageUri(uri: string): string {
if (uri.startsWith("file://") || uri.startsWith("content://")) {
return uri;
}
return `file://${uri}`;
}
/** Run on-device ML Kit OCR and parse receipt fields from recognized text. */
export async function recognizeReceiptFromImage(uri: string): Promise<ReceiptOcrResult> {
if (!mlKitOcrAvailable()) {
throw new Error("On-device OCR is not supported on this device.");
}
const result = await recognizeText(normalizeImageUri(uri));
const rawText = result.text?.trim() ?? "";
const parsed = parseReceiptText(rawText);
return {
...parsed,
rawText,
};
}
export function applyReceiptOcrToForm(
ocr: ReceiptParseResult,
current: { description: string; amountText: string; date: Date },
): { description: string; amountText: string; date: Date; ocrText: string } {
return {
description: ocr.vendor?.trim() || current.description,
amountText: ocr.amount != null ? String(ocr.amount) : current.amountText,
date: ocr.date ?? current.date,
ocrText: ocr.rawLines.join("\n"),
};
}
+128
View File
@@ -0,0 +1,128 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
subtotal: number | null;
tax: number | null;
vendor: string | null;
items: ReceiptLineItem[];
rawLines: string[];
};
export type ReceiptLineItem = {
id: string;
name: string;
amount: number;
rawLine: string;
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
const SUBTOTAL_PATTERNS = [
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const TAX_PATTERNS = [
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const NON_ITEM_LINE =
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseFirstMatchingAmount(
text: string,
patterns: RegExp[],
): number | null {
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value >= 0) return value;
}
return null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
function parseLineItems(lines: string[]): ReceiptLineItem[] {
const items: ReceiptLineItem[] = [];
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/\s+/g, " ").trim();
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
if (!match?.[1] || !match[2]) continue;
const amount = Number(match[2].replace(/,/g, ""));
const name = match[1]
.replace(/^\d+\s*[xX]\s+/, "")
.replace(/\s+\d+\s*[xX]\s*$/, "")
.trim();
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
items.push({
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
name: name.slice(0, 80),
amount,
rawLine,
});
}
return items.slice(0, 30);
}
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
vendor: parseVendor(rawLines),
items: parseLineItems(rawLines),
rawLines,
};
}
+135
View File
@@ -0,0 +1,135 @@
import * as ImagePicker from "expo-image-picker";
import { Alert } from "react-native";
import {
applyReceiptOcrToForm,
recognizeReceiptFromImage,
} from "@/lib/receipt-ocr";
import type { ReceiptLineItem } from "@/lib/receipt-parse";
export type ReceiptSuggestFn = (input: { text: string }) => Promise<{
amount: number | null;
date: Date | null;
description: string | null;
}>;
export type PickedReceiptImage = {
uri: string;
base64: string;
filename: string;
mimeType: string;
};
export type ReceiptScanResult = {
image: PickedReceiptImage;
description: string;
amountText: string;
date: Date;
ocrText: string;
items: ReceiptLineItem[];
subtotal: number | null;
tax: number | null;
total: number | null;
};
async function requestPermissions(fromCamera: boolean): Promise<boolean> {
const permission = fromCamera
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.granted) return true;
Alert.alert(
fromCamera ? "Camera access needed" : "Photos access needed",
fromCamera
? "Allow camera access to scan receipts."
: "Allow photo library access to import receipt images.",
);
return false;
}
export async function pickReceiptImage(
fromCamera: boolean,
): Promise<PickedReceiptImage | null> {
if (!(await requestPermissions(fromCamera))) return null;
const result = fromCamera
? await ImagePicker.launchCameraAsync({
mediaTypes: ["images"],
quality: 0.9,
base64: true,
})
: await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.9,
base64: true,
});
if (result.canceled || !result.assets[0]) return null;
const asset = result.assets[0];
if (!asset.uri || !asset.base64) {
Alert.alert(
"Could not read image",
"Try another photo or lower the image size.",
);
return null;
}
return {
uri: asset.uri,
base64: asset.base64,
filename:
asset.fileName ?? (fromCamera ? "receipt.jpg" : "receipt-import.jpg"),
mimeType: asset.mimeType ?? "image/jpeg",
};
}
export async function scanReceiptImage(
fromCamera: boolean,
current: { description: string; amountText: string; date: Date },
suggest?: ReceiptSuggestFn,
): Promise<ReceiptScanResult | null> {
const image = await pickReceiptImage(fromCamera);
if (!image) return null;
try {
const ocr = await recognizeReceiptFromImage(image.uri);
let next = applyReceiptOcrToForm(ocr, current);
next = { ...next, ocrText: ocr.rawText || next.ocrText };
if (ocr.rawText && suggest) {
try {
const suggestion = await suggest({ text: ocr.rawText });
next = {
description: suggestion.description?.trim() || next.description,
amountText:
suggestion.amount != null
? String(suggestion.amount)
: next.amountText,
date: suggestion.date ? new Date(suggestion.date) : next.date,
ocrText: ocr.rawText,
};
} catch {
// Local parse is enough when server suggest fails offline.
}
}
return {
image,
...next,
items: ocr.items,
subtotal: ocr.subtotal,
tax: ocr.tax,
total: ocr.amount,
};
} catch (error) {
Alert.alert(
"OCR failed",
error instanceof Error
? error.message
: "Could not read text from the receipt.",
);
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);
}
+49
View File
@@ -0,0 +1,49 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ParsedShortcut } from "@/lib/shortcuts";
const STORAGE_KEY = "beenvoice:pending-shortcut";
let memory: ParsedShortcut | null = null;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) {
listener();
}
}
export async function enqueueShortcut(shortcut: ParsedShortcut): Promise<void> {
memory = shortcut;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(shortcut));
notify();
}
export async function peekPendingShortcut(): Promise<ParsedShortcut | null> {
if (memory) return memory;
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
memory = JSON.parse(raw) as ParsedShortcut;
return memory;
} catch {
await AsyncStorage.removeItem(STORAGE_KEY);
return null;
}
}
export async function clearPendingShortcut(): Promise<void> {
memory = null;
await AsyncStorage.removeItem(STORAGE_KEY);
}
export function subscribeShortcutQueue(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export async function hasPendingShortcut(): Promise<boolean> {
return (await peekPendingShortcut()) != null;
}
+66
View File
@@ -0,0 +1,66 @@
import * as Linking from "expo-linking";
export type ShortcutAction = "clock-in" | "clock-out" | "open-timer";
export type ParsedShortcut = {
action: ShortcutAction;
title: string;
clientId: string;
};
function queryParam(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
}
function normalizeShortcutPath(hostname: string | null, path: string | null): string | null {
const host = (hostname ?? "").replace(/^\/+|\/+$/g, "");
const segment = (path ?? "").replace(/^\/+|\/+$/g, "");
if (host === "shortcuts" && segment) {
return segment;
}
const combined = [host, segment].filter(Boolean).join("/");
const match = combined.match(/(?:^|\/)shortcuts\/(clock-in|clock-out)$/);
if (match?.[1]) return match[1];
if (combined === "clock-in" || combined === "clock-out") {
return combined;
}
return null;
}
/** Parse `beenvoice://shortcuts/clock-in` and related URLs from Shortcuts / Siri. */
export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut | null {
if (!url) return null;
const parsed = Linking.parse(url);
if (parsed.scheme !== "beenvoice") return null;
const path = (parsed.path ?? "").replace(/^\/+/, "");
const host = parsed.hostname ?? "";
if (path === "timer" || host === "timer") {
return { action: "open-timer", title: "", clientId: "" };
}
const shortcutAction = normalizeShortcutPath(host, path);
if (shortcutAction === "clock-in" || shortcutAction === "clock-out") {
return {
action: shortcutAction,
title: queryParam(parsed.queryParams?.title),
clientId: queryParam(parsed.queryParams?.clientId),
};
}
return null;
}
export const SHORTCUT_URLS = {
timer: "beenvoice://timer",
openTimer: "beenvoice://timer",
clockIn: "beenvoice://shortcuts/clock-in",
clockOut: "beenvoice://shortcuts/clock-out",
} as const;
+67
View File
@@ -0,0 +1,67 @@
import { Platform, useWindowDimensions } from "react-native";
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context";
import { spacing } from "@/constants/theme";
/** Standard UITabBar content height (home indicator is separate). */
const IOS_TAB_BAR_HEIGHT = 49;
/** Trim extra inset so scroll content sits closer to the tab bar. */
const TAB_BAR_PADDING_TRIM = spacing.lg;
/**
* Pixels between the bottom of the safe-area layout frame and the window bottom.
*/
function useBelowLayoutFrame(): number {
const { height: windowHeight } = useWindowDimensions();
const frame = useSafeAreaFrame();
return Math.max(0, windowHeight - frame.y - frame.height);
}
/** Native tab bar height excluding the home-indicator inset. */
export function useNativeTabBarHeight(): number {
const belowLayoutFrame = useBelowLayoutFrame();
const { bottom: homeIndicator } = useSafeAreaInsets();
const measured = Math.max(0, belowLayoutFrame - homeIndicator);
if (measured > 0) return measured;
return Platform.OS === "ios" ? IOS_TAB_BAR_HEIGHT : 0;
}
/**
* Bottom padding so scroll content can clear the floating native tab bar.
* Uses tab bar + home indicator as the target — layout-frame measurement can
* over-report and leave a large empty scroll gap above the tab bar.
*/
export function useTabBarScrollPadding(): number {
const { bottom: homeIndicator } = useSafeAreaInsets();
const tabBar = useNativeTabBarHeight();
const clearance = tabBar + homeIndicator;
return Math.max(spacing.xs, clearance - TAB_BAR_PADDING_TRIM);
}
/** Bottom offset for floating action buttons above the tab bar. */
export function useFloatingActionBottom(): number {
const { bottom: homeIndicator } = useSafeAreaInsets();
const tabBar = useNativeTabBarHeight();
return tabBar + homeIndicator + spacing.xs;
}
/**
* Bottom padding for tab-root ScrollViews (Dashboard, Invoices, etc.).
* Uses full tab-bar clearance — do not trim; undershooting hides content under the bar.
*/
export function useTabScreenScrollPadding(): number {
const { bottom: homeIndicator } = useSafeAreaInsets();
const tabBar = useNativeTabBarHeight();
return tabBar + homeIndicator + spacing.sm;
}
/** @deprecated Use useTabBarScrollPadding */
export function useTabBarInset() {
return useTabBarScrollPadding();
}
+18
View File
@@ -0,0 +1,18 @@
import { StyleSheet } from "react-native";
import { spacing } from "@/constants/theme";
/** Shared spacing for tab screens — single source of truth. */
export const tabLayout = StyleSheet.create({
pageHeader: {
gap: 4,
paddingTop: spacing.md,
paddingBottom: spacing.md,
},
scrollContent: {
paddingHorizontal: spacing.md,
},
scrollBody: {
gap: spacing.md,
},
});
+107
View File
@@ -0,0 +1,107 @@
import type { ColorSchemeName } from "react-native";
import * as light from "@/lib/beenvoice-theme";
/** Dark palette — mirrors `globals.css` `:root.dark` */
export const dark = {
background: "#09090B",
foreground: "#FAFAFA",
primary: "#FAFAFA",
primaryForeground: "#18181B",
muted: "#27272A",
mutedForeground: "#A1A1AA",
border: "#27272A",
border50: "rgba(39, 39, 42, 0.5)",
surface80: "rgba(9, 9, 11, 0.8)",
chrome80: "rgba(9, 9, 11, 0.8)",
gridLine: "rgba(128, 128, 128, 0.12)",
blobCore: "rgba(115, 115, 115, 0.3)",
destructive: "#F87171",
destructiveForeground: "#FAFAFA",
destructiveBg: "#450A0A",
success: "#4ADE80",
successBg: "#052E16",
warning: "#FBBF24",
warningBg: "#422006",
} as const;
export type ThemeColors = {
background: string;
backgroundMuted: string;
foreground: string;
card: string;
cardGlass: string;
primary: string;
primaryForeground: string;
muted: string;
mutedForeground: string;
border: string;
borderGlass: string;
secondary: string;
secondaryForeground: string;
accent: string;
destructive: string;
destructiveForeground: string;
destructiveBg: string;
success: string;
successBg: string;
warning: string;
warningBg: string;
brand: string;
brandDark: string;
text: string;
textMuted: string;
switchTrackOn: string;
switchTrackOff: string;
switchThumb: string;
switchIosBackground: string;
};
export function getThemeColors(scheme: ColorSchemeName): ThemeColors {
const isDark = scheme === "dark";
const palette = isDark ? dark : null;
return {
background: palette?.background ?? light.background,
backgroundMuted: palette?.muted ?? light.muted,
foreground: palette?.foreground ?? light.foreground,
card: palette?.background ?? light.background,
cardGlass: palette?.surface80 ?? light.surface80,
primary: palette?.primary ?? light.primary,
primaryForeground: palette?.primaryForeground ?? light.primaryForeground,
muted: palette?.muted ?? light.muted,
mutedForeground: palette?.mutedForeground ?? light.mutedForeground,
border: palette?.border ?? light.border,
borderGlass: palette?.border50 ?? light.border50,
secondary: palette?.border ?? light.border,
secondaryForeground: palette?.primary ?? light.primary,
accent: palette?.muted ?? light.muted,
destructive: palette?.destructive ?? "#EF4444",
destructiveForeground: palette?.destructiveForeground ?? light.primaryForeground,
destructiveBg: palette?.destructiveBg ?? "#FEF2F2",
success: palette?.success ?? "#16A34A",
successBg: palette?.successBg ?? "#F0FDF4",
warning: palette?.warning ?? "#D97706",
warningBg: palette?.warningBg ?? "#FFFBEB",
brand: palette?.primary ?? light.primary,
brandDark: palette?.foreground ?? light.foreground,
text: palette?.foreground ?? light.foreground,
textMuted: palette?.mutedForeground ?? light.mutedForeground,
switchTrackOn: isDark ? (palette?.success ?? "#4ADE80") : light.primary,
switchTrackOff: isDark ? "#3F3F46" : light.border,
switchThumb: isDark ? "#FAFAFA" : "#FFFFFF",
switchIosBackground: isDark ? "#3F3F46" : light.border,
};
}
export function getBackgroundTokens(scheme: ColorSchemeName) {
const isDark = scheme === "dark";
return {
background: isDark ? dark.background : light.background,
gridLine: isDark ? dark.gridLine : light.gridLine,
blobCore: isDark ? dark.blobCore : light.blobCore,
gridSize: light.gridSize,
blobDiameter: light.blobDiameter,
blobAnimation: light.blobAnimation,
};
}
+119
View File
@@ -0,0 +1,119 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import { Platform } from "react-native";
import { formatElapsedHoursMinutes, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock";
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
type RunningEntry = {
description: string;
startedAt: Date | string;
client?: { name: string } | null;
invoice?: {
invoicePrefix: string | null;
invoiceNumber: string;
business?: { name: string } | null;
} | null;
};
type LiveActivityHandle = {
update: (props: TimeClockActivityProps) => Promise<void>;
end: (policy?: "default" | "immediate") => Promise<void>;
};
type LiveActivityFactory = {
start: (props: TimeClockActivityProps, url?: string) => LiveActivityHandle;
getInstances: () => LiveActivityHandle[];
};
let factoryCache: LiveActivityFactory | null | undefined;
function isExpoWidgetsAvailable() {
return Platform.OS === "ios" && requireOptionalNativeModule("ExpoWidgets") != null;
}
function getFactory(): LiveActivityFactory | null {
if (factoryCache !== undefined) {
return factoryCache;
}
if (!isExpoWidgetsAvailable()) {
factoryCache = null;
return null;
}
try {
factoryCache = require("@/widgets/TimeClockActivity").default as LiveActivityFactory;
} catch {
factoryCache = null;
}
return factoryCache;
}
export function isTimeClockLiveActivitySupported() {
return getFactory() != null;
}
export function buildTimeClockActivityProps(
running: RunningEntry,
elapsedSeconds: number,
): TimeClockActivityProps {
const invoice = running.invoice;
return {
startedAtMs: new Date(running.startedAt).getTime(),
elapsed: formatElapsedSeconds(elapsedSeconds),
elapsedShort: formatElapsedHoursMinutes(elapsedSeconds),
clockTime: new Date().toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
}),
description: resolveClockDescription(running.description),
clientName: running.client?.name ?? "",
businessName: invoice?.business?.name ?? "",
invoiceLabel: invoice
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
: "",
};
}
export async function syncTimeClockLiveActivity(
running: RunningEntry | null | undefined,
elapsedSeconds: number,
) {
const factory = getFactory();
if (!factory) return;
if (!running) {
await endTimeClockLiveActivity();
return;
}
try {
const props = buildTimeClockActivityProps(running, elapsedSeconds);
const instances = factory.getInstances();
if (instances.length > 0) {
await instances[0]!.update(props);
return;
}
const instance = factory.start(props, "beenvoice://timer");
await instance.update(props);
} catch (error) {
if (__DEV__) {
console.warn("[LiveActivity] sync failed:", error);
}
factoryCache = undefined;
}
}
export async function endTimeClockLiveActivity() {
const factory = getFactory();
if (!factory) return;
try {
const instances = factory.getInstances();
await Promise.all(instances.map((instance) => instance.end("immediate")));
} catch {
factoryCache = undefined;
}
}
@@ -0,0 +1,14 @@
export type TimeClockActivityProps = {
/** 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;
/** Current time, hours:minutes */
clockTime: string;
description: string;
clientName: string;
businessName: string;
invoiceLabel: string;
};
+21
View File
@@ -0,0 +1,21 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
function storageKey(accountId: string) {
return `beenvoice:time-clock:last-client:${accountId}`;
}
export async function getLastTimeClockClientId(accountId: string): Promise<string | null> {
return AsyncStorage.getItem(storageKey(accountId));
}
export async function setLastTimeClockClientId(
accountId: string,
clientId: string,
): Promise<void> {
if (!clientId) return;
await AsyncStorage.setItem(storageKey(accountId), clientId);
}
export async function clearTimeClockPrefsForAccount(accountId: string): Promise<void> {
await AsyncStorage.removeItem(storageKey(accountId));
}
+79
View File
@@ -0,0 +1,79 @@
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
return trimmed || DEFAULT_CLOCK_DESCRIPTION;
}
export function formatRunningTimerLabel(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (
!trimmed ||
trimmed === DEFAULT_CLOCK_DESCRIPTION ||
trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION
) {
return "Clocked in";
}
return trimmed;
}
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
}
/** Hours and minutes only — for Live Activity / compact displays. */
export function formatElapsedHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
export function resolveEffectiveHourlyRate(
rateText: string,
clientDefaultRate?: number | null,
): number | null {
const parsed = rateText.trim() ? Number(rateText) : null;
if (parsed != null && !Number.isNaN(parsed) && parsed >= 0) return parsed;
if (clientDefaultRate != null && clientDefaultRate >= 0) return clientDefaultRate;
return null;
}
export function startedAtFromMinutesAgo(minutes: number): Date {
return new Date(Date.now() - minutes * 60_000);
}
export function describeClockOutOutcome(input: {
outcome: ClockOutOutcome;
hours: number;
rate: number;
invoice?: { invoicePrefix: string; invoiceNumber: string } | null;
}): string {
const amount = input.hours * input.rate;
switch (input.outcome) {
case "linked_to_invoice":
if (input.invoice) {
const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`;
return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to ${label}`;
}
return `Added ${input.hours}h to invoice`;
case "saved_no_invoice":
return `Saved ${input.hours}h — no open invoice for this client.`;
case "saved_no_client":
return `Saved ${input.hours}h — pick a client and invoice to bill.`;
case "zero_hours":
return "Timer stopped.";
}
}
+15
View File
@@ -0,0 +1,15 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { spacing } from "@/constants/theme";
/** Matches `TopChrome` row height. */
export const TOP_CHROME_ROW_HEIGHT = 40;
/** Bottom inset below the chrome row (`TopChromeBar` `paddingBottom`). */
export const TOP_CHROME_PADDING_BOTTOM = spacing.xs;
/** Total height of the blurred status-bar chrome (safe area + content row). */
export function useTopChromeHeight(): number {
const { top } = useSafeAreaInsets();
return top + TOP_CHROME_ROW_HEIGHT + TOP_CHROME_PADDING_BOTTOM;
}
+121
View File
@@ -0,0 +1,121 @@
import { TRPCClientError } from "@trpc/client";
function errorMessage(error: unknown): string {
if (error instanceof TRPCClientError) return error.message;
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message: unknown }).message;
if (typeof message === "string") return message;
}
return "";
}
function errorStatus(error: unknown): number | undefined {
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
const status = (error as { status: unknown }).status;
return typeof status === "number" ? status : undefined;
}
export function parseRetryAfterSeconds(value: string | number | null | undefined): number | null {
if (value == null || value === "") return null;
const seconds = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.ceil(seconds);
}
export function isRateLimitError(error: unknown): boolean {
if (errorStatus(error) === 429) return true;
if (error instanceof TRPCClientError && error.data?.code === "TOO_MANY_REQUESTS") {
return true;
}
const message = errorMessage(error).toLowerCase();
return (
message.includes("too many") ||
message.includes("rate limit") ||
message.includes("try again later")
);
}
export function isUnauthorizedError(error: unknown): boolean {
if (isRateLimitError(error)) return false;
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") return true;
}
if (errorStatus(error) === 401) return true;
const message = errorMessage(error).toLowerCase();
return message === "unauthorized" || message.includes("not authenticated");
}
export function formatRateLimitMessage(retryAfterSeconds?: number | null): string {
const retryAfter = parseRetryAfterSeconds(retryAfterSeconds ?? null);
if (retryAfter != null) {
if (retryAfter < 60) {
return `Too many attempts. Wait ${retryAfter} second${retryAfter === 1 ? "" : "s"} and try again.`;
}
const minutes = Math.ceil(retryAfter / 60);
return `Too many attempts. Wait about ${minutes} minute${minutes === 1 ? "" : "s"} and try again.`;
}
return "Too many attempts. Please wait a moment and try again.";
}
export function formatTrpcErrorMessage(error: unknown, fallback = "Something went wrong"): string {
if (isRateLimitError(error)) {
return formatRateLimitMessage();
}
if (isUnauthorizedError(error)) {
return "Your session expired. Sign in again to continue.";
}
if (error instanceof TRPCClientError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message || fallback;
}
return fallback;
}
type AuthLikeError = {
message?: string;
status?: number;
};
export function formatAuthErrorMessage(error: AuthLikeError | null | undefined): string {
if (!error) return "Something went wrong";
if (isRateLimitError(error)) {
return formatRateLimitMessage();
}
const message = error.message ?? "";
if (message.toLowerCase().includes("internal") || message.includes("500")) {
return "Server error — is the API running with Postgres? Check beenvoice dev + docker.";
}
return message || "Invalid email or password";
}
export async function readHttpErrorMessage(response: Response): Promise<string> {
if (response.status === 429) {
const retryAfter = parseRetryAfterSeconds(
response.headers.get("x-retry-after") ?? response.headers.get("retry-after"),
);
return formatRateLimitMessage(retryAfter);
}
const data = (await response.json().catch(() => ({}))) as {
error?: string;
message?: string;
};
const message = data.error ?? data.message;
if (message && isRateLimitError({ message, status: response.status })) {
return formatRateLimitMessage();
}
return message ?? "Something went wrong";
}
+139
View File
@@ -0,0 +1,139 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import SuperJSON from "superjson";
import { LoadingScreen } from "@/components/LoadingScreen";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
import { configureReactQueryOnlineManager } from "@/lib/network-status";
import {
persistOfflineQueryCache,
restoreOfflineQueryCache,
} from "@/lib/offline-cache";
import { performAuthReset } from "@/lib/auth-session";
import { createAppQueryClient } from "@/lib/query-client";
import type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact<AppRouter>();
export function TRPCProvider({
apiUrl,
children,
}: {
apiUrl: string;
children: ReactNode;
}) {
const authClient = useAuthClient();
const { authStoragePrefix, activeAccountId, clearActiveAccount } =
useAccounts();
const { refetch } = useSession();
const authStoragePrefixRef = useRef(authStoragePrefix);
authStoragePrefixRef.current = authStoragePrefix;
const [cacheReady, setCacheReady] = useState(false);
const mountedRef = useRef(true);
const resettingRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const handleUnauthorized = useCallback(async () => {
if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
const session = await authClient.getSession();
if (session.data?.user || !mountedRef.current) return;
resettingRef.current = true;
try {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession: refetch,
});
} finally {
resettingRef.current = false;
}
}, [authClient, clearActiveAccount, activeAccountId, refetch]);
const onUnauthorizedRef = useRef(handleUnauthorized);
onUnauthorizedRef.current = handleUnauthorized;
const [queryClient] = useState(() =>
createAppQueryClient(() => {
void onUnauthorizedRef.current();
}),
);
const [trpcClient] = useState(() =>
api.createClient({
links: [
httpBatchLink({
url: `${apiUrl}/api/trpc`,
transformer: SuperJSON,
headers() {
return getAuthCookieHeaders(
authClient,
authStoragePrefixRef.current,
);
},
}),
],
}),
);
useEffect(() => {
configureReactQueryOnlineManager();
}, []);
useEffect(() => {
let cancelled = false;
setCacheReady(false);
restoreOfflineQueryCache({
queryClient,
accountId: activeAccountId,
apiUrl,
}).finally(() => {
if (!cancelled && mountedRef.current) {
setCacheReady(true);
}
});
return () => {
cancelled = true;
};
}, [activeAccountId, apiUrl, queryClient]);
useEffect(() => {
if (!cacheReady) return;
return persistOfflineQueryCache({
queryClient,
accountId: activeAccountId,
apiUrl,
});
}, [activeAccountId, apiUrl, cacheReady, queryClient]);
if (!cacheReady) {
return <LoadingScreen message="Loading saved data…" />;
}
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
{children}
</api.Provider>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
/** Live elapsed seconds since `startedAt`, ticking every second. */
export function useRunningElapsed(startedAt?: string | Date | null) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!startedAt) {
setElapsed(0);
return;
}
const startMs = new Date(startedAt).getTime();
if (Number.isNaN(startMs)) {
setElapsed(0);
return;
}
const tick = () => {
setElapsed(Math.max(0, Math.floor((Date.now() - startMs) / 1000)));
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [startedAt]);
return elapsed;
}
+13
View File
@@ -0,0 +1,13 @@
import { useMemo } from "react";
import { StyleSheet } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { ThemeColors } from "@/lib/theme-palette";
/** StyleSheet factory that re-runs when light/dark palette changes. */
export function useThemedStyles<T extends StyleSheet.NamedStyles<T>>(
factory: (colors: ThemeColors, isDark: boolean) => T,
): T {
const { colors, isDark } = useAppTheme();
return useMemo(() => StyleSheet.create(factory(colors, isDark)), [colors, isDark]);
}