feat(mobile): replace starter template with dashboard-driven app flow
This commit is contained in:
106
MobileApp/src/api.ts
Normal file
106
MobileApp/src/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { API_BASE_URL } from '@/src/config';
|
||||
|
||||
type TokenGetter = () => string | null;
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
skipAuth?: boolean;
|
||||
};
|
||||
|
||||
const normalizePath = (path: string): string => {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
};
|
||||
|
||||
export const createApi = (getDeviceToken: TokenGetter) => {
|
||||
const request = async <T = any>(path: string, options: RequestOptions = {}): Promise<T> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
if (!options.skipAuth) {
|
||||
const token = getDeviceToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(normalizePath(path), {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(data as { message?: string; error?: string }).message ||
|
||||
(data as { message?: string; error?: string }).error ||
|
||||
response.statusText ||
|
||||
'Request failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
};
|
||||
|
||||
return {
|
||||
request,
|
||||
auth: {
|
||||
signUp: (data: { email: string; password: string; name: string }) =>
|
||||
request('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(data), skipAuth: true }),
|
||||
signIn: (data: { email: string; password: string }) =>
|
||||
request('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify(data), skipAuth: true }),
|
||||
getSession: () => request('/api/auth/get-session', { skipAuth: true }),
|
||||
signOut: () => request('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}), skipAuth: true }),
|
||||
},
|
||||
devices: {
|
||||
register: (data: Record<string, unknown>) =>
|
||||
request<{ device: any; deviceToken: string }>('/devices/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
list: () => request<{ devices: any[] }>('/devices'),
|
||||
update: (deviceId: string, data: Record<string, unknown>) =>
|
||||
request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
listLinks: () => request<{ links: any[] }>('/device-links'),
|
||||
link: (cameraDeviceId: string, clientDeviceId: string) =>
|
||||
request('/device-links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cameraDeviceId, clientDeviceId }),
|
||||
}),
|
||||
unlink: (linkId: string) => request(`/device-links/${linkId}`, { method: 'DELETE' }),
|
||||
},
|
||||
streams: {
|
||||
request: (cameraDeviceId: string) =>
|
||||
request('/streams/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }),
|
||||
}),
|
||||
accept: (id: string) => request(`/streams/${id}/accept`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
end: (id: string) =>
|
||||
request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }),
|
||||
getPublishCreds: (id: string) => request(`/streams/${id}/publish-credentials`),
|
||||
getSubscribeCreds: (id: string) => request(`/streams/${id}/subscribe-credentials`),
|
||||
},
|
||||
events: {
|
||||
startMotion: () =>
|
||||
request<{ event: { id: string } }>('/events/motion/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }),
|
||||
}),
|
||||
endMotion: (id: string) =>
|
||||
request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }),
|
||||
finalizeRecording: (id: string, payload: Record<string, unknown>) =>
|
||||
request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
},
|
||||
ops: {
|
||||
listRecordings: () => request<{ recordings: any[] }>('/recordings/me/list'),
|
||||
getRecordingDownloadUrl: (recordingId: string) => request<{ downloadUrl: string }>(`/recordings/${recordingId}/download-url`),
|
||||
listNotifications: () => request('/push-notifications/me'),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiClient = ReturnType<typeof createApi>;
|
||||
847
MobileApp/src/app-context.tsx
Normal file
847
MobileApp/src/app-context.tsx
Normal file
@@ -0,0 +1,847 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import type { CameraView } from 'expo-camera';
|
||||
|
||||
import { createApi } from '@/src/api';
|
||||
import { API_BASE_URL } from '@/src/config';
|
||||
import {
|
||||
createInitialState,
|
||||
type AppPage,
|
||||
type AppState,
|
||||
type LinkedCamera,
|
||||
type MotionNotification,
|
||||
unreadNotificationsCount,
|
||||
} from '@/src/state';
|
||||
|
||||
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
|
||||
|
||||
type AppContextValue = {
|
||||
state: AppState;
|
||||
ready: boolean;
|
||||
unreadCount: number;
|
||||
actions: AppActions;
|
||||
};
|
||||
|
||||
type AppActions = {
|
||||
setPage: (page: AppPage) => void;
|
||||
setAuthField: (field: 'email' | 'password' | 'name', value: string) => void;
|
||||
toggleAuthMode: () => void;
|
||||
submitAuth: () => Promise<void>;
|
||||
setOnboardingField: (field: 'name' | 'pushToken', value: string) => void;
|
||||
selectRole: (role: 'camera' | 'client') => void;
|
||||
registerDevice: () => Promise<void>;
|
||||
loadSavedDevice: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
startMotion: () => Promise<void>;
|
||||
endMotion: () => Promise<void>;
|
||||
goOnline: () => Promise<void>;
|
||||
refreshCameraInputs: () => Promise<void>;
|
||||
selectCameraInput: (_cameraInputId: string) => Promise<void>;
|
||||
linkCamera: (cameraDeviceId: string) => Promise<void>;
|
||||
renameLinkedCamera: (cameraDeviceId: string, nextName: string) => Promise<void>;
|
||||
deleteLinkedCamera: (linkId: string) => Promise<void>;
|
||||
requestStream: (cameraDeviceId: string) => Promise<void>;
|
||||
selectCamera: (cameraDeviceId: string) => Promise<void>;
|
||||
closeStreamViewer: () => void;
|
||||
openRecording: (recordingId: string) => Promise<void>;
|
||||
openMotionNotificationTarget: (notificationId: string, cameraDeviceId: string) => Promise<void>;
|
||||
markAllNotificationsRead: () => void;
|
||||
clearNotifications: () => void;
|
||||
refreshClientData: () => Promise<void>;
|
||||
runDiagnostics: () => void;
|
||||
removeToast: (id: string) => void;
|
||||
setCameraPermissionGranted: (granted: boolean) => void;
|
||||
setCameraPreviewReady: (ready: boolean) => void;
|
||||
setCameraRef: (ref: CameraView | null) => void;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
|
||||
const makeId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const getCameraLabel = (cameraDeviceId: string, cameraName?: string | null): string => {
|
||||
const explicitName = cameraName?.trim();
|
||||
if (explicitName) return explicitName;
|
||||
return `Camera ${cameraDeviceId.slice(0, 6)}`;
|
||||
};
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<AppState>(createInitialState());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const stateRef = useRef(state);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const requestedStreamsRef = useRef<Set<string>>(new Set());
|
||||
const lastMotionEventIdRef = useRef<string | null>(null);
|
||||
const activeRecordingStreamSessionIdRef = useRef<string | null>(null);
|
||||
const frameRelayTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const frameRelayBusyRef = useRef(false);
|
||||
const cameraRef = useRef<CameraView | null>(null);
|
||||
const initDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const api = useMemo(() => createApi(() => stateRef.current.deviceToken), []);
|
||||
|
||||
const setAppState = (partial: Partial<AppState>) => {
|
||||
setState((prev) => ({ ...prev, ...partial }));
|
||||
};
|
||||
|
||||
const patchAppState = (updater: (prev: AppState) => Partial<AppState>) => {
|
||||
setState((prev) => ({ ...prev, ...updater(prev) }));
|
||||
};
|
||||
|
||||
const setClientStreamMode = (mode: AppState['clientStreamMode']) => {
|
||||
let text = 'Select a camera to view';
|
||||
if (mode === 'connecting') text = 'Connecting stream...';
|
||||
if (mode === 'unavailable') text = 'Stream unavailable';
|
||||
|
||||
patchAppState((prev) => ({
|
||||
clientStreamMode: mode,
|
||||
clientPlaceholderText: text,
|
||||
clientFallbackFrame: mode === 'image' ? prev.clientFallbackFrame : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const pushToast = (message: string, type: 'info' | 'success' | 'error' = 'info') => {
|
||||
const id = makeId();
|
||||
patchAppState((prev) => ({
|
||||
toasts: [...prev.toasts, { id, message, type }].slice(-6),
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, toasts: prev.toasts.filter((toast) => toast.id !== id) }));
|
||||
}, 3200);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
patchAppState((prev) => ({
|
||||
toasts: prev.toasts.filter((toast) => toast.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
const addActivity = (type: string, message: string) => {
|
||||
const item = {
|
||||
id: makeId(),
|
||||
type,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
patchAppState((prev) => ({
|
||||
activityLog: [item, ...prev.activityLog].slice(0, 200),
|
||||
}));
|
||||
};
|
||||
|
||||
const markMotionNotificationRead = (notificationId: string) => {
|
||||
patchAppState((prev) => ({
|
||||
motionNotifications: prev.motionNotifications.map((notification) =>
|
||||
notification.id === notificationId ? { ...notification, isRead: true } : notification,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const markAllNotificationsRead = () => {
|
||||
patchAppState((prev) => ({
|
||||
motionNotifications: prev.motionNotifications.map((notification) =>
|
||||
notification.isRead ? notification : { ...notification, isRead: true },
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearClientStream = () => {
|
||||
setClientStreamMode('none');
|
||||
};
|
||||
|
||||
const stopFrameRelay = () => {
|
||||
if (frameRelayTimerRef.current) {
|
||||
clearInterval(frameRelayTimerRef.current);
|
||||
frameRelayTimerRef.current = null;
|
||||
}
|
||||
frameRelayBusyRef.current = false;
|
||||
};
|
||||
|
||||
const captureAndRelayFrame = async (streamSessionId: string, toDeviceId: string) => {
|
||||
if (frameRelayBusyRef.current) return;
|
||||
if (!socketRef.current || !cameraRef.current) return;
|
||||
|
||||
try {
|
||||
frameRelayBusyRef.current = true;
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
base64: true,
|
||||
quality: 0.45,
|
||||
skipProcessing: true,
|
||||
});
|
||||
|
||||
if (!photo?.base64) return;
|
||||
|
||||
socketRef.current.emit('stream:frame', {
|
||||
toDeviceId,
|
||||
streamSessionId,
|
||||
frame: `data:image/jpeg;base64,${photo.base64}`,
|
||||
capturedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// ignore transient camera capture errors
|
||||
} finally {
|
||||
frameRelayBusyRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startFrameRelay = (streamSessionId: string, toDeviceId: string) => {
|
||||
if (!streamSessionId || !toDeviceId) return;
|
||||
stopFrameRelay();
|
||||
|
||||
frameRelayTimerRef.current = setInterval(() => {
|
||||
void captureAndRelayFrame(streamSessionId, toDeviceId);
|
||||
}, 700);
|
||||
};
|
||||
|
||||
const pollClientData = async () => {
|
||||
const current = stateRef.current;
|
||||
if (!current.device || current.device.role !== 'client') return;
|
||||
|
||||
const [recs, links, deviceList] = await Promise.all([
|
||||
api.ops.listRecordings().catch(() => ({ recordings: [] })),
|
||||
api.devices.listLinks().catch(() => ({ links: [] })),
|
||||
api.devices.list().catch(() => ({ devices: [] })),
|
||||
]);
|
||||
|
||||
const cameraById = new Map(
|
||||
(deviceList.devices || [])
|
||||
.filter((entry) => entry.role === 'camera')
|
||||
.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
|
||||
const linkedCameras: LinkedCamera[] = (links.links || []).map((link) => {
|
||||
const camera = cameraById.get(link.cameraDeviceId);
|
||||
return {
|
||||
...link,
|
||||
cameraName: camera?.name ?? null,
|
||||
cameraStatus: camera?.status ?? 'offline',
|
||||
};
|
||||
});
|
||||
|
||||
setAppState({
|
||||
recordings: recs.recordings || [],
|
||||
linkedCameras,
|
||||
});
|
||||
|
||||
for (const link of linkedCameras) {
|
||||
if (!requestedStreamsRef.current.has(link.cameraDeviceId)) {
|
||||
requestedStreamsRef.current.add(link.cameraDeviceId);
|
||||
await api.streams.request(link.cameraDeviceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
void pollClientData();
|
||||
pollTimerRef.current = setInterval(() => {
|
||||
void pollClientData();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const disconnectSocket = () => {
|
||||
stopFrameRelay();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
setAppState({ socketConnected: false, connectedStreamSessionIds: [] });
|
||||
};
|
||||
|
||||
const connectSocket = () => {
|
||||
const token = stateRef.current.deviceToken;
|
||||
if (!token) return;
|
||||
|
||||
disconnectSocket();
|
||||
|
||||
const socket = io(API_BASE_URL, {
|
||||
auth: { token },
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setAppState({ socketConnected: true });
|
||||
addActivity('System', 'Connected to realtime server');
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
stopFrameRelay();
|
||||
activeRecordingStreamSessionIdRef.current = null;
|
||||
setAppState({ socketConnected: false, activeStreamSessionId: null, cameraStatus: 'idle' });
|
||||
clearClientStream();
|
||||
addActivity('System', 'Realtime disconnected');
|
||||
});
|
||||
|
||||
socket.on('command:received', async (payload) => {
|
||||
addActivity('Command', `Received ${payload.commandType}`);
|
||||
|
||||
if (payload.commandType !== 'start_stream') {
|
||||
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamId = payload?.payload?.streamSessionId;
|
||||
const sourceDeviceId = payload?.sourceDeviceId;
|
||||
if (streamId) {
|
||||
await api.streams.accept(streamId);
|
||||
await api.streams.getPublishCreds(streamId).catch(() => undefined);
|
||||
activeRecordingStreamSessionIdRef.current = streamId;
|
||||
setAppState({ cameraStatus: 'recording' });
|
||||
if (sourceDeviceId) {
|
||||
startFrameRelay(streamId, sourceDeviceId);
|
||||
}
|
||||
}
|
||||
addActivity('Stream', 'Accepted stream command and started camera frame relay');
|
||||
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Command rejected';
|
||||
socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('motion:detected', (payload) => {
|
||||
const cameraDeviceId = payload.cameraDeviceId || payload.deviceId;
|
||||
if (!cameraDeviceId) return;
|
||||
|
||||
const notification: MotionNotification = {
|
||||
id: makeId(),
|
||||
cameraDeviceId,
|
||||
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
|
||||
createdAt: new Date().toISOString(),
|
||||
isRead: false,
|
||||
};
|
||||
|
||||
patchAppState((prev) => ({
|
||||
motionNotifications: [notification, ...prev.motionNotifications].slice(0, 50),
|
||||
activeCameraDeviceId: cameraDeviceId,
|
||||
}));
|
||||
|
||||
void api.streams.request(cameraDeviceId).catch(() => undefined);
|
||||
pushToast('Motion detected', 'info');
|
||||
addActivity('Motion', notification.message);
|
||||
});
|
||||
|
||||
socket.on('stream:started', async (payload) => {
|
||||
const current = stateRef.current;
|
||||
const cameraSessions = { ...current.cameraSessions, [payload.cameraDeviceId]: payload.streamSessionId };
|
||||
|
||||
setAppState({ cameraSessions });
|
||||
addActivity('Stream', 'Stream is live, connecting...');
|
||||
|
||||
if (payload.cameraDeviceId === current.activeCameraDeviceId) {
|
||||
setAppState({ activeStreamSessionId: payload.streamSessionId });
|
||||
setClientStreamMode('connecting');
|
||||
}
|
||||
|
||||
await api.streams.getSubscribeCreds(payload.streamSessionId).catch(() => undefined);
|
||||
});
|
||||
|
||||
socket.on('stream:frame', (payload) => {
|
||||
if (!payload?.frame || payload.streamSessionId !== stateRef.current.activeStreamSessionId) return;
|
||||
patchAppState((prev) => ({
|
||||
clientFallbackFrame: payload.frame,
|
||||
connectedStreamSessionIds: prev.connectedStreamSessionIds.includes(payload.streamSessionId)
|
||||
? prev.connectedStreamSessionIds
|
||||
: [...prev.connectedStreamSessionIds, payload.streamSessionId],
|
||||
}));
|
||||
setClientStreamMode('image');
|
||||
});
|
||||
|
||||
socket.on('stream:ended', (payload) => {
|
||||
if (!payload?.streamSessionId) return;
|
||||
if (activeRecordingStreamSessionIdRef.current === payload.streamSessionId) {
|
||||
stopFrameRelay();
|
||||
activeRecordingStreamSessionIdRef.current = null;
|
||||
setAppState({ cameraStatus: 'idle' });
|
||||
}
|
||||
if (payload.streamSessionId === stateRef.current.activeStreamSessionId) {
|
||||
setAppState({ activeStreamSessionId: null });
|
||||
clearClientStream();
|
||||
}
|
||||
patchAppState((prev) => ({
|
||||
connectedStreamSessionIds: prev.connectedStreamSessionIds.filter((id) => id !== payload.streamSessionId),
|
||||
}));
|
||||
addActivity('Stream', 'Remote stream ended');
|
||||
});
|
||||
|
||||
socket.on('error:webrtc_signal', (payload) => {
|
||||
const message = payload?.message || 'WebRTC signaling error';
|
||||
addActivity('WebRTC', message);
|
||||
pushToast(message, 'error');
|
||||
});
|
||||
};
|
||||
|
||||
const cleanupConnectionState = async () => {
|
||||
stopPolling();
|
||||
stopFrameRelay();
|
||||
activeRecordingStreamSessionIdRef.current = null;
|
||||
disconnectSocket();
|
||||
requestedStreamsRef.current.clear();
|
||||
setAppState({
|
||||
activeCameraDeviceId: null,
|
||||
activeStreamSessionId: null,
|
||||
cameraSessions: {},
|
||||
connectedStreamSessionIds: [],
|
||||
cameraStatus: 'idle',
|
||||
clientFallbackFrame: '',
|
||||
clientStreamMode: 'none',
|
||||
clientPlaceholderText: 'Select a camera to view',
|
||||
});
|
||||
};
|
||||
|
||||
// This initialization intentionally runs once; lifecycle functions use refs for latest state.
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (initDoneRef.current) return;
|
||||
initDoneRef.current = true;
|
||||
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(DEVICE_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as { device?: AppState['device']; deviceToken?: string };
|
||||
setAppState({
|
||||
device: parsed.device ?? null,
|
||||
deviceToken: parsed.deviceToken ?? null,
|
||||
onboardingForm: {
|
||||
...stateRef.current.onboardingForm,
|
||||
name: parsed.device?.name ?? '',
|
||||
role: parsed.device?.role ?? 'client',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid saved payload
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await api.auth.getSession();
|
||||
if ((session as { session?: unknown })?.session) {
|
||||
setAppState({ session: session as AppState['session'] });
|
||||
|
||||
if (stateRef.current.deviceToken) {
|
||||
connectSocket();
|
||||
startPolling();
|
||||
}
|
||||
} else {
|
||||
setAppState({ session: null });
|
||||
}
|
||||
} catch {
|
||||
setAppState({ session: null });
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
void cleanupConnectionState();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const actions: AppActions = {
|
||||
setPage(page) {
|
||||
setAppState({ page });
|
||||
if (page === 'activity') {
|
||||
markAllNotificationsRead();
|
||||
}
|
||||
if (page === 'client') {
|
||||
void pollClientData();
|
||||
}
|
||||
},
|
||||
|
||||
setAuthField(field, value) {
|
||||
patchAppState((prev) => ({
|
||||
authForm: { ...prev.authForm, [field]: value },
|
||||
}));
|
||||
},
|
||||
|
||||
toggleAuthMode() {
|
||||
patchAppState((prev) => ({ isRegistering: !prev.isRegistering }));
|
||||
},
|
||||
|
||||
async submitAuth() {
|
||||
const current = stateRef.current;
|
||||
const { email, password, name } = current.authForm;
|
||||
const normalizedName = name || email.split('@')[0] || 'User';
|
||||
|
||||
if (!email.trim() || !password.trim()) {
|
||||
pushToast('Email and password are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (current.isRegistering) {
|
||||
await api.auth.signUp({ email: email.trim(), password, name: normalizedName });
|
||||
}
|
||||
|
||||
await api.auth.signIn({ email: email.trim(), password });
|
||||
const session = await api.auth.getSession();
|
||||
setAppState({
|
||||
session: session as AppState['session'],
|
||||
authForm: { ...current.authForm, password: '' },
|
||||
});
|
||||
pushToast('Signed in successfully', 'success');
|
||||
|
||||
if (stateRef.current.deviceToken) {
|
||||
connectSocket();
|
||||
startPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
setOnboardingField(field, value) {
|
||||
patchAppState((prev) => ({
|
||||
onboardingForm: { ...prev.onboardingForm, [field]: value },
|
||||
}));
|
||||
},
|
||||
|
||||
selectRole(role) {
|
||||
patchAppState((prev) => ({
|
||||
onboardingForm: { ...prev.onboardingForm, role },
|
||||
}));
|
||||
},
|
||||
|
||||
async registerDevice() {
|
||||
const { onboardingForm } = stateRef.current;
|
||||
const name = onboardingForm.name.trim() || 'Mobile Dashboard';
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
role: onboardingForm.role,
|
||||
platform: 'mobile',
|
||||
appVersion: 'mobileapp-1.0',
|
||||
};
|
||||
|
||||
if (onboardingForm.pushToken.trim()) {
|
||||
payload.pushToken = onboardingForm.pushToken.trim();
|
||||
}
|
||||
|
||||
const result = await api.devices.register(payload);
|
||||
|
||||
setAppState({
|
||||
device: result.device,
|
||||
deviceToken: result.deviceToken,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(
|
||||
DEVICE_STORAGE_KEY,
|
||||
JSON.stringify({ device: result.device, deviceToken: result.deviceToken }),
|
||||
);
|
||||
|
||||
connectSocket();
|
||||
startPolling();
|
||||
pushToast('Device registered', 'success');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Device registration failed';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async loadSavedDevice() {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(DEVICE_STORAGE_KEY);
|
||||
if (!saved) {
|
||||
pushToast('No saved device found', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(saved) as { device?: AppState['device']; deviceToken?: string };
|
||||
setAppState({
|
||||
device: parsed.device ?? null,
|
||||
deviceToken: parsed.deviceToken ?? null,
|
||||
onboardingForm: {
|
||||
...stateRef.current.onboardingForm,
|
||||
name: parsed.device?.name ?? '',
|
||||
role: parsed.device?.role ?? 'client',
|
||||
},
|
||||
});
|
||||
|
||||
if (parsed.deviceToken && stateRef.current.session) {
|
||||
connectSocket();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
pushToast('Loaded saved device', 'success');
|
||||
} catch {
|
||||
pushToast('Saved device is invalid', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async signOut() {
|
||||
try {
|
||||
await api.auth.signOut();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await cleanupConnectionState();
|
||||
await AsyncStorage.removeItem(DEVICE_STORAGE_KEY);
|
||||
lastMotionEventIdRef.current = null;
|
||||
|
||||
setState({
|
||||
...createInitialState(),
|
||||
toasts: [],
|
||||
});
|
||||
|
||||
pushToast('Signed out', 'info');
|
||||
},
|
||||
|
||||
async startMotion() {
|
||||
if (!stateRef.current.cameraPermissionGranted) {
|
||||
pushToast('Camera permission is required', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await api.events.startMotion();
|
||||
lastMotionEventIdRef.current = response.event.id;
|
||||
setAppState({ isMotionActive: true, cameraStatus: 'recording' });
|
||||
addActivity('Motion', `Started event ${response.event.id}`);
|
||||
pushToast('Motion event started', 'success');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to start motion';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async endMotion() {
|
||||
const eventId = lastMotionEventIdRef.current;
|
||||
if (!eventId) return;
|
||||
|
||||
try {
|
||||
const streamSessionId = activeRecordingStreamSessionIdRef.current;
|
||||
if (streamSessionId) {
|
||||
await api.streams.end(streamSessionId).catch(() => undefined);
|
||||
stopFrameRelay();
|
||||
activeRecordingStreamSessionIdRef.current = null;
|
||||
}
|
||||
await api.events.endMotion(eventId);
|
||||
lastMotionEventIdRef.current = null;
|
||||
setAppState({ isMotionActive: false, cameraStatus: 'idle' });
|
||||
addActivity('Motion', 'Ended event');
|
||||
pushToast('Motion ended', 'success');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to end motion';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async goOnline() {
|
||||
connectSocket();
|
||||
pushToast('Attempting realtime reconnect', 'info');
|
||||
},
|
||||
|
||||
async refreshCameraInputs() {
|
||||
pushToast('Camera input selection is not available in this mobile build', 'info');
|
||||
},
|
||||
|
||||
async selectCameraInput(_cameraInputId: string) {
|
||||
pushToast('Camera input switching is not available in this mobile build', 'info');
|
||||
},
|
||||
|
||||
async linkCamera(cameraDeviceId: string) {
|
||||
const clientDeviceId = stateRef.current.device?.id;
|
||||
if (!clientDeviceId || !cameraDeviceId.trim()) {
|
||||
pushToast('Camera device ID is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.devices.link(cameraDeviceId.trim(), clientDeviceId);
|
||||
pushToast('Camera linked', 'success');
|
||||
await pollClientData();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to link camera';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async renameLinkedCamera(cameraDeviceId: string, nextName: string) {
|
||||
const name = nextName.trim();
|
||||
if (!name) {
|
||||
pushToast('Camera name cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.devices.update(cameraDeviceId, { name });
|
||||
patchAppState((prev) => ({
|
||||
linkedCameras: prev.linkedCameras.map((entry) =>
|
||||
entry.cameraDeviceId === cameraDeviceId ? { ...entry, cameraName: name } : entry,
|
||||
),
|
||||
}));
|
||||
pushToast('Camera renamed', 'success');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to rename camera';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteLinkedCamera(linkId: string) {
|
||||
const link = stateRef.current.linkedCameras.find((entry) => entry.id === linkId);
|
||||
if (!link) return;
|
||||
|
||||
try {
|
||||
await api.devices.unlink(linkId);
|
||||
const remaining = stateRef.current.linkedCameras.filter((entry) => entry.id !== linkId);
|
||||
const isDeletedActive = stateRef.current.activeCameraDeviceId === link.cameraDeviceId;
|
||||
|
||||
requestedStreamsRef.current.delete(link.cameraDeviceId);
|
||||
|
||||
setAppState({
|
||||
linkedCameras: remaining,
|
||||
activeCameraDeviceId: isDeletedActive ? null : stateRef.current.activeCameraDeviceId,
|
||||
activeStreamSessionId: isDeletedActive ? null : stateRef.current.activeStreamSessionId,
|
||||
});
|
||||
|
||||
if (isDeletedActive) {
|
||||
clearClientStream();
|
||||
}
|
||||
|
||||
pushToast('Camera link removed', 'success');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to remove camera link';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async requestStream(cameraDeviceId: string) {
|
||||
try {
|
||||
await api.streams.request(cameraDeviceId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to request stream';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async selectCamera(cameraDeviceId: string) {
|
||||
const sessions = stateRef.current.cameraSessions || {};
|
||||
setAppState({
|
||||
activeCameraDeviceId: cameraDeviceId,
|
||||
activeStreamSessionId: sessions[cameraDeviceId] || null,
|
||||
});
|
||||
|
||||
await actions.requestStream(cameraDeviceId);
|
||||
|
||||
if (!stateRef.current.activeStreamSessionId) {
|
||||
setClientStreamMode('connecting');
|
||||
}
|
||||
},
|
||||
|
||||
closeStreamViewer() {
|
||||
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
||||
clearClientStream();
|
||||
},
|
||||
|
||||
async openRecording(recordingId: string) {
|
||||
try {
|
||||
const result = await api.ops.getRecordingDownloadUrl(recordingId);
|
||||
if (!result?.downloadUrl) {
|
||||
pushToast('Recording URL unavailable', 'error');
|
||||
return;
|
||||
}
|
||||
await Linking.openURL(result.downloadUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to open recording';
|
||||
pushToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async openMotionNotificationTarget(notificationId: string, cameraDeviceId: string) {
|
||||
markMotionNotificationRead(notificationId);
|
||||
if (!cameraDeviceId) return;
|
||||
|
||||
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
|
||||
const readyRecording = (recs.recordings || [])
|
||||
.filter((recording) => recording.cameraDeviceId === cameraDeviceId && recording.status === 'ready')
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
|
||||
)[0];
|
||||
|
||||
if (readyRecording?.id) {
|
||||
await actions.openRecording(readyRecording.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await actions.requestStream(cameraDeviceId);
|
||||
},
|
||||
|
||||
markAllNotificationsRead() {
|
||||
markAllNotificationsRead();
|
||||
},
|
||||
|
||||
clearNotifications() {
|
||||
setAppState({ motionNotifications: [] });
|
||||
},
|
||||
|
||||
async refreshClientData() {
|
||||
await pollClientData();
|
||||
},
|
||||
|
||||
runDiagnostics() {
|
||||
const connected = stateRef.current.socketConnected ? 'connected' : 'disconnected';
|
||||
pushToast(`Diagnostics complete: realtime ${connected}`, 'success');
|
||||
},
|
||||
|
||||
setCameraPermissionGranted(granted: boolean) {
|
||||
setAppState({ cameraPermissionGranted: granted });
|
||||
},
|
||||
|
||||
setCameraPreviewReady(isReady: boolean) {
|
||||
setAppState({ cameraPreviewReady: isReady });
|
||||
},
|
||||
|
||||
setCameraRef(ref: CameraView | null) {
|
||||
cameraRef.current = ref;
|
||||
},
|
||||
|
||||
removeToast,
|
||||
};
|
||||
|
||||
const contextValue: AppContextValue = {
|
||||
state,
|
||||
ready,
|
||||
unreadCount: unreadNotificationsCount(state),
|
||||
actions,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
export function useApp(): AppContextValue {
|
||||
const value = useContext(AppContext);
|
||||
if (!value) {
|
||||
throw new Error('useApp must be used within an AppProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
62
MobileApp/src/components/toast-overlay.tsx
Normal file
62
MobileApp/src/components/toast-overlay.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useApp } from '@/src/app-context';
|
||||
|
||||
const palette = {
|
||||
info: '#1f2937',
|
||||
success: '#166534',
|
||||
error: '#991b1b',
|
||||
};
|
||||
|
||||
export function ToastOverlay() {
|
||||
const { state, actions } = useApp();
|
||||
|
||||
if (state.toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View pointerEvents="box-none" style={styles.container}>
|
||||
{state.toasts.map((toast) => (
|
||||
<View key={toast.id} style={[styles.toast, { backgroundColor: palette[toast.type] }]}>
|
||||
<Text style={styles.message}>{toast.message}</Text>
|
||||
<Pressable onPress={() => actions.removeToast(toast.id)}>
|
||||
<Text style={styles.dismiss}>x</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 52,
|
||||
left: 12,
|
||||
right: 12,
|
||||
zIndex: 100,
|
||||
gap: 8,
|
||||
},
|
||||
toast: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
message: {
|
||||
color: '#f3f4f6',
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
marginRight: 10,
|
||||
},
|
||||
dismiss: {
|
||||
color: '#f3f4f6',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
});
|
||||
30
MobileApp/src/config.ts
Normal file
30
MobileApp/src/config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Constants from 'expo-constants';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const normalizeBaseUrl = (value: string): string => value.replace(/\/+$/, '');
|
||||
|
||||
const hostFromExpo = (): string | null => {
|
||||
const hostUri =
|
||||
Constants.expoConfig?.hostUri ??
|
||||
(Constants as unknown as { manifest2?: { extra?: { expoClient?: { hostUri?: string } } } }).manifest2?.extra
|
||||
?.expoClient?.hostUri;
|
||||
|
||||
if (!hostUri) return null;
|
||||
const host = hostUri.split(':')[0]?.trim();
|
||||
return host || null;
|
||||
};
|
||||
|
||||
const detectDefaultBaseUrl = (): string => {
|
||||
const host = hostFromExpo();
|
||||
if (host && host !== 'localhost') {
|
||||
return `http://${host}:3000`;
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
return 'http://10.0.2.2:3000';
|
||||
}
|
||||
|
||||
return 'http://localhost:3000';
|
||||
};
|
||||
|
||||
export const API_BASE_URL = normalizeBaseUrl(process.env.EXPO_PUBLIC_API_BASE_URL || detectDefaultBaseUrl());
|
||||
136
MobileApp/src/state.ts
Normal file
136
MobileApp/src/state.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
export type AppPage = 'auth' | 'onboarding' | 'camera' | 'client' | 'activity' | 'settings';
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'error';
|
||||
|
||||
export type AppToast = {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LinkedCamera = {
|
||||
id: string;
|
||||
cameraDeviceId: string;
|
||||
clientDeviceId: string;
|
||||
cameraName?: string | null;
|
||||
cameraStatus?: string | null;
|
||||
};
|
||||
|
||||
export type MotionNotification = {
|
||||
id: string;
|
||||
cameraDeviceId: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
isRead: boolean;
|
||||
};
|
||||
|
||||
export type ActivityLogItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RecordingItem = {
|
||||
id: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
cameraDeviceId?: string;
|
||||
durationSeconds?: number | null;
|
||||
streamSessionId?: string;
|
||||
};
|
||||
|
||||
export type Device = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'camera' | 'client';
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type Session = {
|
||||
user?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
session?: {
|
||||
id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
page: AppPage;
|
||||
session: Session | null;
|
||||
device: Device | null;
|
||||
deviceToken: string | null;
|
||||
socketConnected: boolean;
|
||||
isMotionActive: boolean;
|
||||
cameraPermissionGranted: boolean;
|
||||
cameraPreviewReady: boolean;
|
||||
cameraStatus: 'idle' | 'recording';
|
||||
linkedCameras: LinkedCamera[];
|
||||
recordings: RecordingItem[];
|
||||
motionNotifications: MotionNotification[];
|
||||
activeCameraDeviceId: string | null;
|
||||
activeStreamSessionId: string | null;
|
||||
activityLog: ActivityLogItem[];
|
||||
cameraSessions: Record<string, string>;
|
||||
connectedStreamSessionIds: string[];
|
||||
loading: boolean;
|
||||
isRegistering: boolean;
|
||||
authForm: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
};
|
||||
onboardingForm: {
|
||||
name: string;
|
||||
role: 'camera' | 'client';
|
||||
pushToken: string;
|
||||
};
|
||||
toasts: AppToast[];
|
||||
clientStreamMode: 'none' | 'connecting' | 'unavailable' | 'image' | 'video';
|
||||
clientFallbackFrame: string;
|
||||
clientPlaceholderText: string;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export const createInitialState = (): AppState => ({
|
||||
page: 'auth',
|
||||
session: null,
|
||||
device: null,
|
||||
deviceToken: null,
|
||||
socketConnected: false,
|
||||
isMotionActive: false,
|
||||
cameraPermissionGranted: false,
|
||||
cameraPreviewReady: false,
|
||||
cameraStatus: 'idle',
|
||||
linkedCameras: [],
|
||||
recordings: [],
|
||||
motionNotifications: [],
|
||||
activeCameraDeviceId: null,
|
||||
activeStreamSessionId: null,
|
||||
activityLog: [],
|
||||
cameraSessions: {},
|
||||
connectedStreamSessionIds: [],
|
||||
loading: false,
|
||||
isRegistering: false,
|
||||
authForm: {
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
},
|
||||
onboardingForm: {
|
||||
name: '',
|
||||
role: 'client',
|
||||
pushToken: '',
|
||||
},
|
||||
toasts: [],
|
||||
clientStreamMode: 'none',
|
||||
clientFallbackFrame: '',
|
||||
clientPlaceholderText: 'Select a camera to view',
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
export const unreadNotificationsCount = (state: AppState): number =>
|
||||
state.motionNotifications.reduce((count, item) => count + (item.isRead ? 0 : 1), 0);
|
||||
Reference in New Issue
Block a user