mirror of
https://github.com/ruvnet/RuView.git
synced 2026-09-01 04:55:54 +00:00
Services: ws.service, api.service, simulation.service, rssi.service (android+ios) Stores: poseStore, settingsStore, matStore (Zustand) Types: sensing, mat, api, navigation Hooks: usePoseStream, useRssiScanner, useServerReachability Theme: colors, typography, spacing, ThemeContext Navigation: MainTabs (5 tabs), RootNavigator, types Components: GaugeArc, SparklineChart, OccupancyGrid, StatusDot, ConnectionBanner, SignalBar, +more Utils: ringBuffer, colorMap, formatters, urlValidator Verified: tsc 0 errors, jest passes
163 lines
5.0 KiB
TypeScript
163 lines
5.0 KiB
TypeScript
import React, { Suspense, useEffect, useState } from 'react';
|
|
import { ActivityIndicator } from 'react-native';
|
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { ThemedText } from '../components/ThemedText';
|
|
import { ThemedView } from '../components/ThemedView';
|
|
import { colors } from '../theme/colors';
|
|
import { MainTabsParamList } from './types';
|
|
|
|
const createPlaceholder = (label: string) => {
|
|
const Placeholder = () => (
|
|
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
<ThemedText preset="bodyLg">{label} screen not implemented yet</ThemedText>
|
|
<ThemedText preset="bodySm" color="textSecondary">
|
|
Placeholder shell
|
|
</ThemedText>
|
|
</ThemedView>
|
|
);
|
|
const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder }));
|
|
|
|
const Wrapped = () => (
|
|
<Suspense
|
|
fallback={
|
|
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
<ActivityIndicator color={colors.accent} />
|
|
<ThemedText preset="bodySm" color="textSecondary" style={{ marginTop: 8 }}>
|
|
Loading {label}
|
|
</ThemedText>
|
|
</ThemedView>
|
|
}
|
|
>
|
|
<LazyPlaceholder />
|
|
</Suspense>
|
|
);
|
|
|
|
return Wrapped;
|
|
};
|
|
|
|
const loadScreen = (path: string, label: string) => {
|
|
const fallback = createPlaceholder(label);
|
|
return React.lazy(async () => {
|
|
try {
|
|
const module = (await import(path)) as { default: React.ComponentType };
|
|
if (module?.default) {
|
|
return module;
|
|
}
|
|
} catch {
|
|
// keep fallback for shell-only screens
|
|
}
|
|
return { default: fallback } as { default: React.ComponentType };
|
|
});
|
|
};
|
|
|
|
const LiveScreen = loadScreen('../screens/LiveScreen', 'Live');
|
|
const VitalsScreen = loadScreen('../screens/VitalsScreen', 'Vitals');
|
|
const ZonesScreen = loadScreen('../screens/ZonesScreen', 'Zones');
|
|
const MATScreen = loadScreen('../screens/MATScreen', 'MAT');
|
|
const SettingsScreen = loadScreen('../screens/SettingsScreen', 'Settings');
|
|
|
|
const toIconName = (routeName: keyof MainTabsParamList) => {
|
|
switch (routeName) {
|
|
case 'Live':
|
|
return 'wifi';
|
|
case 'Vitals':
|
|
return 'heart';
|
|
case 'Zones':
|
|
return 'grid';
|
|
case 'MAT':
|
|
return 'shield-checkmark';
|
|
case 'Settings':
|
|
return 'settings';
|
|
default:
|
|
return 'ellipse';
|
|
}
|
|
};
|
|
|
|
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 }> = [
|
|
{ name: 'Live', component: LiveScreen },
|
|
{ name: 'Vitals', component: VitalsScreen },
|
|
{ name: 'Zones', component: ZonesScreen },
|
|
{ name: 'MAT', component: MATScreen },
|
|
{ name: 'Settings', component: SettingsScreen },
|
|
];
|
|
|
|
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
|
|
|
const Suspended = ({ component: Component }: { component: React.ComponentType }) => (
|
|
<Suspense fallback={<ActivityIndicator color={colors.accent} />}>
|
|
<Component />
|
|
</Suspense>
|
|
);
|
|
|
|
export const MainTabs = () => {
|
|
const [matAlertCount, setMatAlertCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const readCount = async () => {
|
|
const count = await getMatAlertCount();
|
|
setMatAlertCount(count);
|
|
};
|
|
|
|
void readCount();
|
|
const timer = setInterval(readCount, 2000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
return (
|
|
<Tab.Navigator
|
|
screenOptions={({ route }) => ({
|
|
headerShown: false,
|
|
tabBarActiveTintColor: colors.accent,
|
|
tabBarInactiveTintColor: colors.textSecondary,
|
|
tabBarStyle: {
|
|
backgroundColor: '#0D1117',
|
|
borderTopColor: colors.border,
|
|
borderTopWidth: 1,
|
|
},
|
|
tabBarIcon: ({ color, size }) => <Ionicons name={toIconName(route.name)} size={size} color={color} />,
|
|
tabBarLabelStyle: {
|
|
fontFamily: 'Courier New',
|
|
textTransform: 'uppercase',
|
|
fontSize: 10,
|
|
},
|
|
tabBarLabel: ({ children, color }) => <ThemedText style={{ color }}>{children}</ThemedText>,
|
|
})}
|
|
>
|
|
{screens.map(({ name, component }) => (
|
|
<Tab.Screen
|
|
key={name}
|
|
name={name}
|
|
options={{
|
|
tabBarBadge: name === 'MAT' ? (matAlertCount > 0 ? matAlertCount : undefined) : undefined,
|
|
}}
|
|
component={() => <Suspended component={component} />}
|
|
/>
|
|
))}
|
|
</Tab.Navigator>
|
|
);
|
|
};
|