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:
Yossi Elkrief
2026-03-02 12:53:45 +02:00
parent fbd7d837c7
commit 779bf8ff43
45 changed files with 2290 additions and 19 deletions

View File

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

View File

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

View File

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