import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, Animated, AppState, Image, Text, TouchableOpacity, View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'expo-router'; import { useAppLockStore } from '../stores/appLock'; import { useAuthStore } from '../stores/auth'; /** * Vollbild-Overlay, das den App-Inhalt verdeckt solange die App-Sperre aktiv und * `locked` ist (siehe AppLockGate). Beim Mount — und jedes Mal wenn man aus dem * Hintergrund zur noch-gesperrten App zurückkommt — wird automatisch der * Face-ID/Touch-ID/Passcode-Prompt ausgelöst; schlägt er fehl oder bricht der * User ab, bleibt der „Entsperren"-Button stehen. * * iOS-Freeze-Falle (vormals: Screen blieb hängen, nur Hard-Quit half): * 1. Foreground läuft als background→inactive→active. Würde man nur auf * „prev === 'background'" prüfen, verschluckt das intermediäre 'inactive' den * Trigger → kein Re-Prompt bei Rückkehr → der Screen bleibt „tot". Deshalb * merken wir, ob wir seit dem letzten 'active' ÜBERHAUPT im Hintergrund waren. * 2. Der Face-ID-Sheet selbst schickt die App active→inactive→active. Dieses * 'inactive' darf NICHT als „war im Hintergrund" zählen (sonst Re-Prompt-Loop * direkt nach Abbruch) — nur echtes 'background' setzt das Flag. * 3. evaluatePolicy() während einer active↔inactive-Transition kann nativ hängen * bleiben und den Prompt nie auflösen → inFlight/busy würden für immer * latchen. Gegenmittel: nur prompten wenn die App hart 'active' ist, und beim * Backgrounden den Latch zwangsweise freigeben (iOS reißt den Sheet dabei eh * ab), damit der Re-Prompt bei Rückkehr garantiert sauber startet. * `cancelAuthenticate()` hilft hier NICHT — es ist Android-only (kein iOS-Nativ). * * „Abmelden" unten ist die Notausfahrt: clear't die Session → beim nächsten Start * gibt es keine Session → keine Sperre → frischer Login. Verhindert ein echtes * Aussperren falls Biometrie + Passcode versagen. */ export function LockScreen() { const { t } = useTranslation(); const router = useRouter(); const authenticate = useAppLockStore((s) => s.authenticate); const signOut = useAuthStore((s) => s.signOut); const [busy, setBusy] = useState(false); const inFlight = useRef(false); // dezenter Atem-Puls auf dem Icon (matcht den Splash-Vibe, ohne dessen ganze Choreo) const pulse = useRef(new Animated.Value(1)).current; useEffect(() => { Animated.loop( Animated.sequence([ Animated.timing(pulse, { toValue: 1.04, duration: 1300, useNativeDriver: true }), Animated.timing(pulse, { toValue: 1, duration: 1300, useNativeDriver: true }), ]), ).start(); }, [pulse]); const tryUnlock = useCallback(async () => { // Nur prompten wenn die App wirklich vorne ist. Ein evaluatePolicy()-Call // während einer active↔inactive-Transition kann nativ hängen bleiben und den // Prompt nie auflösen → Screen friert ein (Falle 3 oben). if (AppState.currentState !== 'active') return; if (inFlight.current) return; inFlight.current = true; setBusy(true); try { await authenticate(t('applock.prompt')); } finally { inFlight.current = false; setBusy(false); } }, [authenticate, t]); // Auto-Prompt: beim ersten aktiven Erscheinen + jeder Rückkehr aus dem // Hintergrund. Eine einzige Quelle der Wahrheit für den AppState (siehe die drei // iOS-Fallen im Doc-Block oben). useEffect(() => { let wasBackground = AppState.currentState !== 'active'; // Kaltstart/aktiv gemountet → einmal prompten. Lag die App beim Mount im // Hintergrund/inactive, übernimmt der Listener beim nächsten 'active'. if (AppState.currentState === 'active') tryUnlock(); const sub = AppState.addEventListener('change', (next) => { if (next === 'background') { wasBackground = true; // Sicherheitsnetz gegen einen hängenden nativen Prompt: Latch hart // freigeben (iOS hat den Face-ID-Sheet beim Backgrounden ohnehin abgerissen). inFlight.current = false; setBusy(false); } else if (next === 'active' && wasBackground) { wasBackground = false; tryUnlock(); } }); return () => sub.remove(); }, [tryUnlock]); function handleSignOut() { Alert.alert(t('applock.signOut_title'), t('applock.signOut_body'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('auth.signOut'), style: 'destructive', onPress: async () => { await signOut(); router.replace('/'); }, }, ]); } return ( {t('applock.title')} {t('applock.subtitle')} {t('applock.unlock')} {t('auth.signOut')} ); }