chahinebrini aa9466aa92 feat(rebreak-native): Face ID app lock (opt-in)
Privacy/stigma layer on top of the authenticated Supabase session — re-auth on
open so nobody but the user can open Rebreak. Not a login replacement.

- expo-local-authentication; NSFaceIDUsageDescription in app.config
- stores/appLock.ts: persisted `enabled` pref, in-memory `locked`, device-
  capability check (`available`), device-passcode fallback on biometric failure
- AppLockGate wraps the root layout: locks immediately on `background` (not
  `inactive` → app-switcher peek doesn't lock), renders LockScreen while
  `enabled && locked && session`
- LockScreen: dark brand screen, auto-prompts on mount + on return from
  background, "Abmelden" escape hatch (clears session → fresh login next launch)
- Settings: new "Sicherheit" section, native UISwitch; enabling requires a
  successful biometric prompt first; row disabled + explained when device has no
  biometrics/passcode
- de/en strings

Per product call: the lock gates the whole app incl. SOS (SOS already requires
an authenticated user, so there's no unauthenticated path to carve out).

Cold-start: appLock init blocks the splash → `locked` is set before first paint,
no flash of unlocked content. ios/ is gitignored so EAS prebuilds the new module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:41:56 +02:00

179 lines
4.8 KiB
TypeScript

import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import * as SplashScreen from 'expo-splash-screen';
import {
useFonts,
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
Nunito_800ExtraBold,
} from '@expo-google-fonts/nunito';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language';
import { useAppLockStore } from '../stores/appLock';
import { BrandSplash } from '../components/BrandSplash';
import { AppLockGate } from '../components/AppLockGate';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 60,
},
},
});
function RootLayoutInner() {
const { loading, init } = useAuthStore();
const initTheme = useThemeStore((s) => s.init);
const colorScheme = useThemeStore((s) => s.colorScheme);
const initLanguage = useLanguageStore((s) => s.init);
const initAppLock = useAppLockStore((s) => s.init);
const appLockReady = useAppLockStore((s) => s.ready);
const colors = useColors();
const [fontsLoaded] = useFonts({
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
Nunito_800ExtraBold,
});
useEffect(() => {
init();
initTheme();
initLanguage();
initAppLock();
}, []);
useEffect(() => {
if (fontsLoaded && !loading && appLockReady) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, loading, appLockReady]);
if (!fontsLoaded || loading || !appLockReady) {
return <BrandSplash />;
}
return (
<AppLockGate>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
<Stack.Screen
name="lyra"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="urge"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen
name="dm"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="settings"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="profile/index"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="profile/[userId]"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="games"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="debug"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
</Stack>
</AppLockGate>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<SafeAreaProvider>
<RootLayoutInner />
</SafeAreaProvider>
</ActionSheetProvider>
</QueryClientProvider>
</KeyboardProvider>
</GestureHandlerRootView>
);
}