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,84 @@
import { View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { AlertPriority, type Alert } from '@/types/mat';
type SeverityLevel = 'URGENT' | 'HIGH' | 'NORMAL';
type AlertCardProps = {
alert: Alert;
};
type SeverityMeta = {
label: SeverityLevel;
icon: string;
color: string;
};
const resolveSeverity = (alert: Alert): SeverityMeta => {
if (alert.priority === AlertPriority.Critical) {
return {
label: 'URGENT',
icon: '‼',
color: colors.danger,
};
}
if (alert.priority === AlertPriority.High) {
return {
label: 'HIGH',
icon: '⚠',
color: colors.warn,
};
}
return {
label: 'NORMAL',
icon: '•',
color: colors.accent,
};
};
const formatTime = (value?: string): string => {
if (!value) {
return 'Unknown';
}
try {
return new Date(value).toLocaleTimeString();
} catch {
return 'Unknown';
}
};
export const AlertCard = ({ alert }: AlertCardProps) => {
const severity = resolveSeverity(alert);
return (
<View
style={{
backgroundColor: '#111827',
borderWidth: 1,
borderColor: `${severity.color}55`,
padding: spacing.md,
borderRadius: 10,
marginBottom: spacing.sm,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ThemedText preset="labelMd" style={{ color: severity.color }}>
{severity.icon} {severity.label}
</ThemedText>
<View style={{ flex: 1 }}>
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
{formatTime(alert.created_at)}
</ThemedText>
</View>
</View>
<ThemedText preset="bodyMd" style={{ color: colors.textPrimary, marginTop: 6 }}>
{alert.message}
</ThemedText>
</View>
);
};
@@ -0,0 +1,41 @@
import { FlatList, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import type { Alert } from '@/types/mat';
import { AlertCard } from './AlertCard';
type AlertListProps = {
alerts: Alert[];
};
export const AlertList = ({ alerts }: AlertListProps) => {
if (alerts.length === 0) {
return (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
backgroundColor: '#111827',
}}
>
<ThemedText preset="bodyMd">No alerts system nominal</ThemedText>
</View>
);
}
return (
<FlatList
data={alerts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <AlertCard alert={item} />}
contentContainerStyle={{ paddingBottom: spacing.md }}
showsVerticalScrollIndicator={false}
removeClippedSubviews={false}
/>
);
};
@@ -0,0 +1,26 @@
import { StyleProp, ViewStyle } from 'react-native';
import WebView, { type WebViewMessageEvent } from 'react-native-webview';
import type { RefObject } from 'react';
import MAT_DASHBOARD_HTML from '@/assets/webview/mat-dashboard.html';
type MatWebViewProps = {
webViewRef: RefObject<WebView | null>;
onMessage: (event: WebViewMessageEvent) => void;
style?: StyleProp<ViewStyle>;
};
export const MatWebView = ({ webViewRef, onMessage, style }: MatWebViewProps) => {
return (
<WebView
ref={webViewRef}
originWhitelist={["*"]}
style={style}
source={{ html: MAT_DASHBOARD_HTML }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
mixedContentMode="always"
overScrollMode="never"
/>
);
};
@@ -0,0 +1,89 @@
import { View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { TriageStatus, type Survivor } from '@/types/mat';
type SurvivorCounterProps = {
survivors: Survivor[];
};
type Breakdown = {
immediate: number;
delayed: number;
minor: number;
deceased: number;
unknown: number;
};
const getBreakdown = (survivors: Survivor[]): Breakdown => {
const output = {
immediate: 0,
delayed: 0,
minor: 0,
deceased: 0,
unknown: 0,
};
survivors.forEach((survivor) => {
if (survivor.triage_status === TriageStatus.Immediate) {
output.immediate += 1;
return;
}
if (survivor.triage_status === TriageStatus.Delayed) {
output.delayed += 1;
return;
}
if (survivor.triage_status === TriageStatus.Minor) {
output.minor += 1;
return;
}
if (survivor.triage_status === TriageStatus.Deceased) {
output.deceased += 1;
return;
}
output.unknown += 1;
});
return output;
};
const BreakoutChip = ({ label, value, color }: { label: string; value: number; color: string }) => (
<View
style={{
backgroundColor: '#0D1117',
borderRadius: 999,
borderWidth: 1,
borderColor: `${color}55`,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
marginRight: spacing.sm,
marginTop: spacing.sm,
}}
>
<ThemedText preset="bodySm" style={{ color }}>
{label}: {value}
</ThemedText>
</View>
);
export const SurvivorCounter = ({ survivors }: SurvivorCounterProps) => {
const total = survivors.length;
const breakdown = getBreakdown(survivors);
return (
<View style={{ paddingBottom: spacing.md }}>
<ThemedText preset="displayLg" style={{ color: colors.textPrimary }}>
{total} SURVIVORS DETECTED
</ThemedText>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: spacing.sm }}>
<BreakoutChip label="Immediate" value={breakdown.immediate} color={colors.danger} />
<BreakoutChip label="Delayed" value={breakdown.delayed} color={colors.warn} />
<BreakoutChip label="Minimal" value={breakdown.minor} color={colors.success} />
<BreakoutChip label="Expectant" value={breakdown.deceased} color={colors.textSecondary} />
<BreakoutChip label="Unknown" value={breakdown.unknown} color="#a0aec0" />
</View>
</View>
);
};
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef } from 'react';
import { useWindowDimensions, View } from 'react-native';
import { ConnectionBanner } from '@/components/ConnectionBanner';
import { ThemedView } from '@/components/ThemedView';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { usePoseStream } from '@/hooks/usePoseStream';
import { useMatStore } from '@/stores/matStore';
import { type ConnectionStatus } from '@/types/sensing';
import { Alert, type Survivor } from '@/types/mat';
import { AlertList } from './AlertList';
import { MatWebView } from './MatWebView';
import { SurvivorCounter } from './SurvivorCounter';
import { useMatBridge } from './useMatBridge';
const isAlert = (value: unknown): value is Alert => {
if (!value || typeof value !== 'object') {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.id === 'string' && typeof record.message === 'string';
};
const isSurvivor = (value: unknown): value is Survivor => {
if (!value || typeof value !== 'object') {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.id === 'string' && typeof record.zone_id === 'string';
};
const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
if (status === 'connecting') {
return 'disconnected';
}
return status;
};
export const MATScreen = () => {
const { connectionStatus, lastFrame } = usePoseStream();
const survivors = useMatStore((state) => state.survivors);
const alerts = useMatStore((state) => state.alerts);
const upsertSurvivor = useMatStore((state) => state.upsertSurvivor);
const addAlert = useMatStore((state) => state.addAlert);
const upsertEvent = useMatStore((state) => state.upsertEvent);
const { webViewRef, ready, onMessage, sendFrameUpdate, postEvent } = useMatBridge({
onSurvivorDetected: (survivor) => {
if (isSurvivor(survivor)) {
upsertSurvivor(survivor);
}
},
onAlertGenerated: (alert) => {
if (isAlert(alert)) {
addAlert(alert);
}
},
});
const seededRef = useRef(false);
useEffect(() => {
if (!ready || seededRef.current) {
return;
}
const createEvent = postEvent('CREATE_EVENT');
createEvent({
type: 'earthquake',
latitude: 37.7749,
longitude: -122.4194,
name: 'Training Scenario',
});
const addZone = postEvent('ADD_ZONE');
addZone({
name: 'Zone A',
zone_type: 'rectangle',
x: 60,
y: 60,
width: 180,
height: 120,
});
addZone({
name: 'Zone B',
zone_type: 'circle',
center_x: 300,
center_y: 170,
radius: 60,
});
upsertEvent({
event_id: 'training-scenario',
disaster_type: 1,
latitude: 37.7749,
longitude: -122.4194,
description: 'Training Scenario',
});
seededRef.current = true;
}, [postEvent, upsertEvent, ready]);
useEffect(() => {
if (ready && lastFrame) {
sendFrameUpdate(lastFrame);
}
}, [lastFrame, ready, sendFrameUpdate]);
const { height } = useWindowDimensions();
const webHeight = Math.max(240, Math.floor(height * 0.5));
return (
<ThemedView style={{ flex: 1, backgroundColor: colors.bg, padding: spacing.md }}>
<ConnectionBanner status={resolveBannerState(connectionStatus)} />
<View style={{ marginTop: 20 }}>
<SurvivorCounter survivors={survivors} />
</View>
<View style={{ height: webHeight }}>
<MatWebView
webViewRef={webViewRef}
onMessage={onMessage}
style={{ flex: 1, borderRadius: 12, overflow: 'hidden', backgroundColor: colors.surface }}
/>
</View>
<View style={{ flex: 1, marginTop: spacing.md }}>
<AlertList alerts={alerts} />
</View>
</ThemedView>
);
};
export default MATScreen;
@@ -0,0 +1,118 @@
import { useCallback, useRef, useState } from 'react';
import type { WebView, WebViewMessageEvent } from 'react-native-webview';
import type { Alert, Survivor } from '@/types/mat';
import type { SensingFrame } from '@/types/sensing';
type MatBridgeMessageType = 'CREATE_EVENT' | 'ADD_ZONE' | 'FRAME_UPDATE';
type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'ALERT_GENERATED';
type MatIncomingMessage = {
type: MatIncomingType;
payload?: unknown;
};
type MatOutgoingMessage = {
type: MatBridgeMessageType;
payload?: unknown;
};
type UseMatBridgeOptions = {
onSurvivorDetected?: (survivor: Survivor) => void;
onAlertGenerated?: (alert: Alert) => void;
};
const safeParseJson = (value: string): unknown | null => {
try {
return JSON.parse(value);
} catch {
return null;
}
};
export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBridgeOptions = {}) => {
const webViewRef = useRef<WebView | null>(null);
const isReadyRef = useRef(false);
const queuedMessages = useRef<string[]>([]);
const [ready, setReady] = useState(false);
const flush = useCallback(() => {
if (!webViewRef.current || !isReadyRef.current) {
return;
}
while (queuedMessages.current.length > 0) {
const payload = queuedMessages.current.shift();
if (payload) {
webViewRef.current.postMessage(payload);
}
}
}, []);
const sendMessage = useCallback(
(message: MatOutgoingMessage) => {
const payload = JSON.stringify(message);
if (isReadyRef.current && webViewRef.current) {
webViewRef.current.postMessage(payload);
return;
}
queuedMessages.current.push(payload);
},
[],
);
const sendFrameUpdate = useCallback(
(frame: SensingFrame) => {
sendMessage({ type: 'FRAME_UPDATE', payload: frame });
},
[sendMessage],
);
const postEvent = useCallback(
(type: 'CREATE_EVENT' | 'ADD_ZONE') => {
return (payload: unknown) => {
sendMessage({
type,
payload,
});
};
},
[sendMessage],
);
const onMessage = useCallback(
(event: WebViewMessageEvent) => {
const payload = safeParseJson(event.nativeEvent.data);
if (!payload || typeof payload !== 'object') {
return;
}
const message = payload as MatIncomingMessage;
if (message.type === 'READY') {
isReadyRef.current = true;
setReady(true);
flush();
return;
}
if (message.type === 'SURVIVOR_DETECTED') {
onSurvivorDetected?.(message.payload as Survivor);
return;
}
if (message.type === 'ALERT_GENERATED') {
onAlertGenerated?.(message.payload as Alert);
}
},
[flush, onAlertGenerated, onSurvivorDetected],
);
return {
webViewRef,
ready,
onMessage,
sendMessage,
sendFrameUpdate,
postEvent,
};
};