Files
Admin 31cff32824 [feat](client): join without an account; the token the join returns is the identity
There is no anonymous session to start and nothing to mint: the app comes up on
Home with no account, and the first QR scan performs the unauthenticated
joinAlert, which returns a deviceToken the client stores with the alert. That
token — not a user, not a session — is what the subscriber calls carry as
X-Device-Token, and what the socket puts in ?deviceToken=.

- apiClient takes an optional device token per request; when present it is sent
  instead of the access token, because it is the narrower credential
- alarmApi.joinAlert returns it; getAlert/alarmStatus/ackAlarm/
  removeSubscription/qrImage and the socket URL accept it
- the alert record keeps the token beside its subscribe token, so a queued ack
  for an alert that is gone still resolves the right device
- login is now a destination, never a wall: without a session the app is the
  subscriber view, and the login screen's 'continue without an account' button
  is gone because there is nothing to continue from

tsc clean, 129 tests (8 new ones pin the header transport and the
no-access-token join).
2026-10-19 18:41:49 +01:00

201 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;
// 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 <LoadingView label="Starting…" />;
return (
<AlarmProvider>
<Navigator initialRoute="Home" />
</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;