init
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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 { 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 }}>
|
||||
<HeroUINativeProviderRaw>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
<ConnectionProvider>
|
||||
<AuthProvider>
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<Root />
|
||||
</NavigationContainer>
|
||||
</AuthProvider>
|
||||
</ConnectionProvider>
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</HeroUINativeProviderRaw>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user