feat: Zones, MAT, and Settings screens #82

Open
MaTriXy wants to merge 3 commits from MaTriXy/feat/mobile-zones-mat-settings into main
45 changed files with 2290 additions and 19 deletions
Showing only changes of commit 779bf8ff43 - Show all commits

View File

@@ -1,25 +1,38 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useEffect } from 'react';
import { NavigationContainer, DarkTheme } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from './src/theme/ThemeContext';
import { RootNavigator } from './src/navigation/RootNavigator';
export default function App() {
useEffect(() => {
(globalThis as { __appStartTime?: number }).__appStartTime = Date.now();
}, []);
const navigationTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: '#0A0E1A',
card: '#0D1117',
text: '#E2E8F0',
border: '#1E293B',
primary: '#32B8C6',
},
};
return (
<View style={styles.container}>
<Text style={styles.title}>WiFi-DensePose</Text>
<StatusBar style="dark" />
</View>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ThemeProvider>
<NavigationContainer theme={navigationTheme}>
<RootNavigator />
</NavigationContainer>
</ThemeProvider>
</SafeAreaProvider>
<StatusBar style="light" />
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
},
title: {
fontSize: 24,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,70 @@
import { StyleSheet, View } from 'react-native';
import { ThemedText } from './ThemedText';
type ConnectionState = 'connected' | 'simulated' | 'disconnected';
type ConnectionBannerProps = {
status: ConnectionState;
};
const resolveState = (status: ConnectionState) => {
if (status === 'connected') {
return {
label: 'LIVE STREAM',
backgroundColor: '#0F6B2A',
textColor: '#E2FFEA',
};
}
if (status === 'disconnected') {
return {
label: 'DISCONNECTED',
backgroundColor: '#8A1E2A',
textColor: '#FFE3E7',
};
}
return {
label: 'SIMULATED DATA',
backgroundColor: '#9A5F0C',
textColor: '#FFF3E1',
};
};
export const ConnectionBanner = ({ status }: ConnectionBannerProps) => {
const state = resolveState(status);
return (
<View
style={[
styles.banner,
{
backgroundColor: state.backgroundColor,
borderBottomColor: state.textColor,
},
]}
>
<ThemedText preset="labelMd" style={[styles.text, { color: state.textColor }]}>
{state.label}
</ThemedText>
</View>
);
};
const styles = StyleSheet.create({
banner: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 100,
paddingVertical: 6,
borderBottomWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
text: {
letterSpacing: 2,
fontWeight: '700',
},
});

View File

@@ -0,0 +1,66 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { ThemedText } from './ThemedText';
import { ThemedView } from './ThemedView';
type ErrorBoundaryProps = {
children: ReactNode;
};
type ErrorBoundaryState = {
hasError: boolean;
error?: Error;
};
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error', error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
return (
<ThemedView style={styles.container}>
<ThemedText preset="displayMd">Something went wrong</ThemedText>
<ThemedText preset="bodySm" style={styles.message}>
{this.state.error?.message ?? 'An unexpected error occurred.'}
</ThemedText>
<View style={styles.buttonWrap}>
<Button title="Retry" onPress={this.handleRetry} />
</View>
</ThemedView>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
gap: 12,
},
message: {
textAlign: 'center',
},
buttonWrap: {
marginTop: 8,
},
});

View File

@@ -0,0 +1,96 @@
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, { useAnimatedProps, useSharedValue, withTiming } from 'react-native-reanimated';
import Svg, { Circle, G, Text as SvgText } from 'react-native-svg';
type GaugeArcProps = {
value: number;
max: number;
label: string;
unit: string;
color: string;
size?: number;
};
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export const GaugeArc = ({ value, max, label, unit, color, size = 140 }: GaugeArcProps) => {
const radius = (size - 20) / 2;
const circumference = 2 * Math.PI * radius;
const arcLength = circumference * 0.75;
const strokeWidth = 12;
const progress = useSharedValue(0);
const normalized = Math.max(0, Math.min(max > 0 ? value / max : 0, 1));
const displayText = `${value.toFixed(1)} ${unit}`;
useEffect(() => {
progress.value = withTiming(normalized, { duration: 600 });
}, [normalized, progress]);
const animatedStroke = useAnimatedProps(() => {
const dashOffset = arcLength - arcLength * progress.value;
return {
strokeDashoffset: dashOffset,
};
});
return (
<View style={styles.wrapper}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<G transform={`rotate(-135 ${size / 2} ${size / 2})`}>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
stroke="#1E293B"
fill="none"
strokeDasharray={`${arcLength} ${circumference}`}
strokeLinecap="round"
/>
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
stroke={color}
fill="none"
strokeDasharray={`${arcLength} ${circumference}`}
strokeLinecap="round"
animatedProps={animatedStroke}
/>
</G>
<SvgText
x={size / 2}
y={size / 2 - 4}
fill="#E2E8F0"
fontSize={18}
fontFamily="Courier New"
fontWeight="700"
textAnchor="middle"
>
{displayText}
</SvgText>
<SvgText
x={size / 2}
y={size / 2 + 16}
fill="#94A3B8"
fontSize={10}
fontFamily="Courier New"
textAnchor="middle"
letterSpacing="0.6"
>
{label}
</SvgText>
</Svg>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { StyleSheet, ViewStyle } from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
import Svg, { Circle } from 'react-native-svg';
import { colors } from '../theme/colors';
type LoadingSpinnerProps = {
size?: number;
color?: string;
style?: ViewStyle;
};
export const LoadingSpinner = ({ size = 36, color = colors.accent, style }: LoadingSpinnerProps) => {
const rotation = useSharedValue(0);
const strokeWidth = Math.max(4, size * 0.14);
const center = size / 2;
const radius = center - strokeWidth;
const circumference = 2 * Math.PI * radius;
useEffect(() => {
rotation.value = withRepeat(withTiming(360, { duration: 900, easing: Easing.linear }), -1);
}, [rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotateZ: `${rotation.value}deg` }],
}));
return (
<Animated.View style={[styles.container, { width: size, height: size }, style, animatedStyle]} pointerEvents="none">
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Circle
cx={center}
cy={center}
r={radius}
stroke="rgba(255,255,255,0.2)"
strokeWidth={strokeWidth}
fill="none"
/>
<Circle
cx={center}
cy={center}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={`${circumference * 0.3} ${circumference * 0.7}`}
strokeDashoffset={circumference * 0.2}
/>
</Svg>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -0,0 +1,71 @@
import { StyleSheet } from 'react-native';
import { ThemedText } from './ThemedText';
import { colors } from '../theme/colors';
type Mode = 'CSI' | 'RSSI' | 'SIM' | 'LIVE';
const modeStyle: Record<
Mode,
{
background: string;
border: string;
color: string;
}
> = {
CSI: {
background: 'rgba(50, 184, 198, 0.25)',
border: colors.accent,
color: colors.accent,
},
RSSI: {
background: 'rgba(255, 165, 2, 0.2)',
border: colors.warn,
color: colors.warn,
},
SIM: {
background: 'rgba(255, 71, 87, 0.18)',
border: colors.simulated,
color: colors.simulated,
},
LIVE: {
background: 'rgba(46, 213, 115, 0.18)',
border: colors.connected,
color: colors.connected,
},
};
type ModeBadgeProps = {
mode: Mode;
};
export const ModeBadge = ({ mode }: ModeBadgeProps) => {
const style = modeStyle[mode];
return (
<ThemedText
preset="labelMd"
style={[
styles.badge,
{
backgroundColor: style.background,
borderColor: style.border,
color: style.color,
},
]}
>
{mode}
</ThemedText>
);
};
const styles = StyleSheet.create({
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
overflow: 'hidden',
letterSpacing: 1,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,147 @@
import { useEffect, useMemo, useRef } from 'react';
import { StyleProp, ViewStyle } from 'react-native';
import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withTiming, type SharedValue } from 'react-native-reanimated';
import Svg, { Circle, G, Rect } from 'react-native-svg';
import { colors } from '../theme/colors';
type Point = {
x: number;
y: number;
};
type OccupancyGridProps = {
values: number[];
personPositions?: Point[];
size?: number;
style?: StyleProp<ViewStyle>;
};
const GRID_DIMENSION = 20;
const CELLS = GRID_DIMENSION * GRID_DIMENSION;
const toColor = (value: number): string => {
const clamped = Math.max(0, Math.min(1, value));
let r: number;
let g: number;
let b: number;
if (clamped < 0.5) {
const t = clamped * 2;
r = Math.round(255 * 0);
g = Math.round(255 * t);
b = Math.round(255 * (1 - t));
} else {
const t = (clamped - 0.5) * 2;
r = Math.round(255 * t);
g = Math.round(255 * (1 - t));
b = 0;
}
return `rgb(${r}, ${g}, ${b})`;
};
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const normalizeValues = (values: number[]) => {
const normalized = new Array(CELLS).fill(0);
for (let i = 0; i < CELLS; i += 1) {
const value = values?.[i] ?? 0;
normalized[i] = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
return normalized;
};
type CellProps = {
index: number;
size: number;
progress: SharedValue<number>;
previousColors: string[];
nextColors: string[];
};
const Cell = ({ index, size, progress, previousColors, nextColors }: CellProps) => {
const col = index % GRID_DIMENSION;
const row = Math.floor(index / GRID_DIMENSION);
const cellSize = size / GRID_DIMENSION;
const x = col * cellSize;
const y = row * cellSize;
const animatedProps = useAnimatedProps(() => ({
fill: interpolateColor(
progress.value,
[0, 1],
[previousColors[index] ?? colors.surfaceAlt, nextColors[index] ?? colors.surfaceAlt],
),
}));
return (
<AnimatedRect
x={x}
y={y}
width={cellSize}
height={cellSize}
rx={1}
animatedProps={animatedProps}
/>
);
};
export const OccupancyGrid = ({
values,
personPositions = [],
size = 320,
style,
}: OccupancyGridProps) => {
const normalizedValues = useMemo(() => normalizeValues(values), [values]);
const previousColors = useRef<string[]>(normalizedValues.map(toColor));
const nextColors = useRef<string[]>(normalizedValues.map(toColor));
const progress = useSharedValue(1);
useEffect(() => {
const next = normalizeValues(values);
previousColors.current = normalizedValues.map(toColor);
nextColors.current = next.map(toColor);
progress.value = 0;
progress.value = withTiming(1, { duration: 500 });
}, [values, normalizedValues, progress]);
const markers = useMemo(() => {
const cellSize = size / GRID_DIMENSION;
return personPositions.map(({ x, y }, idx) => {
const clampedX = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(x)));
const clampedY = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(y)));
const cx = (clampedX + 0.5) * cellSize;
const cy = (clampedY + 0.5) * cellSize;
const markerRadius = Math.max(3, cellSize * 0.25);
return (
<Circle
key={`person-${idx}`}
cx={cx}
cy={cy}
r={markerRadius}
fill={colors.accent}
stroke={colors.textPrimary}
strokeWidth={1}
/>
);
});
}, [personPositions, size]);
return (
<Svg width={size} height={size} style={style} viewBox={`0 0 ${size} ${size}`}>
<G>
{Array.from({ length: CELLS }).map((_, index) => (
<Cell
key={index}
index={index}
size={size}
progress={progress}
previousColors={previousColors.current}
nextColors={nextColors.current}
/>
))}
</G>
{markers}
</Svg>
);
};

View File

@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { ThemedText } from './ThemedText';
import { colors } from '../theme/colors';
type SignalBarProps = {
value: number;
label: string;
color?: string;
};
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
export const SignalBar = ({ value, label, color = colors.accent }: SignalBarProps) => {
const progress = useSharedValue(clamp01(value));
useEffect(() => {
progress.value = withTiming(clamp01(value), { duration: 250 });
}, [value, progress]);
const animatedFill = useAnimatedStyle(() => ({
width: `${progress.value * 100}%`,
}));
return (
<View style={styles.container}>
<ThemedText preset="bodySm" style={styles.label}>
{label}
</ThemedText>
<View style={styles.track}>
<Animated.View style={[styles.fill, { backgroundColor: color }, animatedFill]} />
</View>
<ThemedText preset="bodySm" style={styles.percent}>
{Math.round(clamp01(value) * 100)}%
</ThemedText>
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 6,
},
label: {
marginBottom: 4,
},
track: {
height: 8,
borderRadius: 4,
backgroundColor: colors.surfaceAlt,
overflow: 'hidden',
},
fill: {
height: '100%',
borderRadius: 4,
},
percent: {
textAlign: 'right',
color: colors.textSecondary,
},
});

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { View, ViewStyle } from 'react-native';
import { colors } from '../theme/colors';
type SparklineChartProps = {
data: number[];
color?: string;
height?: number;
style?: ViewStyle;
};
const defaultHeight = 72;
export const SparklineChart = ({
data,
color = colors.accent,
height = defaultHeight,
style,
}: SparklineChartProps) => {
const normalizedData = data.length > 0 ? data : [0];
const chartData = useMemo(
() =>
normalizedData.map((value, index) => ({
x: index,
y: value,
})),
[normalizedData],
);
const yValues = normalizedData.map((value) => Number(value) || 0);
const yMin = Math.min(...yValues);
const yMax = Math.max(...yValues);
const yPadding = yMax - yMin === 0 ? 1 : (yMax - yMin) * 0.2;
return (
<View style={style}>
<View
accessibilityRole="image"
style={{
height,
width: '100%',
borderRadius: 4,
borderWidth: 1,
borderColor: color,
opacity: 0.2,
backgroundColor: 'transparent',
}}
>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
{chartData.map((point) => (
<View key={point.x} style={{ position: 'absolute', left: `${(point.x / Math.max(normalizedData.length - 1, 1)) * 100}%` }} />
))}
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { StyleSheet, ViewStyle } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { colors } from '../theme/colors';
type StatusType = 'connected' | 'simulated' | 'disconnected' | 'connecting';
type StatusDotProps = {
status: StatusType;
size?: number;
style?: ViewStyle;
};
const resolveColor = (status: StatusType): string => {
if (status === 'connecting') return colors.warn;
return colors[status];
};
export const StatusDot = ({ status, size = 10, style }: StatusDotProps) => {
const scale = useSharedValue(1);
const opacity = useSharedValue(1);
const isConnecting = status === 'connecting';
useEffect(() => {
if (isConnecting) {
scale.value = withRepeat(
withSequence(
withTiming(1.35, { duration: 800, easing: Easing.out(Easing.cubic) }),
withTiming(1, { duration: 800, easing: Easing.in(Easing.cubic) }),
),
-1,
);
opacity.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 800, easing: Easing.out(Easing.quad) }),
withTiming(1, { duration: 800, easing: Easing.in(Easing.quad) }),
),
-1,
);
return;
}
cancelAnimation(scale);
cancelAnimation(opacity);
scale.value = 1;
opacity.value = 1;
}, [isConnecting, opacity, scale]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Animated.View
style={[
styles.dot,
{
width: size,
height: size,
backgroundColor: resolveColor(status),
borderRadius: size / 2,
},
animatedStyle,
style,
]}
/>
);
};
const styles = StyleSheet.create({
dot: {
borderRadius: 999,
},
});

View File

@@ -0,0 +1,28 @@
import { ComponentPropsWithoutRef } from 'react';
import { StyleProp, Text, TextStyle } from 'react-native';
import { useTheme } from '../hooks/useTheme';
import { colors } from '../theme/colors';
import { typography } from '../theme/typography';
type TextPreset = keyof typeof typography;
type ColorKey = keyof typeof colors;
type ThemedTextProps = Omit<ComponentPropsWithoutRef<typeof Text>, 'style'> & {
preset?: TextPreset;
color?: ColorKey;
style?: StyleProp<TextStyle>;
};
export const ThemedText = ({
preset = 'bodyMd',
color = 'textPrimary',
style,
...props
}: ThemedTextProps) => {
const { colors, typography } = useTheme();
const presetStyle = (typography as Record<TextPreset, TextStyle>)[preset];
const colorStyle = { color: colors[color] };
return <Text {...props} style={[presetStyle, colorStyle, style]} />;
};

View File

@@ -0,0 +1,24 @@
import { PropsWithChildren, forwardRef } from 'react';
import { View, ViewProps } from 'react-native';
import { useTheme } from '../hooks/useTheme';
type ThemedViewProps = PropsWithChildren<ViewProps>;
export const ThemedView = forwardRef<View, ThemedViewProps>(({ children, style, ...props }, ref) => {
const { colors } = useTheme();
return (
<View
ref={ref}
{...props}
style={[
{
backgroundColor: colors.bg,
},
style,
]}
>
{children}
</View>
);
});

View File

@@ -0,0 +1,14 @@
export const API_ROOT = '/api/v1';
export const API_POSE_STATUS_PATH = '/api/v1/pose/status';
export const API_POSE_FRAMES_PATH = '/api/v1/pose/frames';
export const API_POSE_ZONES_PATH = '/api/v1/pose/zones';
export const API_POSE_CURRENT_PATH = '/api/v1/pose/current';
export const API_STREAM_STATUS_PATH = '/api/v1/stream/status';
export const API_STREAM_POSE_PATH = '/api/v1/stream/pose';
export const API_MAT_EVENTS_PATH = '/api/v1/mat/events';
export const API_HEALTH_PATH = '/health';
export const API_HEALTH_SYSTEM_PATH = '/health/health';
export const API_HEALTH_READY_PATH = '/health/ready';
export const API_HEALTH_LIVE_PATH = '/health/live';

View File

@@ -0,0 +1,20 @@
export const SIMULATION_TICK_INTERVAL_MS = 500;
export const SIMULATION_GRID_SIZE = 20;
export const RSSI_BASE_DBM = -45;
export const RSSI_AMPLITUDE_DBM = 3;
export const VARIANCE_BASE = 1.5;
export const VARIANCE_AMPLITUDE = 1.0;
export const MOTION_BAND_MIN = 0.05;
export const MOTION_BAND_AMPLITUDE = 0.15;
export const BREATHING_BAND_MIN = 0.03;
export const BREATHING_BAND_AMPLITUDE = 0.08;
export const SIGNAL_FIELD_PRESENCE_LEVEL = 0.8;
export const BREATHING_BPM_MIN = 12;
export const BREATHING_BPM_MAX = 24;
export const HEART_BPM_MIN = 58;
export const HEART_BPM_MAX = 96;

View File

@@ -0,0 +1,3 @@
export const WS_PATH = '/api/v1/stream/pose';
export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
export const MAX_RECONNECT_ATTEMPTS = 10;

View File

@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { wsService } from '@/services/ws.service';
import { usePoseStore } from '@/stores/poseStore';
import { useSettingsStore } from '@/stores/settingsStore';
export interface UsePoseStreamResult {
connectionStatus: ReturnType<typeof usePoseStore.getState>['connectionStatus'];
lastFrame: ReturnType<typeof usePoseStore.getState>['lastFrame'];
isSimulated: boolean;
}
export function usePoseStream(): UsePoseStreamResult {
const serverUrl = useSettingsStore((state) => state.serverUrl);
const connectionStatus = usePoseStore((state) => state.connectionStatus);
const lastFrame = usePoseStore((state) => state.lastFrame);
const isSimulated = usePoseStore((state) => state.isSimulated);
useEffect(() => {
const unsubscribe = wsService.subscribe((frame) => {
usePoseStore.getState().handleFrame(frame);
});
wsService.connect(serverUrl);
return () => {
unsubscribe();
wsService.disconnect();
};
}, [serverUrl]);
return { connectionStatus, lastFrame, isSimulated };
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { rssiService, type WifiNetwork } from '@/services/rssi.service';
import { useSettingsStore } from '@/stores/settingsStore';
export function useRssiScanner(): { networks: WifiNetwork[]; isScanning: boolean } {
const enabled = useSettingsStore((state) => state.rssiScanEnabled);
const [networks, setNetworks] = useState<WifiNetwork[]>([]);
const [isScanning, setIsScanning] = useState(false);
useEffect(() => {
if (!enabled) {
rssiService.stopScanning();
setIsScanning(false);
return;
}
const unsubscribe = rssiService.subscribe((result) => {
setNetworks(result);
});
rssiService.startScanning(2000);
setIsScanning(true);
return () => {
unsubscribe();
rssiService.stopScanning();
setIsScanning(false);
};
}, [enabled]);
return { networks, isScanning };
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { apiService } from '@/services/api.service';
interface ServerReachability {
reachable: boolean;
latencyMs: number | null;
}
const POLL_MS = 10000;
export function useServerReachability(): ServerReachability {
const [state, setState] = useState<ServerReachability>({
reachable: false,
latencyMs: null,
});
useEffect(() => {
let active = true;
const check = async () => {
const started = Date.now();
try {
await apiService.getStatus();
if (!active) {
return;
}
setState({
reachable: true,
latencyMs: Date.now() - started,
});
} catch {
if (!active) {
return;
}
setState({
reachable: false,
latencyMs: null,
});
}
};
void check();
const timer = setInterval(check, POLL_MS);
return () => {
active = false;
clearInterval(timer);
};
}, []);
return state;
}

View File

@@ -0,0 +1,4 @@
import { useContext } from 'react';
import { ThemeContext, ThemeContextValue } from '../theme/ThemeContext';
export const useTheme = (): ThemeContextValue => useContext(ThemeContext);

View File

@@ -0,0 +1,162 @@
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>
);
};

View File

@@ -0,0 +1,5 @@
import { MainTabs } from './MainTabs';
export const RootNavigator = () => {
return <MainTabs />;
};

View File

@@ -0,0 +1,11 @@
export type RootStackParamList = {
MainTabs: undefined;
};
export type MainTabsParamList = {
Live: undefined;
Vitals: undefined;
Zones: undefined;
MAT: undefined;
Settings: undefined;
};

View File

@@ -0,0 +1,92 @@
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios';
import { API_POSE_FRAMES_PATH, API_POSE_STATUS_PATH, API_POSE_ZONES_PATH } from '@/constants/api';
import type { ApiError, HistoricalFrames, PoseStatus, ZoneConfig } from '@/types/api';
class ApiService {
private baseUrl = '';
private client: AxiosInstance;
constructor() {
this.client = axios.create({
timeout: 5000,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
setBaseUrl(url: string): void {
this.baseUrl = url ?? '';
}
private buildUrl(path: string): string {
if (!this.baseUrl) {
return path;
}
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const normalized = this.baseUrl.replace(/\/$/, '');
return `${normalized}${path.startsWith('/') ? path : `/${path}`}`;
}
private normalizeError(error: unknown): ApiError {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ message?: string }>;
const message =
axiosError.response?.data && typeof axiosError.response.data === 'object' && 'message' in axiosError.response.data
? String((axiosError.response.data as { message?: string }).message)
: axiosError.message || 'Request failed';
return {
message,
status: axiosError.response?.status,
code: axiosError.code,
details: axiosError.response?.data,
};
}
if (error instanceof Error) {
return { message: error.message };
}
return { message: 'Unknown error' };
}
private async requestWithRetry<T>(config: AxiosRequestConfig, retriesLeft: number): Promise<T> {
try {
const response = await this.client.request<T>({
...config,
url: this.buildUrl(config.url || ''),
});
return response.data;
} catch (error) {
if (retriesLeft > 0) {
return this.requestWithRetry<T>(config, retriesLeft - 1);
}
throw this.normalizeError(error);
}
}
get<T>(path: string): Promise<T> {
return this.requestWithRetry<T>({ method: 'GET', url: path }, 2);
}
post<T>(path: string, body: unknown): Promise<T> {
return this.requestWithRetry<T>({ method: 'POST', url: path, data: body }, 2);
}
getStatus(): Promise<PoseStatus> {
return this.get<PoseStatus>(API_POSE_STATUS_PATH);
}
getZones(): Promise<ZoneConfig[]> {
return this.get<ZoneConfig[]>(API_POSE_ZONES_PATH);
}
getFrames(limit: number): Promise<HistoricalFrames> {
return this.get<HistoricalFrames>(`${API_POSE_FRAMES_PATH}?limit=${encodeURIComponent(String(limit))}`);
}
}
export const apiService = new ApiService();

View File

@@ -0,0 +1,62 @@
import type { RssiService, WifiNetwork } from './rssi.service';
import WifiManager from '@react-native-wifi-reborn';
type NativeWifiNetwork = {
SSID?: string;
BSSID?: string;
level?: number;
levelDbm?: number;
};
class AndroidRssiService implements RssiService {
private timer: ReturnType<typeof setInterval> | null = null;
private listeners = new Set<(networks: WifiNetwork[]) => void>();
startScanning(intervalMs: number): void {
this.stopScanning();
this.scanOnce();
this.timer = setInterval(() => {
this.scanOnce();
}, intervalMs);
}
stopScanning(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private async scanOnce(): Promise<void> {
try {
const results = (await WifiManager.loadWifiList()) as NativeWifiNetwork[];
const mapped = results.map((item) => ({
ssid: item.SSID || '',
bssid: item.BSSID,
level: typeof item.level === 'number' ? item.level : typeof item.levelDbm === 'number' ? item.levelDbm : -100,
}));
this.broadcast(mapped.filter((n) => n.ssid.length > 0));
} catch {
this.broadcast([]);
}
}
private broadcast(networks: WifiNetwork[]): void {
this.listeners.forEach((listener) => {
try {
listener(networks);
} catch {
// listener safety
}
});
}
}
export const rssiService = new AndroidRssiService();

View File

@@ -0,0 +1,41 @@
import type { RssiService, WifiNetwork } from './rssi.service';
class IosRssiService implements RssiService {
private timer: ReturnType<typeof setInterval> | null = null;
private listeners = new Set<(networks: WifiNetwork[]) => void>();
startScanning(intervalMs: number): void {
console.warn('iOS RSSI scanning not available; returning synthetic network data.');
this.stopScanning();
this.timer = setInterval(() => {
this.broadcast([{ ssid: 'WiFi-DensePose', bssid: undefined, level: -60 }]);
}, intervalMs);
this.broadcast([{ ssid: 'WiFi-DensePose', bssid: undefined, level: -60 }]);
}
stopScanning(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private broadcast(networks: WifiNetwork[]): void {
this.listeners.forEach((listener) => {
try {
listener(networks);
} catch {
// listener safety
}
});
}
}
export const rssiService = new IosRssiService();

View File

@@ -0,0 +1,19 @@
import { Platform } from 'react-native';
import { rssiService as androidRssiService } from './rssi.service.android';
import { rssiService as iosRssiService } from './rssi.service.ios';
export interface WifiNetwork {
ssid: string;
bssid?: string;
level: number;
}
export interface RssiService {
startScanning(intervalMs: number): void;
stopScanning(): void;
subscribe(listener: (networks: WifiNetwork[]) => void): () => void;
}
export const rssiService: RssiService =
Platform.OS === 'android' ? androidRssiService : iosRssiService;

View File

@@ -0,0 +1,107 @@
import {
BREATHING_BAND_AMPLITUDE,
BREATHING_BAND_MIN,
BREATHING_BPM_MAX,
BREATHING_BPM_MIN,
HEART_BPM_MAX,
HEART_BPM_MIN,
MOTION_BAND_AMPLITUDE,
MOTION_BAND_MIN,
RSSI_AMPLITUDE_DBM,
RSSI_BASE_DBM,
SIMULATION_GRID_SIZE,
SIMULATION_TICK_INTERVAL_MS,
SIGNAL_FIELD_PRESENCE_LEVEL,
VARIANCE_AMPLITUDE,
VARIANCE_BASE,
} from '@/constants/simulation';
import type { SensingFrame } from '@/types/sensing';
function gaussian(x: number, y: number, cx: number, cy: number, sigma: number): number {
const dx = x - cx;
const dy = y - cy;
return Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));
}
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
export function generateSimulatedData(timeMs = Date.now()): SensingFrame {
const t = timeMs / 1000;
const baseRssi = RSSI_BASE_DBM + Math.sin(t * 0.5) * RSSI_AMPLITUDE_DBM;
const variance = VARIANCE_BASE + Math.sin(t * 0.1) * VARIANCE_AMPLITUDE;
const motionBand = MOTION_BAND_MIN + Math.abs(Math.sin(t * 0.3)) * MOTION_BAND_AMPLITUDE;
const breathingBand = BREATHING_BAND_MIN + Math.abs(Math.sin(t * 0.05)) * BREATHING_BAND_AMPLITUDE;
const isPresent = variance > SIGNAL_FIELD_PRESENCE_LEVEL;
const isActive = motionBand > 0.12;
const grid = SIMULATION_GRID_SIZE;
const cx = grid / 2;
const cy = grid / 2;
const bodyX = cx + 3 * Math.sin(t * 0.2);
const bodyY = cy + 2 * Math.cos(t * 0.15);
const breathX = cx + 4 * Math.sin(t * 0.04);
const breathY = cy + 4 * Math.cos(t * 0.04);
const values: number[] = [];
for (let z = 0; z < grid; z += 1) {
for (let x = 0; x < grid; x += 1) {
let value = Math.max(0, 1 - Math.sqrt((x - cx) ** 2 + (z - cy) ** 2) / (grid * 0.7)) * 0.3;
value += gaussian(x, z, bodyX, bodyY, 3.4) * (0.3 + motionBand * 3);
value += gaussian(x, z, breathX, breathY, 6) * (0.15 + breathingBand * 2);
if (!isPresent) {
value *= 0.7;
}
values.push(clamp(value, 0, 1));
}
}
const dominantFreqHz = 0.3 + Math.sin(t * 0.02) * 0.1;
const breathingBpm = BREATHING_BPM_MIN + ((Math.sin(t * 0.07) + 1) * 0.5) * (BREATHING_BPM_MAX - BREATHING_BPM_MIN);
const hrProxy = HEART_BPM_MIN + ((Math.sin(t * 0.09) + 1) * 0.5) * (HEART_BPM_MAX - HEART_BPM_MIN);
const confidence = 0.6 + Math.abs(Math.sin(t * 0.03)) * 0.4;
return {
type: 'sensing_update',
timestamp: timeMs,
source: 'simulated',
tick: Math.floor(t / (SIMULATION_TICK_INTERVAL_MS / 1000)),
nodes: [
{
node_id: 1,
rssi_dbm: baseRssi,
position: [2, 0, 1.5],
amplitude: [baseRssi],
subcarrier_count: 1,
},
],
features: {
mean_rssi: baseRssi,
variance,
motion_band_power: motionBand,
breathing_band_power: breathingBand,
spectral_entropy: 1 - clamp(Math.abs(dominantFreqHz - 0.3), 0, 1),
std: Math.sqrt(Math.abs(variance)),
dominant_freq_hz: dominantFreqHz,
change_points: Math.max(0, Math.floor(variance * 2)),
spectral_power: motionBand + breathingBand,
},
classification: {
motion_level: isActive ? 'active' : isPresent ? 'present_still' : 'absent',
presence: isPresent,
confidence: isPresent ? 0.75 + Math.abs(Math.sin(t * 0.03)) * 0.2 : 0.5 + Math.abs(Math.cos(t * 0.03)) * 0.3,
},
signal_field: {
grid_size: [grid, 1, grid],
values,
},
vital_signs: {
breathing_bpm: breathingBpm,
hr_proxy_bpm: hrProxy,
confidence,
},
};
}

View File

@@ -0,0 +1,164 @@
import { SIMULATION_TICK_INTERVAL_MS } from '@/constants/simulation';
import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH } from '@/constants/websocket';
import { usePoseStore } from '@/stores/poseStore';
import { generateSimulatedData } from '@/services/simulation.service';
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
type FrameListener = (frame: SensingFrame) => void;
class WsService {
private ws: WebSocket | null = null;
private listeners = new Set<FrameListener>();
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private simulationTimer: ReturnType<typeof setInterval> | null = null;
private targetUrl = '';
private active = false;
private status: ConnectionStatus = 'disconnected';
connect(url: string): void {
this.targetUrl = url;
this.active = true;
this.reconnectAttempt = 0;
if (!url) {
this.handleStatusChange('simulated');
this.startSimulation();
return;
}
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.handleStatusChange('connecting');
try {
const endpoint = this.buildWsUrl(url);
const socket = new WebSocket(endpoint);
this.ws = socket;
socket.onopen = () => {
this.reconnectAttempt = 0;
this.stopSimulation();
this.handleStatusChange('connected');
};
socket.onmessage = (evt) => {
try {
const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data);
const frame = JSON.parse(raw) as SensingFrame;
this.listeners.forEach((listener) => listener(frame));
} catch {
// ignore malformed frames
}
};
socket.onerror = () => {
// handled by onclose
};
socket.onclose = (evt) => {
this.ws = null;
if (!this.active) {
this.handleStatusChange('disconnected');
return;
}
if (evt.code === 1000) {
this.handleStatusChange('disconnected');
return;
}
this.scheduleReconnect();
};
} catch {
this.scheduleReconnect();
}
}
disconnect(): void {
this.active = false;
this.clearReconnectTimer();
this.stopSimulation();
if (this.ws) {
this.ws.close(1000, 'client disconnect');
this.ws = null;
}
this.handleStatusChange('disconnected');
}
subscribe(listener: FrameListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getStatus(): ConnectionStatus {
return this.status;
}
private buildWsUrl(rawUrl: string): string {
const parsed = new URL(rawUrl);
const proto = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:';
return `${proto}//${parsed.host}${WS_PATH}`;
}
private handleStatusChange(status: ConnectionStatus): void {
if (status === this.status) {
return;
}
this.status = status;
usePoseStore.getState().setConnectionStatus(status);
}
private scheduleReconnect(): void {
if (!this.active) {
this.handleStatusChange('disconnected');
return;
}
if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
this.handleStatusChange('simulated');
this.startSimulation();
return;
}
const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
this.reconnectAttempt += 1;
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect(this.targetUrl);
}, delay);
this.startSimulation();
}
private startSimulation(): void {
if (this.simulationTimer) {
return;
}
this.simulationTimer = setInterval(() => {
this.handleStatusChange('simulated');
const frame = generateSimulatedData();
this.listeners.forEach((listener) => {
listener(frame);
});
}, SIMULATION_TICK_INTERVAL_MS);
}
private stopSimulation(): void {
if (this.simulationTimer) {
clearInterval(this.simulationTimer);
this.simulationTimer = null;
}
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
export const wsService = new WsService();

View File

@@ -0,0 +1,74 @@
import { create } from 'zustand';
import type { Alert, DisasterEvent, ScanZone, Survivor } from '@/types/mat';
export interface MatState {
events: DisasterEvent[];
zones: ScanZone[];
survivors: Survivor[];
alerts: Alert[];
selectedEventId: string | null;
upsertEvent: (event: DisasterEvent) => void;
addZone: (zone: ScanZone) => void;
upsertSurvivor: (survivor: Survivor) => void;
addAlert: (alert: Alert) => void;
setSelectedEvent: (id: string | null) => void;
}
export const useMatStore = create<MatState>((set) => ({
events: [],
zones: [],
survivors: [],
alerts: [],
selectedEventId: null,
upsertEvent: (event) => {
set((state) => {
const index = state.events.findIndex((item) => item.event_id === event.event_id);
if (index === -1) {
return { events: [...state.events, event] };
}
const events = [...state.events];
events[index] = event;
return { events };
});
},
addZone: (zone) => {
set((state) => {
const index = state.zones.findIndex((item) => item.id === zone.id);
if (index === -1) {
return { zones: [...state.zones, zone] };
}
const zones = [...state.zones];
zones[index] = zone;
return { zones };
});
},
upsertSurvivor: (survivor) => {
set((state) => {
const index = state.survivors.findIndex((item) => item.id === survivor.id);
if (index === -1) {
return { survivors: [...state.survivors, survivor] };
}
const survivors = [...state.survivors];
survivors[index] = survivor;
return { survivors };
});
},
addAlert: (alert) => {
set((state) => {
if (state.alerts.some((item) => item.id === alert.id)) {
return {
alerts: state.alerts.map((item) => (item.id === alert.id ? alert : item)),
};
}
return { alerts: [...state.alerts, alert] };
});
},
setSelectedEvent: (id) => {
set({ selectedEventId: id });
},
}));

View File

@@ -0,0 +1,71 @@
import { create } from 'zustand';
import { RingBuffer } from '@/utils/ringBuffer';
import type { Classification, ConnectionStatus, FeatureSet, SensingFrame, SignalField } from '@/types/sensing';
export interface PoseState {
connectionStatus: ConnectionStatus;
isSimulated: boolean;
lastFrame: SensingFrame | null;
rssiHistory: number[];
features: FeatureSet | null;
classification: Classification | null;
signalField: SignalField | null;
messageCount: number;
uptimeStart: number | null;
handleFrame: (frame: SensingFrame) => void;
setConnectionStatus: (status: ConnectionStatus) => void;
reset: () => void;
}
const MAX_RSSI_HISTORY = 60;
const rssiHistory = new RingBuffer<number>(MAX_RSSI_HISTORY, (a, b) => a - b);
export const usePoseStore = create<PoseState>((set) => ({
connectionStatus: 'disconnected',
isSimulated: false,
lastFrame: null,
rssiHistory: [],
features: null,
classification: null,
signalField: null,
messageCount: 0,
uptimeStart: null,
handleFrame: (frame: SensingFrame) => {
if (typeof frame.features?.mean_rssi === 'number') {
rssiHistory.push(frame.features.mean_rssi);
}
set((state) => ({
lastFrame: frame,
features: frame.features,
classification: frame.classification,
signalField: frame.signal_field,
messageCount: state.messageCount + 1,
uptimeStart: state.uptimeStart ?? Date.now(),
rssiHistory: rssiHistory.toArray(),
}));
},
setConnectionStatus: (status: ConnectionStatus) => {
set({
connectionStatus: status,
isSimulated: status === 'simulated',
});
},
reset: () => {
rssiHistory.clear();
set({
connectionStatus: 'disconnected',
isSimulated: false,
lastFrame: null,
rssiHistory: [],
features: null,
classification: null,
signalField: null,
messageCount: 0,
uptimeStart: null,
});
},
}));

View File

@@ -0,0 +1,47 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
export type Theme = 'light' | 'dark' | 'system';
export interface SettingsState {
serverUrl: string;
rssiScanEnabled: boolean;
theme: Theme;
alertSoundEnabled: boolean;
setServerUrl: (url: string) => void;
setRssiScanEnabled: (value: boolean) => void;
setTheme: (theme: Theme) => void;
setAlertSoundEnabled: (value: boolean) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
serverUrl: 'http://192.168.1.100:8080',
rssiScanEnabled: false,
theme: 'system',
alertSoundEnabled: true,
setServerUrl: (url) => {
set({ serverUrl: url });
},
setRssiScanEnabled: (value) => {
set({ rssiScanEnabled: value });
},
setTheme: (theme) => {
set({ theme });
},
setAlertSoundEnabled: (value) => {
set({ alertSoundEnabled: value });
},
}),
{
name: 'wifi-densepose-settings',
storage: createJSONStorage(() => AsyncStorage),
},
),
);

View File

@@ -0,0 +1,74 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useColorScheme } from 'react-native';
import { colors } from './colors';
import { spacing } from './spacing';
import { typography } from './typography';
export type ThemeMode = 'light' | 'dark' | 'system';
export type ThemeContextValue = {
colors: typeof colors;
typography: typeof typography;
spacing: typeof spacing;
isDark: boolean;
};
const fallbackThemeValue: ThemeContextValue = {
colors,
typography,
spacing,
isDark: true,
};
export const ThemeContext = React.createContext<ThemeContextValue>(fallbackThemeValue);
const isValidThemeMode = (value: unknown): value is ThemeMode => {
return value === 'light' || value === 'dark' || value === 'system';
};
const readThemeFromSettings = async (): Promise<ThemeMode> => {
try {
const settingsStore = (await import('../stores/settingsStore')) as Record<string, unknown>;
const stateAccessors = [settingsStore.useSettingsStore, (settingsStore as { useStore?: unknown }).useStore].filter(
(candidate): candidate is { getState: () => { theme?: unknown } } =>
typeof candidate === 'function' &&
typeof (candidate as { getState?: unknown }).getState === 'function',
);
for (const accessor of stateAccessors) {
const state = accessor.getState?.() as { theme?: unknown } | undefined;
const candidateTheme = state?.theme;
if (isValidThemeMode(candidateTheme)) {
return candidateTheme;
}
}
} catch {
// No-op if store is unavailable during bootstrap.
}
return 'system';
};
export const ThemeProvider = ({ children }: PropsWithChildren<object>) => {
const [themeMode, setThemeMode] = useState<ThemeMode>('system');
const systemScheme = useColorScheme() ?? 'light';
useEffect(() => {
void readThemeFromSettings().then(setThemeMode);
}, []);
const isDark = themeMode === 'dark' || (themeMode === 'system' && systemScheme === 'dark');
return (
<ThemeContext.Provider
value={{
colors,
typography,
spacing,
isDark,
}}
>
{children}
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,22 @@
export const colors = {
bg: '#0A0E1A',
surface: '#111827',
surfaceAlt: '#1A2233',
accent: '#32B8C6',
accentDim: '#1A6B73',
danger: '#FF4757',
warn: '#FFA502',
success: '#2ED573',
textPrimary: '#E2E8F0',
textSecondary: '#94A3B8',
muted: '#475569',
border: '#1E293B',
connected: '#2ED573',
simulated: '#FFA502',
disconnected: '#FF4757',
signalLow: '#3B82F6',
signalMid: '#10B981',
signalHigh: '#EF4444',
};
export type ColorKey = keyof typeof colors;

View File

@@ -0,0 +1,4 @@
export * from './colors';
export * from './spacing';
export * from './typography';
export * from './ThemeContext';

View File

@@ -0,0 +1,10 @@
export const spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
xxxl: 32,
huge: 48,
};

View File

@@ -0,0 +1,26 @@
import { Platform } from 'react-native';
export const typography = {
displayXl: { fontSize: 48, fontWeight: '700', letterSpacing: -1 },
displayLg: { fontSize: 32, fontWeight: '700', letterSpacing: -0.5 },
displayMd: { fontSize: 24, fontWeight: '600' },
labelLg: {
fontSize: 16,
fontWeight: '600',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
labelMd: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 1,
textTransform: 'uppercase',
},
bodyLg: { fontSize: 16, fontWeight: '400' },
bodyMd: { fontSize: 14, fontWeight: '400' },
bodySm: { fontSize: 12, fontWeight: '400' },
mono: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
fontSize: 13,
},
};

View File

@@ -0,0 +1,39 @@
import type { SensingFrame } from './sensing';
export interface PoseStatus {
status?: string;
healthy?: boolean;
services?: Record<string, unknown>;
streaming?: {
active?: boolean;
active_connections?: number;
total_messages?: number;
uptime?: number;
[key: string]: unknown;
};
timestamp?: string;
[key: string]: unknown;
}
export interface ZoneConfig {
id: string;
name: string;
type: 'rectangle' | 'circle' | 'polygon';
status?: string;
scan_count?: number;
detection_count?: number;
bounds?: Record<string, unknown>;
}
export interface HistoricalFrames {
frames: SensingFrame[];
limit?: number;
total?: number;
}
export interface ApiError {
message: string;
status?: number;
code?: string;
details?: unknown;
}

View File

@@ -0,0 +1,93 @@
export enum DisasterType {
BuildingCollapse = 0,
Earthquake = 1,
Landslide = 2,
Avalanche = 3,
Flood = 4,
MineCollapse = 5,
Industrial = 6,
TunnelCollapse = 7,
Unknown = 8,
}
export enum TriageStatus {
Immediate = 0,
Delayed = 1,
Minor = 2,
Deceased = 3,
Unknown = 4,
}
export enum ZoneStatus {
Active = 0,
Paused = 1,
Complete = 2,
Inaccessible = 3,
}
export enum AlertPriority {
Critical = 0,
High = 1,
Medium = 2,
Low = 3,
}
export interface DisasterEvent {
event_id: string;
disaster_type: DisasterType;
latitude: number;
longitude: number;
description: string;
}
export interface RectangleZone {
id: string;
name: string;
zone_type: 'rectangle';
status: ZoneStatus;
scan_count: number;
detection_count: number;
bounds_json: string;
}
export interface CircleZone {
id: string;
name: string;
zone_type: 'circle';
status: ZoneStatus;
scan_count: number;
detection_count: number;
bounds_json: string;
}
export type ScanZone = RectangleZone | CircleZone;
export interface Survivor {
id: string;
zone_id: string;
x: number;
y: number;
depth: number;
triage_status: TriageStatus;
triage_color: string;
confidence: number;
breathing_rate: number;
heart_rate: number;
first_detected: string;
last_updated: string;
is_deteriorating: boolean;
}
export interface Alert {
id: string;
survivor_id: string;
priority: AlertPriority;
title: string;
message: string;
recommended_action: string;
triage_status: TriageStatus;
location_x: number;
location_y: number;
created_at: string;
priority_color: string;
}

View File

@@ -0,0 +1,17 @@
export type RootStackParamList = {
MainTabs: undefined;
};
export type MainTabsParamList = {
Live: undefined;
Vitals: undefined;
Zones: undefined;
MAT: undefined;
Settings: undefined;
};
export type LiveScreenParams = undefined;
export type VitalsScreenParams = undefined;
export type ZonesScreenParams = undefined;
export type MATScreenParams = undefined;
export type SettingsScreenParams = undefined;

View File

@@ -0,0 +1,14 @@
declare module '@react-native-wifi-reborn' {
interface NativeWifiNetwork {
SSID?: string;
BSSID?: string;
level?: number;
levelDbm?: number;
}
const WifiManager: {
loadWifiList: () => Promise<NativeWifiNetwork[]>;
};
export default WifiManager;
}

View File

@@ -0,0 +1,50 @@
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'simulated';
export interface SensingNode {
node_id: number;
rssi_dbm: number;
position: [number, number, number];
amplitude?: number[];
subcarrier_count?: number;
}
export interface FeatureSet {
mean_rssi: number;
variance: number;
motion_band_power: number;
breathing_band_power: number;
spectral_entropy: number;
std?: number;
dominant_freq_hz?: number;
change_points?: number;
spectral_power?: number;
}
export interface Classification {
motion_level: 'absent' | 'present_still' | 'active';
presence: boolean;
confidence: number;
}
export interface SignalField {
grid_size: [number, number, number];
values: number[];
}
export interface VitalsData {
breathing_bpm: number;
hr_proxy_bpm: number;
confidence: number;
}
export interface SensingFrame {
type?: string;
timestamp?: number;
source?: string;
tick?: number;
nodes: SensingNode[];
features: FeatureSet;
classification: Classification;
signal_field: SignalField;
vital_signs?: VitalsData;
}

View File

@@ -0,0 +1,21 @@
export function valueToColor(v: number): [number, number, number] {
const clamped = Math.max(0, Math.min(1, v));
let r: number;
let g: number;
let b: number;
if (clamped < 0.5) {
const t = clamped * 2;
r = 0;
g = t;
b = 1 - t;
} else {
const t = (clamped - 0.5) * 2;
r = t;
g = 1 - t;
b = 0;
}
return [r, g, b];
}

View File

@@ -0,0 +1,34 @@
export function formatRssi(v: number | null | undefined): string {
if (typeof v !== 'number' || !Number.isFinite(v)) {
return '-- dBm';
}
return `${Math.round(v)} dBm`;
}
export function formatBpm(v: number | null | undefined): string {
if (typeof v !== 'number' || !Number.isFinite(v)) {
return '--';
}
return `${Math.round(v)} BPM`;
}
export function formatConfidence(v: number | null | undefined): string {
if (typeof v !== 'number' || !Number.isFinite(v)) {
return '--';
}
const normalized = v > 1 ? v / 100 : v;
return `${Math.round(Math.max(0, Math.min(1, normalized)) * 100)}%`;
}
export function formatUptime(ms: number | null | undefined): string {
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
return '--:--:--';
}
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

View File

@@ -0,0 +1,48 @@
export class RingBuffer<T> {
private readonly capacity: number;
private readonly compare?: (a: T, b: T) => number;
private readonly values: T[] = [];
constructor(capacity: number, compare?: (a: T, b: T) => number) {
if (!Number.isFinite(capacity) || capacity <= 0) {
throw new Error('RingBuffer capacity must be greater than 0');
}
this.capacity = Math.floor(capacity);
this.compare = compare;
}
push(v: T): void {
this.values.push(v);
if (this.values.length > this.capacity) {
this.values.shift();
}
}
toArray(): T[] {
return [...this.values];
}
clear(): void {
this.values.length = 0;
}
get max(): T | null {
if (this.values.length === 0) {
return null;
}
if (!this.compare) {
throw new Error('Comparator required for max()');
}
return this.values.reduce((acc, value) => (this.compare!(value, acc) > 0 ? value : acc), this.values[0]);
}
get min(): T | null {
if (this.values.length === 0) {
return null;
}
if (!this.compare) {
throw new Error('Comparator required for min()');
}
return this.values.reduce((acc, value) => (this.compare!(value, acc) < 0 ? value : acc), this.values[0]);
}
}

View File

@@ -0,0 +1,25 @@
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']);
export interface UrlValidationResult {
valid: boolean;
error?: string;
}
export function validateServerUrl(url: string): UrlValidationResult {
if (typeof url !== 'string' || !url.trim()) {
return { valid: false, error: 'URL must be a non-empty string.' };
}
try {
const parsed = new URL(url);
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
return { valid: false, error: 'URL must use http, https, ws, or wss.' };
}
if (!parsed.host) {
return { valid: false, error: 'URL must include a host.' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid URL format.' };
}
}