import React, { useEffect, useRef } from 'react'; import { StatusBar, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { HeroUINativeProviderRaw } from 'heroui-native/provider-raw'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { DarkTheme, NavigationContainer, useNavigation } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ThemeProvider } from './src/state/ThemeContext'; import { ConnectionProvider } from './src/state/ConnectionContext'; import { AuthProvider, useAuth } from './src/state/AuthContext'; import { AlarmProvider } from './src/state/AlarmContext'; import LoginScreen from './src/screens/LoginScreen'; import HomeScreen from './src/screens/HomeScreen'; import AlertDetailScreen from './src/screens/AlertDetailScreen'; import QrDisplayScreen from './src/screens/QrDisplayScreen'; import ScannerScreen from './src/screens/ScannerScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import LiveAlarmScreen from './src/screens/LiveAlarmScreen'; import { LoadingView } from './src/components/ui'; import { bg, border, focusRing, text } from './src/theme/tokens'; import { navigationRef, openLiveAlarm } from './src/navigationRef'; import { getLaunchAlert } from './src/services/alarmNative'; import type { RootStackParamList } from './src/navigation'; const Stack = createNativeStackNavigator(); /** * React Navigation paints each scene with its own theme background, and the * default theme is light — that white surface is what showed through every * screen that did not paint `bg` itself (the login page, the boot loader), * producing light text on white. The app palette is dark by construction, so * the navigator theme has to say so too. */ const NAV_THEME = { ...DarkTheme, colors: { ...DarkTheme.colors, background: bg, card: bg, text, border, primary: focusRing, }, }; function Navigator({ initialRoute }: { initialRoute: 'Home' | 'Login' }) { return ( {/* The takeover is a modal presentation with no gesture dismissal and no back affordance: while an alarm is live it is the only reachable surface (spec §9, Cross-screen rules). */} ); } /** * A native full-screen launch (`AlarmActivity`, started by the armed foreground * service) carries the alert id in its intent. Read it once on boot and open the * takeover, so a trigger that cold-starts the process still lands on the alarm * rather than the home screen. Ordinary launches resolve `null`. * * Runs inside the navigator: the intent read is async, but `navigationRef` is * not ready on the first frame, so a launch that arrives early is retried until * the container is up (bounded — it is a boot path, not a poll). */ function useNativeLaunchTakeover() { useEffect(() => { let cancelled = false; let attempts = 0; let timer: ReturnType | undefined; const open = (params: RootStackParamList['LiveAlarm']) => { if (cancelled) return; if (navigationRef.isReady()) { openLiveAlarm(params); return; } if (attempts++ > 20) return; timer = setTimeout(() => open(params), 100); }; getLaunchAlert() .then(launch => { if (cancelled || !launch) return; open({ alertId: launch.alertId, message: launch.message || undefined, }); }) .catch(() => { // No native module (iOS / JS-only run) — nothing to do. }); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, []); } function Root() { const navigation = useNavigation>(); const { token, booting } = useAuth(); const prevToken = useRef(token); // Single navigator owns every route; auth state is driven by navigation // resets rather than remounting whole navigators. A reset always replaces // the whole stack, so Login/Home is the only route left, with no back stack. useEffect(() => { if (token === prevToken.current) return; prevToken.current = token; // Home is always reachable: without an account it is the subscriber view // (rev 10). Login is an explicit destination, never a wall. navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); }, [token, navigation]); useNativeLaunchTakeover(); if (booting) return ; return ( ); } /** * React Native's equivalent of Flutter's `SafeArea`: pad the whole tree by the * platform's window insets once, so no screen can draw its header under the * status bar or its buttons under the navigation bar. * * This is not optional on Android 15+: with `targetSdk 36` the system enforces * edge-to-edge, so the window extends behind both system bars and the app is * responsible for the padding. Without it the Home header's settings gear sat * underneath the notification bar and could not be tapped. */ function SafeAreaFrame({ children }: { children: React.ReactNode }) { const insets = useSafeAreaInsets(); return ( {children} ); } function App() { return ( ); } export default App;