Files
RuView/mobile/src/navigation/MainTabs.tsx
Yossi Elkrief 779bf8ff43 feat: Phase 3 — services, stores, navigation, design system
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
2026-03-02 12:53:45 +02:00

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>
);
};