Files
alarm-client/App.tsx
T
Admin 020a90f0d3 [fix](client): apply the window insets so nothing draws under the system bars
React Native has Flutter's SafeArea under another name: react-native-safe-area-
context. The app mounted SafeAreaProvider but never consumed a single inset, so
on Android 15+ — where targetSdk 36 forces edge-to-edge and the window extends
behind both system bars — the Home header's settings gear sat underneath the
notification bar and could not be tapped.

A single SafeAreaFrame pads the tree by all four insets once, above the
navigator, so every present and future screen inherits the safe area instead of
each one re-deriving it.
2026-10-19 16:03:17 +01:00

203 lines
7.0 KiB
TypeScript

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<RootStackParamList>();
/**
* 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 (
<Stack.Navigator initialRouteName={initialRoute} screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="AlertDetail" component={AlertDetailScreen} />
<Stack.Screen name="QrDisplay" component={QrDisplayScreen} />
<Stack.Screen
name="Scanner"
component={ScannerScreen}
options={{ animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="Settings" component={SettingsScreen} />
{/*
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).
*/}
<Stack.Screen
name="LiveAlarm"
component={LiveAlarmScreen}
options={{
presentation: 'fullScreenModal',
animation: 'fade',
gestureEnabled: false,
headerShown: false,
}}
/>
</Stack.Navigator>
);
}
/**
* 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<typeof setTimeout> | 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<NativeStackNavigationProp<RootStackParamList>>();
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;
if (token) {
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
} else {
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
}
}, [token, navigation]);
useNativeLaunchTakeover();
if (booting) return <LoadingView label="Starting…" />;
return (
<AlarmProvider>
<Navigator initialRoute={token ? 'Home' : 'Login'} />
</AlarmProvider>
);
}
/**
* 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 (
<View
style={{
flex: 1,
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{children}
</View>
);
}
function App() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: bg }}>
<StatusBar barStyle="light-content" backgroundColor={bg} />
<HeroUINativeProviderRaw>
<SafeAreaProvider>
<SafeAreaFrame>
<ThemeProvider>
<ConnectionProvider>
<AuthProvider>
<NavigationContainer ref={navigationRef} theme={NAV_THEME}>
<Root />
</NavigationContainer>
</AuthProvider>
</ConnectionProvider>
</ThemeProvider>
</SafeAreaFrame>
</SafeAreaProvider>
</HeroUINativeProviderRaw>
</GestureHandlerRootView>
);
}
export default App;