14c880123c
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>
233 lines
6.0 KiB
TypeScript
233 lines
6.0 KiB
TypeScript
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;
|
||
}
|