feat: full app integration — all screens wired and working
This commit is contained in:
@@ -3,10 +3,46 @@ import { NavigationContainer, DarkTheme } from '@react-navigation/native';
|
|||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { apiService } from '@/services/api.service';
|
||||||
|
import { rssiService } from '@/services/rssi.service';
|
||||||
|
import { wsService } from '@/services/ws.service';
|
||||||
import { ThemeProvider } from './src/theme/ThemeContext';
|
import { ThemeProvider } from './src/theme/ThemeContext';
|
||||||
|
import { usePoseStore } from './src/stores/poseStore';
|
||||||
|
import { useSettingsStore } from './src/stores/settingsStore';
|
||||||
import { RootNavigator } from './src/navigation/RootNavigator';
|
import { RootNavigator } from './src/navigation/RootNavigator';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const serverUrl = useSettingsStore((state) => state.serverUrl);
|
||||||
|
const rssiScanEnabled = useSettingsStore((state) => state.rssiScanEnabled);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiService.setBaseUrl(serverUrl);
|
||||||
|
const unsubscribe = wsService.subscribe(usePoseStore.getState().handleFrame);
|
||||||
|
wsService.connect(serverUrl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
wsService.disconnect();
|
||||||
|
};
|
||||||
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rssiScanEnabled) {
|
||||||
|
rssiService.stopScanning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = rssiService.subscribe(() => {
|
||||||
|
// Consumers can subscribe elsewhere for RSSI events.
|
||||||
|
});
|
||||||
|
rssiService.startScanning(2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
rssiService.stopScanning();
|
||||||
|
};
|
||||||
|
}, [rssiScanEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(globalThis as { __appStartTime?: number }).__appStartTime = Date.now();
|
(globalThis as { __appStartTime?: number }).__appStartTime = Date.now();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -9,3 +9,16 @@ jest.mock('react-native-wifi-reborn', () => ({
|
|||||||
jest.mock('react-native-reanimated', () =>
|
jest.mock('react-native-reanimated', () =>
|
||||||
require('react-native-reanimated/mock')
|
require('react-native-reanimated/mock')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
jest.mock('react-native-webview', () => {
|
||||||
|
const React = require('react');
|
||||||
|
const { View } = require('react-native');
|
||||||
|
|
||||||
|
const MockWebView = (props: unknown) => React.createElement(View, props);
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: MockWebView,
|
||||||
|
WebView: MockWebView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
36
mobile/src/__tests__/test-utils.tsx
Normal file
36
mobile/src/__tests__/test-utils.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { PropsWithChildren } from 'react';
|
||||||
|
import { render, type RenderOptions } from '@testing-library/react-native';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { ThemeProvider } from '@/theme/ThemeContext';
|
||||||
|
|
||||||
|
type TestProvidersProps = PropsWithChildren<object>;
|
||||||
|
|
||||||
|
const TestProviders = ({ children }: TestProvidersProps) => (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TestProvidersWithNavigation = ({ children }: TestProvidersProps) => (
|
||||||
|
<TestProviders>
|
||||||
|
<NavigationContainer>{children}</NavigationContainer>
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
|
withNavigation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderWithProviders = (
|
||||||
|
ui: React.ReactElement,
|
||||||
|
{ withNavigation, ...options }: RenderWithProvidersOptions = {},
|
||||||
|
) => {
|
||||||
|
return render(ui, {
|
||||||
|
...options,
|
||||||
|
wrapper: withNavigation ? TestProvidersWithNavigation : TestProviders,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { wsService } from '@/services/ws.service';
|
import { wsService } from '@/services/ws.service';
|
||||||
import { usePoseStore } from '@/stores/poseStore';
|
import { usePoseStore } from '@/stores/poseStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
|
||||||
|
|
||||||
export interface UsePoseStreamResult {
|
export interface UsePoseStreamResult {
|
||||||
connectionStatus: ReturnType<typeof usePoseStore.getState>['connectionStatus'];
|
connectionStatus: ReturnType<typeof usePoseStore.getState>['connectionStatus'];
|
||||||
@@ -10,7 +9,6 @@ export interface UsePoseStreamResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePoseStream(): UsePoseStreamResult {
|
export function usePoseStream(): UsePoseStreamResult {
|
||||||
const serverUrl = useSettingsStore((state) => state.serverUrl);
|
|
||||||
const connectionStatus = usePoseStore((state) => state.connectionStatus);
|
const connectionStatus = usePoseStore((state) => state.connectionStatus);
|
||||||
const lastFrame = usePoseStore((state) => state.lastFrame);
|
const lastFrame = usePoseStore((state) => state.lastFrame);
|
||||||
const isSimulated = usePoseStore((state) => state.isSimulated);
|
const isSimulated = usePoseStore((state) => state.isSimulated);
|
||||||
@@ -19,13 +17,11 @@ export function usePoseStream(): UsePoseStreamResult {
|
|||||||
const unsubscribe = wsService.subscribe((frame) => {
|
const unsubscribe = wsService.subscribe((frame) => {
|
||||||
usePoseStore.getState().handleFrame(frame);
|
usePoseStore.getState().handleFrame(frame);
|
||||||
});
|
});
|
||||||
wsService.connect(serverUrl);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
wsService.disconnect();
|
|
||||||
};
|
};
|
||||||
}, [serverUrl]);
|
}, []);
|
||||||
|
|
||||||
return { connectionStatus, lastFrame, isSimulated };
|
return { connectionStatus, lastFrame, isSimulated };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { Suspense, useEffect, useState } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { ActivityIndicator } from 'react-native';
|
import { ActivityIndicator } from 'react-native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { ThemedText } from '../components/ThemedText';
|
import { ThemedText } from '../components/ThemedText';
|
||||||
import { ThemedView } from '../components/ThemedView';
|
import { ThemedView } from '../components/ThemedView';
|
||||||
import { colors } from '../theme/colors';
|
import { colors } from '../theme/colors';
|
||||||
|
import { useMatStore } from '../stores/matStore';
|
||||||
import { MainTabsParamList } from './types';
|
import { MainTabsParamList } from './types';
|
||||||
|
|
||||||
const createPlaceholder = (label: string) => {
|
const createPlaceholder = (label: string) => {
|
||||||
@@ -74,29 +75,6 @@ const toIconName = (routeName: keyof MainTabsParamList) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMatAlertCount = async (): Promise<number> => {
|
|
||||||
try {
|
|
||||||
const mod = (await import('../stores/matStore')) as Record<string, unknown>;
|
|
||||||
const candidates = [mod.useMatStore, mod.useStore].filter((candidate) => {
|
|
||||||
return (
|
|
||||||
!!candidate &&
|
|
||||||
typeof candidate === 'function' &&
|
|
||||||
typeof (candidate as { getState?: () => unknown }).getState === 'function'
|
|
||||||
);
|
|
||||||
}) as Array<{ getState: () => { alerts?: unknown[] } }>;
|
|
||||||
|
|
||||||
for (const store of candidates) {
|
|
||||||
const alerts = store.getState().alerts;
|
|
||||||
if (Array.isArray(alerts)) {
|
|
||||||
return alerts.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const screens: ReadonlyArray<{ name: keyof MainTabsParamList; component: React.ComponentType }> = [
|
const screens: ReadonlyArray<{ name: keyof MainTabsParamList; component: React.ComponentType }> = [
|
||||||
{ name: 'Live', component: LiveScreen },
|
{ name: 'Live', component: LiveScreen },
|
||||||
{ name: 'Vitals', component: VitalsScreen },
|
{ name: 'Vitals', component: VitalsScreen },
|
||||||
@@ -114,18 +92,7 @@ const Suspended = ({ component: Component }: { component: React.ComponentType })
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const MainTabs = () => {
|
export const MainTabs = () => {
|
||||||
const [matAlertCount, setMatAlertCount] = useState(0);
|
const matAlertCount = useMatStore((state) => state.alerts.length);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const readCount = async () => {
|
|
||||||
const count = await getMatAlertCount();
|
|
||||||
setMatAlertCount(count);
|
|
||||||
};
|
|
||||||
|
|
||||||
void readCount();
|
|
||||||
const timer = setInterval(readCount, 2000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
|
|||||||
@@ -98,10 +98,11 @@ export const SettingsScreen = () => {
|
|||||||
const intervalSummary = useMemo(() => `${scanInterval}s`, [scanInterval]);
|
const intervalSummary = useMemo(() => `${scanInterval}s`, [scanInterval]);
|
||||||
|
|
||||||
const handleSaveUrl = () => {
|
const handleSaveUrl = () => {
|
||||||
setServerUrl(draftUrl);
|
const newUrl = draftUrl.trim();
|
||||||
apiService.setBaseUrl(draftUrl);
|
setServerUrl(newUrl);
|
||||||
wsService.disconnect();
|
wsService.disconnect();
|
||||||
wsService.connect(draftUrl);
|
wsService.connect(newUrl);
|
||||||
|
apiService.setBaseUrl(newUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenGitHub = async () => {
|
const handleOpenGitHub = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user