feat: Implement RSSI service for iOS and Web platforms

- Added IosRssiService to handle synthetic RSSI data for iOS.
- Created WebRssiService to simulate RSSI scanning on the web.
- Defined shared types for WifiNetwork and RssiService in rssi.service.ts.
- Introduced simulation service to generate synthetic sensing data.
- Implemented WebSocket service for real-time data handling with reconnection logic.
- Established Zustand stores for managing application state related to MAT and pose data.
- Developed theme context and utility functions for consistent styling and formatting.
- Added type definitions for various application entities including API responses and sensing data.
- Created utility functions for color mapping and URL validation.
- Configured TypeScript settings for the mobile application.
This commit is contained in:
ruv
2026-03-02 10:30:33 -05:00
parent 02192b0232
commit fdc7142dfa
131 changed files with 24090 additions and 0 deletions
@@ -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,
},
});
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withSpring } from 'react-native-reanimated';
import Svg, { Circle, G, Text as SvgText } from 'react-native-svg';
type GaugeArcProps = {
value: number;
min?: number;
max: number;
label: string;
unit: string;
color: string;
colorTo?: string;
size?: number;
};
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
export const GaugeArc = ({ value, min = 0, max, label, unit, color, colorTo, 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 = useMemo(() => {
const span = max - min;
const safeSpan = span > 0 ? span : 1;
return clamp((value - min) / safeSpan, 0, 1);
}, [value, min, max]);
const displayValue = useMemo(() => {
if (!Number.isFinite(value)) {
return '--';
}
return `${Math.max(min, Math.min(max, value)).toFixed(1)} ${unit}`;
}, [max, min, unit, value]);
useEffect(() => {
progress.value = withSpring(normalized, {
damping: 16,
stiffness: 140,
mass: 1,
});
}, [normalized, progress]);
const animatedStroke = useAnimatedProps(() => {
const dashOffset = arcLength - arcLength * progress.value;
const strokeColor = colorTo ? interpolateColor(progress.value, [0, 1], [color, colorTo]) : color;
return {
strokeDashoffset: dashOffset,
stroke: strokeColor,
};
});
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 - 8}
fill="#E2E8F0"
fontSize={Math.round(size * 0.16)}
fontFamily="Courier New"
fontWeight="700"
textAnchor="middle"
>
{displayValue}
</SvgText>
<SvgText
x={size / 2}
y={size / 2 + 18}
fill="#94A3B8"
fontSize={Math.round(size * 0.085)}
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',
},
});
+71
View File
@@ -0,0 +1,71 @@
import { StyleSheet } from 'react-native';
import { ThemedText } from './ThemedText';
import { colors } from '../theme/colors';
type Mode = 'CSI' | 'RSSI' | 'SIM' | 'LIVE';
const modeStyle: Record<
Mode,
{
background: string;
border: string;
color: string;
}
> = {
CSI: {
background: 'rgba(50, 184, 198, 0.25)',
border: colors.accent,
color: colors.accent,
},
RSSI: {
background: 'rgba(255, 165, 2, 0.2)',
border: colors.warn,
color: colors.warn,
},
SIM: {
background: 'rgba(255, 71, 87, 0.18)',
border: colors.simulated,
color: colors.simulated,
},
LIVE: {
background: 'rgba(46, 213, 115, 0.18)',
border: colors.connected,
color: colors.connected,
},
};
type ModeBadgeProps = {
mode: Mode;
};
export const ModeBadge = ({ mode }: ModeBadgeProps) => {
const style = modeStyle[mode];
return (
<ThemedText
preset="labelMd"
style={[
styles.badge,
{
backgroundColor: style.background,
borderColor: style.border,
color: style.color,
},
]}
>
{mode}
</ThemedText>
);
};
const styles = StyleSheet.create({
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
overflow: 'hidden',
letterSpacing: 1,
textAlign: 'center',
},
});
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useRef } from 'react';
import { StyleProp, ViewStyle } from 'react-native';
import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withTiming, type SharedValue } from 'react-native-reanimated';
import Svg, { Circle, G, Rect } from 'react-native-svg';
import { colors } from '../theme/colors';
type Point = {
x: number;
y: number;
};
type OccupancyGridProps = {
values: number[];
personPositions?: Point[];
size?: number;
style?: StyleProp<ViewStyle>;
};
const GRID_DIMENSION = 20;
const CELLS = GRID_DIMENSION * GRID_DIMENSION;
const toColor = (value: number): string => {
const clamped = Math.max(0, Math.min(1, value));
let r: number;
let g: number;
let b: number;
if (clamped < 0.5) {
const t = clamped * 2;
r = Math.round(255 * 0);
g = Math.round(255 * t);
b = Math.round(255 * (1 - t));
} else {
const t = (clamped - 0.5) * 2;
r = Math.round(255 * t);
g = Math.round(255 * (1 - t));
b = 0;
}
return `rgb(${r}, ${g}, ${b})`;
};
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const normalizeValues = (values: number[]) => {
const normalized = new Array(CELLS).fill(0);
for (let i = 0; i < CELLS; i += 1) {
const value = values?.[i] ?? 0;
normalized[i] = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
return normalized;
};
type CellProps = {
index: number;
size: number;
progress: SharedValue<number>;
previousColors: string[];
nextColors: string[];
};
const Cell = ({ index, size, progress, previousColors, nextColors }: CellProps) => {
const col = index % GRID_DIMENSION;
const row = Math.floor(index / GRID_DIMENSION);
const cellSize = size / GRID_DIMENSION;
const x = col * cellSize;
const y = row * cellSize;
const animatedProps = useAnimatedProps(() => ({
fill: interpolateColor(
progress.value,
[0, 1],
[previousColors[index] ?? colors.surfaceAlt, nextColors[index] ?? colors.surfaceAlt],
),
}));
return (
<AnimatedRect
x={x}
y={y}
width={cellSize}
height={cellSize}
rx={1}
animatedProps={animatedProps}
/>
);
};
export const OccupancyGrid = ({
values,
personPositions = [],
size = 320,
style,
}: OccupancyGridProps) => {
const normalizedValues = useMemo(() => normalizeValues(values), [values]);
const previousColors = useRef<string[]>(normalizedValues.map(toColor));
const nextColors = useRef<string[]>(normalizedValues.map(toColor));
const progress = useSharedValue(1);
useEffect(() => {
const next = normalizeValues(values);
previousColors.current = normalizedValues.map(toColor);
nextColors.current = next.map(toColor);
progress.value = 0;
progress.value = withTiming(1, { duration: 500 });
}, [values, normalizedValues, progress]);
const markers = useMemo(() => {
const cellSize = size / GRID_DIMENSION;
return personPositions.map(({ x, y }, idx) => {
const clampedX = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(x)));
const clampedY = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(y)));
const cx = (clampedX + 0.5) * cellSize;
const cy = (clampedY + 0.5) * cellSize;
const markerRadius = Math.max(3, cellSize * 0.25);
return (
<Circle
key={`person-${idx}`}
cx={cx}
cy={cy}
r={markerRadius}
fill={colors.accent}
stroke={colors.textPrimary}
strokeWidth={1}
/>
);
});
}, [personPositions, size]);
return (
<Svg width={size} height={size} style={style} viewBox={`0 0 ${size} ${size}`}>
<G>
{Array.from({ length: CELLS }).map((_, index) => (
<Cell
key={index}
index={index}
size={size}
progress={progress}
previousColors={previousColors.current}
nextColors={nextColors.current}
/>
))}
</G>
{markers}
</Svg>
);
};
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { ThemedText } from './ThemedText';
import { colors } from '../theme/colors';
type SignalBarProps = {
value: number;
label: string;
color?: string;
};
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
export const SignalBar = ({ value, label, color = colors.accent }: SignalBarProps) => {
const progress = useSharedValue(clamp01(value));
useEffect(() => {
progress.value = withTiming(clamp01(value), { duration: 250 });
}, [value, progress]);
const animatedFill = useAnimatedStyle(() => ({
width: `${progress.value * 100}%`,
}));
return (
<View style={styles.container}>
<ThemedText preset="bodySm" style={styles.label}>
{label}
</ThemedText>
<View style={styles.track}>
<Animated.View style={[styles.fill, { backgroundColor: color }, animatedFill]} />
</View>
<ThemedText preset="bodySm" style={styles.percent}>
{Math.round(clamp01(value) * 100)}%
</ThemedText>
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 6,
},
label: {
marginBottom: 4,
},
track: {
height: 8,
borderRadius: 4,
backgroundColor: colors.surfaceAlt,
overflow: 'hidden',
},
fill: {
height: '100%',
borderRadius: 4,
},
percent: {
textAlign: 'right',
color: colors.textSecondary,
},
});
@@ -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>
);
};
+83
View File
@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { StyleSheet, ViewStyle } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { colors } from '../theme/colors';
type StatusType = 'connected' | 'simulated' | 'disconnected' | 'connecting';
type StatusDotProps = {
status: StatusType;
size?: number;
style?: ViewStyle;
};
const resolveColor = (status: StatusType): string => {
if (status === 'connecting') return colors.warn;
return colors[status];
};
export const StatusDot = ({ status, size = 10, style }: StatusDotProps) => {
const scale = useSharedValue(1);
const opacity = useSharedValue(1);
const isConnecting = status === 'connecting';
useEffect(() => {
if (isConnecting) {
scale.value = withRepeat(
withSequence(
withTiming(1.35, { duration: 800, easing: Easing.out(Easing.cubic) }),
withTiming(1, { duration: 800, easing: Easing.in(Easing.cubic) }),
),
-1,
);
opacity.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 800, easing: Easing.out(Easing.quad) }),
withTiming(1, { duration: 800, easing: Easing.in(Easing.quad) }),
),
-1,
);
return;
}
cancelAnimation(scale);
cancelAnimation(opacity);
scale.value = 1;
opacity.value = 1;
}, [isConnecting, opacity, scale]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Animated.View
style={[
styles.dot,
{
width: size,
height: size,
backgroundColor: resolveColor(status),
borderRadius: size / 2,
},
animatedStyle,
style,
]}
/>
);
};
const styles = StyleSheet.create({
dot: {
borderRadius: 999,
},
});
+28
View File
@@ -0,0 +1,28 @@
import { ComponentPropsWithoutRef } from 'react';
import { StyleProp, Text, TextStyle } from 'react-native';
import { useTheme } from '../hooks/useTheme';
import { colors } from '../theme/colors';
import { typography } from '../theme/typography';
type TextPreset = keyof typeof typography;
type ColorKey = keyof typeof colors;
type ThemedTextProps = Omit<ComponentPropsWithoutRef<typeof Text>, 'style'> & {
preset?: TextPreset;
color?: ColorKey;
style?: StyleProp<TextStyle>;
};
export const ThemedText = ({
preset = 'bodyMd',
color = 'textPrimary',
style,
...props
}: ThemedTextProps) => {
const { colors, typography } = useTheme();
const presetStyle = (typography as Record<TextPreset, TextStyle>)[preset];
const colorStyle = { color: colors[color] };
return <Text {...props} style={[presetStyle, colorStyle, style]} />;
};
+24
View File
@@ -0,0 +1,24 @@
import { PropsWithChildren, forwardRef } from 'react';
import { View, ViewProps } from 'react-native';
import { useTheme } from '../hooks/useTheme';
type ThemedViewProps = PropsWithChildren<ViewProps>;
export const ThemedView = forwardRef<View, ThemedViewProps>(({ children, style, ...props }, ref) => {
const { colors } = useTheme();
return (
<View
ref={ref}
{...props}
style={[
{
backgroundColor: colors.bg,
},
style,
]}
>
{children}
</View>
);
});