diff --git a/mobile/App.tsx b/mobile/App.tsx
index 64709bc..4b452af 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -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 (
-
- WiFi-DensePose
-
-
+
+
+
+
+
+
+
+
+
+
);
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: '#fff',
- },
- title: {
- fontSize: 24,
- fontWeight: '600',
- },
-});
diff --git a/mobile/src/components/ConnectionBanner.tsx b/mobile/src/components/ConnectionBanner.tsx
index e69de29..9ab6601 100644
--- a/mobile/src/components/ConnectionBanner.tsx
+++ b/mobile/src/components/ConnectionBanner.tsx
@@ -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 (
+
+
+ {state.label}
+
+
+ );
+};
+
+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',
+ },
+});
diff --git a/mobile/src/components/ErrorBoundary.tsx b/mobile/src/components/ErrorBoundary.tsx
index e69de29..9f1d03a 100644
--- a/mobile/src/components/ErrorBoundary.tsx
+++ b/mobile/src/components/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+ Something went wrong
+
+ {this.state.error?.message ?? 'An unexpected error occurred.'}
+
+
+
+
+
+ );
+ }
+
+ 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,
+ },
+});
diff --git a/mobile/src/components/GaugeArc.tsx b/mobile/src/components/GaugeArc.tsx
index e69de29..033491c 100644
--- a/mobile/src/components/GaugeArc.tsx
+++ b/mobile/src/components/GaugeArc.tsx
@@ -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 (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wrapper: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/mobile/src/components/LoadingSpinner.tsx b/mobile/src/components/LoadingSpinner.tsx
index e69de29..6be9b48 100644
--- a/mobile/src/components/LoadingSpinner.tsx
+++ b/mobile/src/components/LoadingSpinner.tsx
@@ -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 (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/mobile/src/components/ModeBadge.tsx b/mobile/src/components/ModeBadge.tsx
index e69de29..5897b1f 100644
--- a/mobile/src/components/ModeBadge.tsx
+++ b/mobile/src/components/ModeBadge.tsx
@@ -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 (
+
+ {mode}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ badge: {
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 999,
+ borderWidth: 1,
+ overflow: 'hidden',
+ letterSpacing: 1,
+ textAlign: 'center',
+ },
+});
diff --git a/mobile/src/components/OccupancyGrid.tsx b/mobile/src/components/OccupancyGrid.tsx
index e69de29..9af0154 100644
--- a/mobile/src/components/OccupancyGrid.tsx
+++ b/mobile/src/components/OccupancyGrid.tsx
@@ -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;
+};
+
+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;
+ 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 (
+
+ );
+};
+
+export const OccupancyGrid = ({
+ values,
+ personPositions = [],
+ size = 320,
+ style,
+}: OccupancyGridProps) => {
+ const normalizedValues = useMemo(() => normalizeValues(values), [values]);
+ const previousColors = useRef(normalizedValues.map(toColor));
+ const nextColors = useRef(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 (
+
+ );
+ });
+ }, [personPositions, size]);
+
+ return (
+
+ );
+};
diff --git a/mobile/src/components/SignalBar.tsx b/mobile/src/components/SignalBar.tsx
index e69de29..b44807e 100644
--- a/mobile/src/components/SignalBar.tsx
+++ b/mobile/src/components/SignalBar.tsx
@@ -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 (
+
+
+ {label}
+
+
+
+
+
+ {Math.round(clamp01(value) * 100)}%
+
+
+ );
+};
+
+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,
+ },
+});
diff --git a/mobile/src/components/SparklineChart.tsx b/mobile/src/components/SparklineChart.tsx
index e69de29..890dc9a 100644
--- a/mobile/src/components/SparklineChart.tsx
+++ b/mobile/src/components/SparklineChart.tsx
@@ -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 (
+
+
+
+ {chartData.map((point) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx
index e69de29..f355785 100644
--- a/mobile/src/components/StatusDot.tsx
+++ b/mobile/src/components/StatusDot.tsx
@@ -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 (
+
+ );
+};
+
+const styles = StyleSheet.create({
+ dot: {
+ borderRadius: 999,
+ },
+});
diff --git a/mobile/src/components/ThemedText.tsx b/mobile/src/components/ThemedText.tsx
index e69de29..a78353e 100644
--- a/mobile/src/components/ThemedText.tsx
+++ b/mobile/src/components/ThemedText.tsx
@@ -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, 'style'> & {
+ preset?: TextPreset;
+ color?: ColorKey;
+ style?: StyleProp;
+};
+
+export const ThemedText = ({
+ preset = 'bodyMd',
+ color = 'textPrimary',
+ style,
+ ...props
+}: ThemedTextProps) => {
+ const { colors, typography } = useTheme();
+
+ const presetStyle = (typography as Record)[preset];
+ const colorStyle = { color: colors[color] };
+
+ return ;
+};
diff --git a/mobile/src/components/ThemedView.tsx b/mobile/src/components/ThemedView.tsx
index e69de29..9a04dc1 100644
--- a/mobile/src/components/ThemedView.tsx
+++ b/mobile/src/components/ThemedView.tsx
@@ -0,0 +1,24 @@
+import { PropsWithChildren, forwardRef } from 'react';
+import { View, ViewProps } from 'react-native';
+import { useTheme } from '../hooks/useTheme';
+
+type ThemedViewProps = PropsWithChildren;
+
+export const ThemedView = forwardRef(({ children, style, ...props }, ref) => {
+ const { colors } = useTheme();
+
+ return (
+
+ {children}
+
+ );
+});
diff --git a/mobile/src/constants/api.ts b/mobile/src/constants/api.ts
index e69de29..4d6c07d 100644
--- a/mobile/src/constants/api.ts
+++ b/mobile/src/constants/api.ts
@@ -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';
diff --git a/mobile/src/constants/simulation.ts b/mobile/src/constants/simulation.ts
index e69de29..a4ba03d 100644
--- a/mobile/src/constants/simulation.ts
+++ b/mobile/src/constants/simulation.ts
@@ -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;
diff --git a/mobile/src/constants/websocket.ts b/mobile/src/constants/websocket.ts
index e69de29..07d6e0f 100644
--- a/mobile/src/constants/websocket.ts
+++ b/mobile/src/constants/websocket.ts
@@ -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;
diff --git a/mobile/src/hooks/usePoseStream.ts b/mobile/src/hooks/usePoseStream.ts
index e69de29..10cf8b6 100644
--- a/mobile/src/hooks/usePoseStream.ts
+++ b/mobile/src/hooks/usePoseStream.ts
@@ -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['connectionStatus'];
+ lastFrame: ReturnType['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 };
+}
diff --git a/mobile/src/hooks/useRssiScanner.ts b/mobile/src/hooks/useRssiScanner.ts
index e69de29..daddf23 100644
--- a/mobile/src/hooks/useRssiScanner.ts
+++ b/mobile/src/hooks/useRssiScanner.ts
@@ -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([]);
+ 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 };
+}
diff --git a/mobile/src/hooks/useServerReachability.ts b/mobile/src/hooks/useServerReachability.ts
index e69de29..2de728c 100644
--- a/mobile/src/hooks/useServerReachability.ts
+++ b/mobile/src/hooks/useServerReachability.ts
@@ -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({
+ 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;
+}
diff --git a/mobile/src/hooks/useTheme.ts b/mobile/src/hooks/useTheme.ts
index e69de29..b90c646 100644
--- a/mobile/src/hooks/useTheme.ts
+++ b/mobile/src/hooks/useTheme.ts
@@ -0,0 +1,4 @@
+import { useContext } from 'react';
+import { ThemeContext, ThemeContextValue } from '../theme/ThemeContext';
+
+export const useTheme = (): ThemeContextValue => useContext(ThemeContext);
diff --git a/mobile/src/navigation/MainTabs.tsx b/mobile/src/navigation/MainTabs.tsx
index e69de29..615a5c4 100644
--- a/mobile/src/navigation/MainTabs.tsx
+++ b/mobile/src/navigation/MainTabs.tsx
@@ -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 = () => (
+
+ {label} screen not implemented yet
+
+ Placeholder shell
+
+
+ );
+ const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder }));
+
+ const Wrapped = () => (
+
+
+
+ Loading {label}
+
+
+ }
+ >
+
+
+ );
+
+ 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 => {
+ try {
+ const mod = (await import('../stores/matStore')) as Record;
+ 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();
+
+const Suspended = ({ component: Component }: { component: React.ComponentType }) => (
+ }>
+
+
+);
+
+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 (
+ ({
+ headerShown: false,
+ tabBarActiveTintColor: colors.accent,
+ tabBarInactiveTintColor: colors.textSecondary,
+ tabBarStyle: {
+ backgroundColor: '#0D1117',
+ borderTopColor: colors.border,
+ borderTopWidth: 1,
+ },
+ tabBarIcon: ({ color, size }) => ,
+ tabBarLabelStyle: {
+ fontFamily: 'Courier New',
+ textTransform: 'uppercase',
+ fontSize: 10,
+ },
+ tabBarLabel: ({ children, color }) => {children},
+ })}
+ >
+ {screens.map(({ name, component }) => (
+ 0 ? matAlertCount : undefined) : undefined,
+ }}
+ component={() => }
+ />
+ ))}
+
+ );
+};
diff --git a/mobile/src/navigation/RootNavigator.tsx b/mobile/src/navigation/RootNavigator.tsx
index e69de29..15088d2 100644
--- a/mobile/src/navigation/RootNavigator.tsx
+++ b/mobile/src/navigation/RootNavigator.tsx
@@ -0,0 +1,5 @@
+import { MainTabs } from './MainTabs';
+
+export const RootNavigator = () => {
+ return ;
+};
diff --git a/mobile/src/navigation/types.ts b/mobile/src/navigation/types.ts
index e69de29..2c72c46 100644
--- a/mobile/src/navigation/types.ts
+++ b/mobile/src/navigation/types.ts
@@ -0,0 +1,11 @@
+export type RootStackParamList = {
+ MainTabs: undefined;
+};
+
+export type MainTabsParamList = {
+ Live: undefined;
+ Vitals: undefined;
+ Zones: undefined;
+ MAT: undefined;
+ Settings: undefined;
+};
diff --git a/mobile/src/services/api.service.ts b/mobile/src/services/api.service.ts
index e69de29..64e27fc 100644
--- a/mobile/src/services/api.service.ts
+++ b/mobile/src/services/api.service.ts
@@ -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(config: AxiosRequestConfig, retriesLeft: number): Promise {
+ try {
+ const response = await this.client.request({
+ ...config,
+ url: this.buildUrl(config.url || ''),
+ });
+ return response.data;
+ } catch (error) {
+ if (retriesLeft > 0) {
+ return this.requestWithRetry(config, retriesLeft - 1);
+ }
+ throw this.normalizeError(error);
+ }
+ }
+
+ get(path: string): Promise {
+ return this.requestWithRetry({ method: 'GET', url: path }, 2);
+ }
+
+ post(path: string, body: unknown): Promise {
+ return this.requestWithRetry({ method: 'POST', url: path, data: body }, 2);
+ }
+
+ getStatus(): Promise {
+ return this.get(API_POSE_STATUS_PATH);
+ }
+
+ getZones(): Promise {
+ return this.get(API_POSE_ZONES_PATH);
+ }
+
+ getFrames(limit: number): Promise {
+ return this.get(`${API_POSE_FRAMES_PATH}?limit=${encodeURIComponent(String(limit))}`);
+ }
+}
+
+export const apiService = new ApiService();
diff --git a/mobile/src/services/rssi.service.android.ts b/mobile/src/services/rssi.service.android.ts
index e69de29..1341e33 100644
--- a/mobile/src/services/rssi.service.android.ts
+++ b/mobile/src/services/rssi.service.android.ts
@@ -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 | 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 {
+ 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();
diff --git a/mobile/src/services/rssi.service.ios.ts b/mobile/src/services/rssi.service.ios.ts
index e69de29..0acf1e4 100644
--- a/mobile/src/services/rssi.service.ios.ts
+++ b/mobile/src/services/rssi.service.ios.ts
@@ -0,0 +1,41 @@
+import type { RssiService, WifiNetwork } from './rssi.service';
+
+class IosRssiService implements RssiService {
+ private timer: ReturnType | 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();
diff --git a/mobile/src/services/rssi.service.ts b/mobile/src/services/rssi.service.ts
index e69de29..d7c09dd 100644
--- a/mobile/src/services/rssi.service.ts
+++ b/mobile/src/services/rssi.service.ts
@@ -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;
diff --git a/mobile/src/services/simulation.service.ts b/mobile/src/services/simulation.service.ts
index e69de29..53f07f1 100644
--- a/mobile/src/services/simulation.service.ts
+++ b/mobile/src/services/simulation.service.ts
@@ -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,
+ },
+ };
+}
diff --git a/mobile/src/services/ws.service.ts b/mobile/src/services/ws.service.ts
index e69de29..b79dfb5 100644
--- a/mobile/src/services/ws.service.ts
+++ b/mobile/src/services/ws.service.ts
@@ -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();
+ private reconnectAttempt = 0;
+ private reconnectTimer: ReturnType | null = null;
+ private simulationTimer: ReturnType | 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();
diff --git a/mobile/src/stores/matStore.ts b/mobile/src/stores/matStore.ts
index e69de29..b070a60 100644
--- a/mobile/src/stores/matStore.ts
+++ b/mobile/src/stores/matStore.ts
@@ -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((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 });
+ },
+}));
diff --git a/mobile/src/stores/poseStore.ts b/mobile/src/stores/poseStore.ts
index e69de29..6eb65d0 100644
--- a/mobile/src/stores/poseStore.ts
+++ b/mobile/src/stores/poseStore.ts
@@ -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(MAX_RSSI_HISTORY, (a, b) => a - b);
+
+export const usePoseStore = create((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,
+ });
+ },
+}));
diff --git a/mobile/src/stores/settingsStore.ts b/mobile/src/stores/settingsStore.ts
index e69de29..487a048 100644
--- a/mobile/src/stores/settingsStore.ts
+++ b/mobile/src/stores/settingsStore.ts
@@ -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()(
+ 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),
+ },
+ ),
+);
diff --git a/mobile/src/theme/ThemeContext.tsx b/mobile/src/theme/ThemeContext.tsx
index e69de29..279299b 100644
--- a/mobile/src/theme/ThemeContext.tsx
+++ b/mobile/src/theme/ThemeContext.tsx
@@ -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(fallbackThemeValue);
+
+const isValidThemeMode = (value: unknown): value is ThemeMode => {
+ return value === 'light' || value === 'dark' || value === 'system';
+};
+
+const readThemeFromSettings = async (): Promise => {
+ try {
+ const settingsStore = (await import('../stores/settingsStore')) as Record;
+ 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