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:
@@ -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;
|
||||
}
|
||||
@@ -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 4–6 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user