Add beenvoice mobile companion app with full dark mode support.

Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 22:36:37 -04:00
parent 8a7a8df477
commit 14c880123c
93 changed files with 8849 additions and 7849 deletions
+211
View File
@@ -0,0 +1,211 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { LoadingScreen } from "@/components/LoadingScreen";
import {
authStoragePrefix,
buildAccountId,
loadAccounts,
loadActiveAccountId,
loadDraftInstanceUrl,
saveAccounts,
saveActiveAccountId,
saveDraftInstanceUrl,
type SavedAccount,
} from "@/lib/accounts";
import { setRuntimeApiUrl, getApiUrl } from "@/lib/config";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
type AccountsContextValue = {
accounts: SavedAccount[];
activeAccount: SavedAccount | null;
activeAccountId: string | null;
apiUrl: string;
authStoragePrefix: string;
setInstanceUrl: (url: string) => Promise<string>;
switchAccount: (accountId: string) => Promise<void>;
registerAccount: (input: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<SavedAccount>;
removeAccount: (accountId: string) => Promise<void>;
clearActiveAccount: () => Promise<void>;
};
const AccountsContext = createContext<AccountsContextValue | null>(null);
export function AccountsProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [accounts, setAccounts] = useState<SavedAccount[]>([]);
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
const [apiUrl, setApiUrl] = useState(getApiUrl);
useEffect(() => {
Promise.all([loadAccounts(), loadActiveAccountId(), loadDraftInstanceUrl()])
.then(([storedAccounts, activeId, draftUrl]) => {
setAccounts(storedAccounts);
const active = storedAccounts.find((account) => account.id === activeId) ?? null;
if (active) {
setActiveAccountId(active.id);
setRuntimeApiUrl(active.instanceUrl);
} else if (draftUrl) {
setRuntimeApiUrl(draftUrl);
}
setApiUrl(getApiUrl());
})
.finally(() => setReady(true));
}, []);
const activeAccount = useMemo(
() => accounts.find((account) => account.id === activeAccountId) ?? null,
[accounts, activeAccountId],
);
const setInstanceUrl = useCallback(
async (url: string) => {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
}
if (activeAccount) {
const nextAccounts = accounts.map((account) =>
account.id === activeAccount.id ? { ...account, instanceUrl: normalized } : account,
);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
} else {
await saveDraftInstanceUrl(normalized);
}
await saveStoredInstanceUrl(normalized);
setRuntimeApiUrl(normalized);
setApiUrl(normalized);
return normalized;
},
[activeAccount, accounts],
);
const switchAccount = useCallback(
async (accountId: string) => {
const account = accounts.find((entry) => entry.id === accountId);
if (!account) return;
const nextAccounts = accounts.map((entry) =>
entry.id === accountId ? { ...entry, lastUsedAt: Date.now() } : entry,
);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
await saveActiveAccountId(accountId);
setActiveAccountId(accountId);
setRuntimeApiUrl(account.instanceUrl);
setApiUrl(account.instanceUrl);
},
[accounts],
);
const registerAccount = useCallback(
async (input: { instanceUrl: string; userId: string; email: string; name: string }) => {
const id = buildAccountId(input.instanceUrl, input.userId);
const existing = accounts.find((account) => account.id === id);
const account: SavedAccount = {
id,
instanceUrl: input.instanceUrl,
userId: input.userId,
email: input.email,
name: input.name,
lastUsedAt: Date.now(),
};
const nextAccounts = existing
? accounts.map((entry) => (entry.id === id ? account : entry))
: [account, ...accounts.filter((entry) => entry.id !== id)];
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
await saveActiveAccountId(id);
await saveDraftInstanceUrl(null);
setActiveAccountId(id);
setRuntimeApiUrl(input.instanceUrl);
setApiUrl(input.instanceUrl);
await saveStoredInstanceUrl(input.instanceUrl);
return account;
},
[accounts],
);
const removeAccount = useCallback(
async (accountId: string) => {
const nextAccounts = accounts.filter((account) => account.id !== accountId);
setAccounts(nextAccounts);
await saveAccounts(nextAccounts);
if (activeAccountId === accountId) {
const fallback = nextAccounts[0] ?? null;
await saveActiveAccountId(fallback?.id ?? null);
setActiveAccountId(fallback?.id ?? null);
if (fallback) {
setRuntimeApiUrl(fallback.instanceUrl);
setApiUrl(fallback.instanceUrl);
}
}
},
[accounts, activeAccountId],
);
const clearActiveAccount = useCallback(async () => {
await saveActiveAccountId(null);
setActiveAccountId(null);
}, []);
const value = useMemo(
() => ({
accounts,
activeAccount,
activeAccountId,
apiUrl,
authStoragePrefix: activeAccount
? authStoragePrefix(activeAccount.id)
: "beenvoice:guest",
setInstanceUrl,
switchAccount,
registerAccount,
removeAccount,
clearActiveAccount,
}),
[
accounts,
activeAccount,
activeAccountId,
apiUrl,
setInstanceUrl,
switchAccount,
registerAccount,
removeAccount,
clearActiveAccount,
],
);
if (!ready) {
return <LoadingScreen message="Starting…" />;
}
return <AccountsContext.Provider value={value}>{children}</AccountsContext.Provider>;
}
export function useAccounts() {
const ctx = useContext(AccountsContext);
if (!ctx) throw new Error("useAccounts must be used within AccountsProvider");
return ctx;
}
+232
View File
@@ -0,0 +1,232 @@
import * as LocalAuthentication from "expo-local-authentication";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import {
clearStoredPin,
getAppLockEnabled,
getBiometricEnabled,
getStoredPin,
isValidPin,
setAppLockEnabled,
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
type AppLockContextValue = {
enabled: boolean;
biometricEnabled: boolean;
hasPin: boolean;
isLocked: boolean;
biometricAvailable: boolean;
biometricLabel: string;
unlockWithPin: (pin: string) => Promise<boolean>;
unlockWithBiometric: () => Promise<boolean>;
enableLock: (pin: string) => Promise<void>;
disableLock: (pin: string) => Promise<boolean>;
changePin: (currentPin: string, nextPin: string) => Promise<boolean>;
setUseBiometric: (enabled: boolean) => Promise<void>;
lock: () => void;
};
const AppLockContext = createContext<AppLockContextValue | null>(null);
export function AppLockProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabled] = useState(false);
const [biometricEnabled, setBiometricEnabledState] = useState(false);
const [hasPin, setHasPin] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
const wasBackgrounded = useRef(false);
const hydrated = useRef(false);
useEffect(() => {
let cancelled = false;
async function hydrate() {
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes] =
await Promise.all([
getAppLockEnabled(),
getStoredPin(),
getBiometricEnabled(),
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
]);
if (cancelled) return;
const bioAvailable = hasHardware && isEnrolled;
setEnabled(lockEnabled);
setHasPin(Boolean(pin));
setBiometricEnabledState(bioEnabled && bioAvailable);
setBiometricAvailable(bioAvailable);
setBiometricLabel(
authTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)
? "Face ID"
: authTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)
? "Touch ID"
: "Biometrics",
);
setIsLocked(lockEnabled);
hydrated.current = true;
}
void hydrate();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled) return;
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
}
if (nextState === "active" && wasBackgrounded.current) {
wasBackgrounded.current = false;
setIsLocked(true);
}
});
return () => subscription.remove();
}, [enabled]);
const unlockWithPin = useCallback(async (pin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== pin) {
return false;
}
setIsLocked(false);
return true;
}, []);
const unlockWithBiometric = useCallback(async () => {
if (!biometricEnabled || !biometricAvailable) {
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: true,
});
if (!result.success) {
return false;
}
setIsLocked(false);
return true;
}, [biometricAvailable, biometricEnabled]);
const enableLock = useCallback(async (pin: string) => {
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
await setStoredPin(pin);
await setAppLockEnabled(true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
}, []);
const disableLock = useCallback(async (pin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== pin) {
return false;
}
await setAppLockEnabled(false);
await clearStoredPin();
await setBiometricEnabled(false);
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
return true;
}, []);
const changePin = useCallback(async (currentPin: string, nextPin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
return false;
}
await setStoredPin(nextPin);
return true;
}, []);
const setUseBiometric = useCallback(async (next: boolean) => {
if (next) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `Enable ${biometricLabel}`,
cancelLabel: "Cancel",
disableDeviceFallback: true,
});
if (!result.success) return;
}
await setBiometricEnabled(next);
setBiometricEnabledState(next);
}, [biometricLabel]);
const lock = useCallback(() => {
if (enabled) {
setIsLocked(true);
}
}, [enabled]);
const value = useMemo(
() => ({
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
}),
[
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
],
);
return <AppLockContext.Provider value={value}>{children}</AppLockContext.Provider>;
}
export function useAppLock() {
const context = useContext(AppLockContext);
if (!context) {
throw new Error("useAppLock must be used within AppLockProvider");
}
return context;
}
+50
View File
@@ -0,0 +1,50 @@
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
import {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
type AuthClient = ReturnType<typeof createAuthClient>;
const AuthContext = createContext<AuthClient | null>(null);
export function AuthProvider({
apiUrl,
storagePrefix,
children,
}: {
apiUrl: string;
storagePrefix: string;
children: ReactNode;
}) {
const client = useMemo(
() =>
createAuthClient({
baseURL: apiUrl,
plugins: [
expoClient({
scheme: "beenvoice",
storagePrefix,
storage: SecureStore,
}),
],
}),
[apiUrl, storagePrefix],
);
return <AuthContext.Provider value={client}>{children}</AuthContext.Provider>;
}
export function useAuthClient() {
const client = useContext(AuthContext);
if (!client) throw new Error("useAuthClient must be used within AuthProvider");
return client;
}
export function useSession() {
return useAuthClient().useSession();
}
+88
View File
@@ -0,0 +1,88 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useColorScheme as useSystemColorScheme, type ColorSchemeName } from "react-native";
import { getThemeColors, type ThemeColors } from "@/lib/theme-palette";
export type ColorMode = "system" | "light" | "dark";
const STORAGE_KEY = "beenvoice:color-mode";
type ThemeContextValue = {
colorMode: ColorMode;
setColorMode: (mode: ColorMode) => Promise<void>;
colorScheme: NonNullable<ColorSchemeName>;
colors: ThemeColors;
isDark: boolean;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemScheme = useSystemColorScheme();
const [colorMode, setColorModeState] = useState<ColorMode>("system");
const [ready, setReady] = useState(false);
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY)
.then((stored) => {
if (stored === "light" || stored === "dark" || stored === "system") {
setColorModeState(stored);
}
})
.finally(() => setReady(true));
}, []);
const colorScheme: NonNullable<ColorSchemeName> =
colorMode === "system" ? (systemScheme ?? "light") : colorMode;
const colors = useMemo(() => getThemeColors(colorScheme), [colorScheme]);
const setColorMode = useCallback(async (mode: ColorMode) => {
setColorModeState(mode);
await AsyncStorage.setItem(STORAGE_KEY, mode);
}, []);
const value = useMemo(
() => ({
colorMode,
setColorMode,
colorScheme,
colors,
isDark: colorScheme === "dark",
}),
[colorMode, setColorMode, colorScheme, colors],
);
if (!ready) {
return (
<ThemeContext.Provider
value={{
colorMode: "system",
setColorMode: async () => {},
colorScheme: systemScheme ?? "light",
colors: getThemeColors(systemScheme ?? "light"),
isDark: systemScheme === "dark",
}}
>
{children}
</ThemeContext.Provider>
);
}
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useAppTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useAppTheme must be used within ThemeProvider");
return ctx;
}