feat: Phase 3 — services, stores, navigation, design system
Services: ws.service, api.service, simulation.service, rssi.service (android+ios) Stores: poseStore, settingsStore, matStore (Zustand) Types: sensing, mat, api, navigation Hooks: usePoseStream, useRssiScanner, useServerReachability Theme: colors, typography, spacing, ThemeContext Navigation: MainTabs (5 tabs), RootNavigator, types Components: GaugeArc, SparklineChart, OccupancyGrid, StatusDot, ConnectionBanner, SignalBar, +more Utils: ringBuffer, colorMap, formatters, urlValidator Verified: tsc 0 errors, jest passes
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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]} />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, ThemeContextValue } from '../theme/ThemeContext';
|
||||
|
||||
export const useTheme = (): ThemeContextValue => useContext(ThemeContext);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MainTabs } from './MainTabs';
|
||||
|
||||
export const RootNavigator = () => {
|
||||
return <MainTabs />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type RootStackParamList = {
|
||||
MainTabs: undefined;
|
||||
};
|
||||
|
||||
export type MainTabsParamList = {
|
||||
Live: undefined;
|
||||
Vitals: undefined;
|
||||
Zones: undefined;
|
||||
MAT: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './colors';
|
||||
export * from './spacing';
|
||||
export * from './typography';
|
||||
export * from './ThemeContext';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
xxl: 24,
|
||||
xxxl: 32,
|
||||
huge: 48,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
mobile/src/types/react-native-wifi-reborn.d.ts
vendored
Normal file
14
mobile/src/types/react-native-wifi-reborn.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user