Files
alarm-client/App.tsx
T
Admin ff07df7ae6 [fix](client): paint the app background and default the theme to dark
Every screen but Login set `backgroundColor: bg` itself, so the login page and
the boot loader fell through to the platform's white surface while the text
tokens stayed light — white on light, unreadable. The palette in
`theme/tokens.ts` is fixed dark by construction, and `Uniwind.setTheme` defaulted
to light, so heroUI's own surfaces were light too. Paint the background at the
root, on the login screen and on the loader, and default the theme to dark.
2026-10-19 09:20:42 +01:00

153 lines
5.4 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { HeroUINativeProviderRaw } from 'heroui-native/provider-raw';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { 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 } 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>();
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>
);
}
function App() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: bg }}>
<HeroUINativeProviderRaw>
<SafeAreaProvider>
<ThemeProvider>
<ConnectionProvider>
<AuthProvider>
<NavigationContainer ref={navigationRef}>
<Root />
</NavigationContainer>
</AuthProvider>
</ConnectionProvider>
</ThemeProvider>
</SafeAreaProvider>
</HeroUINativeProviderRaw>
</GestureHandlerRootView>
);
}
export default App;