Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -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);
|
||||
})();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
] as const;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Use useTabBarScrollPadding from @/lib/tab-bar-insets */
|
||||
export { useTabBarInset, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user