feat(mobile): replace starter template with dashboard-driven app flow
This commit is contained in:
5
MobileApp/.env.example
Normal file
5
MobileApp/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# SecureCam backend base URL consumed by the mobile app.
|
||||||
|
# iOS simulator: http://localhost:3000
|
||||||
|
# Android emulator: http://10.0.2.2:3000
|
||||||
|
# Physical device: http://<your-lan-ip>:3000
|
||||||
|
EXPO_PUBLIC_API_BASE_URL=http://localhost:3000
|
||||||
@@ -1,50 +1,87 @@
|
|||||||
# Welcome to your Expo app 👋
|
# SecureCam Mobile (Expo)
|
||||||
|
|
||||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
React Native mobile client for the SecureCam backend.
|
||||||
|
|
||||||
## Get started
|
## Features migrated from the Svelte web app
|
||||||
|
|
||||||
1. Install dependencies
|
- Email/password auth + account creation
|
||||||
|
- Device onboarding (`camera` or `client` role)
|
||||||
|
- Realtime socket connection status
|
||||||
|
- Client dashboard:
|
||||||
|
- Link/unlink cameras
|
||||||
|
- Rename linked cameras
|
||||||
|
- Request live stream sessions
|
||||||
|
- View frame-based live feed fallback images
|
||||||
|
- Open recordings by presigned URL
|
||||||
|
- Activity feed (motion notifications)
|
||||||
|
- Settings (diagnostics + sign out)
|
||||||
|
- Camera dashboard:
|
||||||
|
- Native camera preview (`expo-camera`)
|
||||||
|
- Frame relay over Socket.IO for stream fallback
|
||||||
|
- Motion start/end controls
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm
|
||||||
|
- Expo CLI via `npx expo ...`
|
||||||
|
- Backend running on port `3000` (default)
|
||||||
|
|
||||||
|
## Environment setup
|
||||||
|
|
||||||
|
Set the backend URL for the mobile app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
EXPO_PUBLIC_API_BASE_URL=http://<HOST>:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
You can place this in `MobileApp/.env`.
|
||||||
|
|
||||||
|
### Common values
|
||||||
|
|
||||||
|
- iOS simulator: `http://localhost:3000`
|
||||||
|
- Android emulator: `http://10.0.2.2:3000`
|
||||||
|
- Physical device (same Wi-Fi): `http://<your-lan-ip>:3000`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPO_PUBLIC_API_BASE_URL=http://192.168.1.25:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install and run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd MobileApp
|
||||||
npm install
|
npm install
|
||||||
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Start the app
|
Then open via Expo Go / simulator.
|
||||||
|
|
||||||
|
## Native module notes
|
||||||
|
|
||||||
|
This app uses `expo-camera` for native camera preview and frame capture.
|
||||||
|
|
||||||
|
If your environment is offline, dependency installation may fail. Once network is available, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx expo start
|
cd MobileApp
|
||||||
|
npx expo install expo-camera socket.io-client
|
||||||
```
|
```
|
||||||
|
|
||||||
In the output, you'll find options to open the app in a
|
## WebRTC note
|
||||||
|
|
||||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
Current native mobile live viewing still uses the legacy `stream:frame` fallback path.
|
||||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
|
||||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
|
||||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
|
||||||
|
|
||||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
The backend `SIMPLE_STREAMING` flag therefore defaults to `false` for safe rollout.
|
||||||
|
|
||||||
## Get a fresh project
|
For full native WebRTC publish/subscribe parity, add a supported RN WebRTC stack (for example `react-native-webrtc`) and replace frame relay with peer connection flows equivalent to the web client before enabling `SIMPLE_STREAMING` by default.
|
||||||
|
|
||||||
When you're ready, run:
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run reset-project
|
cd MobileApp
|
||||||
|
npm run lint
|
||||||
|
npx tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
|
||||||
|
|
||||||
## Learn more
|
|
||||||
|
|
||||||
To learn more about developing your project with Expo, look at the following resources:
|
|
||||||
|
|
||||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
|
||||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
|
||||||
|
|
||||||
## Join the community
|
|
||||||
|
|
||||||
Join our community of developers creating universal apps.
|
|
||||||
|
|
||||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
|
||||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow SecureCam to use your camera for live monitoring and motion relay."
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,58 +1,46 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||||
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
import { HapticTab } from '@/components/haptic-tab';
|
import { useApp } from '@/src/app-context';
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const { ready, state, unreadCount } = useApp();
|
||||||
|
|
||||||
|
if (!ready) return null;
|
||||||
|
if (!state.session?.session) return <Redirect href={'/auth' as any} />;
|
||||||
|
if (!state.deviceToken) return <Redirect href={'/onboarding' as any} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
headerStyle: { backgroundColor: '#0f1015' },
|
||||||
tabBarInactiveTintColor: Colors[colorScheme ?? 'light'].tabIconDefault,
|
headerTintColor: '#f9fafb',
|
||||||
headerShown: false,
|
tabBarStyle: { backgroundColor: '#0f1015', borderTopColor: 'rgba(255,255,255,0.12)' },
|
||||||
tabBarButton: HapticTab,
|
tabBarActiveTintColor: '#60a5fa',
|
||||||
tabBarStyle: Platform.select({
|
tabBarInactiveTintColor: '#6b7280',
|
||||||
default: {
|
sceneStyle: { backgroundColor: '#0a0a0c' },
|
||||||
backgroundColor: Colors[colorScheme ?? 'light'].background,
|
|
||||||
borderTopColor: Colors[colorScheme ?? 'light'].border,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
elevation: 0,
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}>
|
}}>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Home',
|
title: state.device?.role === 'camera' ? 'Camera' : 'Dashboard',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="speedometer-outline" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="alerts"
|
name="activity"
|
||||||
options={{
|
options={{
|
||||||
title: 'Alerts',
|
title: 'Activity',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="bell.fill" color={color} />,
|
tabBarBadge: unreadCount > 0 ? unreadCount : undefined,
|
||||||
}}
|
tabBarIcon: ({ color, size }) => <Ionicons name="notifications-outline" size={size} color={color} />,
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="clips"
|
|
||||||
options={{
|
|
||||||
title: 'Clips',
|
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="film.fill" color={color} />,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="gearshape.fill" color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
121
MobileApp/app/(tabs)/activity.tsx
Normal file
121
MobileApp/app/(tabs)/activity.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useApp } from '@/src/app-context';
|
||||||
|
|
||||||
|
export default function ActivityScreen() {
|
||||||
|
const { state, actions } = useApp();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
actions.setPage('activity');
|
||||||
|
return undefined;
|
||||||
|
}, [actions]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Activity History</Text>
|
||||||
|
<Pressable style={styles.clearButton} onPress={actions.clearNotifications}>
|
||||||
|
<Text style={styles.clearButtonText}>Clear Read</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
{state.motionNotifications.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>All quiet. No notifications yet.</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
state.motionNotifications.map((notification) => (
|
||||||
|
<Pressable
|
||||||
|
key={notification.id}
|
||||||
|
style={[styles.item, notification.isRead ? styles.readItem : styles.unreadItem]}
|
||||||
|
onPress={() =>
|
||||||
|
void actions.openMotionNotificationTarget(notification.id, notification.cameraDeviceId)
|
||||||
|
}>
|
||||||
|
<Text style={styles.itemMessage}>{notification.message}</Text>
|
||||||
|
<Text style={styles.itemDate}>{new Date(notification.createdAt).toLocaleString()}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.16)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
height: 34,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
clearButtonText: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
|
backgroundColor: '#111218',
|
||||||
|
padding: 26,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
unreadItem: {
|
||||||
|
backgroundColor: '#14213d',
|
||||||
|
borderColor: '#1d4ed8',
|
||||||
|
},
|
||||||
|
readItem: {
|
||||||
|
backgroundColor: '#111218',
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
itemMessage: {
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
itemDate: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function AlertsScreen() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const theme = colorScheme ?? 'light';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedView style={styles.header}>
|
|
||||||
<ThemedText type="title">Alerts</ThemedText>
|
|
||||||
<ThemedText type="secondary">Recent security activity</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedView
|
|
||||||
style={[styles.card, {
|
|
||||||
shadowColor: Colors[theme].border,
|
|
||||||
borderColor: Colors[theme].border
|
|
||||||
}]}
|
|
||||||
variant="card"
|
|
||||||
>
|
|
||||||
<IconSymbol size={48} name="bell.fill" color={Colors[theme].icon} />
|
|
||||||
<ThemedText type="subtitle" style={styles.cardTitle}>No Alerts</ThemedText>
|
|
||||||
<ThemedText style={styles.cardDescription} type="secondary">
|
|
||||||
You have no new security alerts.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 20,
|
|
||||||
paddingTop: 80,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
padding: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
cardDescription: {
|
|
||||||
textAlign: 'center',
|
|
||||||
maxWidth: 240,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function ClipsScreen() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const theme = colorScheme ?? 'light';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedView style={styles.header}>
|
|
||||||
<ThemedText type="title">Clips</ThemedText>
|
|
||||||
<ThemedText type="secondary">Recorded footage</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedView
|
|
||||||
style={[styles.card, {
|
|
||||||
shadowColor: Colors[theme].border,
|
|
||||||
borderColor: Colors[theme].border
|
|
||||||
}]}
|
|
||||||
variant="card"
|
|
||||||
>
|
|
||||||
<IconSymbol size={48} name="film.fill" color={Colors[theme].icon} />
|
|
||||||
<ThemedText type="subtitle" style={styles.cardTitle}>No Clips Available</ThemedText>
|
|
||||||
<ThemedText style={styles.cardDescription} type="secondary">
|
|
||||||
Recorded clips will appear here.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 20,
|
|
||||||
paddingTop: 80,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
padding: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
cardDescription: {
|
|
||||||
textAlign: 'center',
|
|
||||||
maxWidth: 240,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,67 +1,673 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { useKeepAwake } from 'expo-keep-awake';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { useApp } from '@/src/app-context';
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const theme = colorScheme ?? 'light';
|
|
||||||
|
|
||||||
|
function PromptModal({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||||
<ThemedView style={styles.header}>
|
<View style={styles.modalBackdrop}>
|
||||||
<ThemedText type="title">Your Cameras</ThemedText>
|
<View style={styles.modalCard}>
|
||||||
<ThemedText type="secondary">Manage your indoor security devices</ThemedText>
|
<Text style={styles.modalTitle}>{title}</Text>
|
||||||
</ThemedView>
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
<ThemedView
|
placeholder={placeholder}
|
||||||
style={[styles.card, {
|
placeholderTextColor="#6b7280"
|
||||||
shadowColor: Colors[theme].border,
|
value={value}
|
||||||
borderColor: Colors[theme].border
|
onChangeText={onChange}
|
||||||
}]}
|
autoCapitalize="none"
|
||||||
variant="card"
|
/>
|
||||||
>
|
<View style={styles.modalActions}>
|
||||||
<IconSymbol size={48} name="house.fill" color={Colors[theme].icon} />
|
<Pressable style={[styles.modalButton, styles.modalCancel]} onPress={onCancel}>
|
||||||
<ThemedText type="subtitle" style={styles.cardTitle}>No Cameras Added</ThemedText>
|
<Text style={styles.modalCancelText}>Cancel</Text>
|
||||||
<ThemedText style={styles.cardDescription} type="secondary">
|
</Pressable>
|
||||||
Add a camera to start monitoring your home.
|
<Pressable style={[styles.modalButton, styles.modalConfirm]} onPress={onConfirm}>
|
||||||
</ThemedText>
|
<Text style={styles.modalConfirmText}>Save</Text>
|
||||||
</ThemedView>
|
</Pressable>
|
||||||
</ThemedView>
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function CameraDashboard() {
|
||||||
container: {
|
const { state, actions } = useApp();
|
||||||
flex: 1,
|
const cameraViewRef = useRef<CameraView | null>(null);
|
||||||
padding: 20,
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
paddingTop: 80,
|
|
||||||
|
// Prevent auto-lock while the camera dashboard is open in the foreground.
|
||||||
|
useKeepAwake('securecam-camera-dashboard');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
actions.setCameraPermissionGranted(Boolean(permission?.granted));
|
||||||
|
}, [actions, permission?.granted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
actions.setCameraRef(null);
|
||||||
|
actions.setCameraPreviewReady(false);
|
||||||
|
};
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Camera Preview</Text>
|
||||||
|
{!permission?.granted ? (
|
||||||
|
<View style={styles.permissionBox}>
|
||||||
|
<Text style={styles.permissionText}>Camera permission is required to publish live frames.</Text>
|
||||||
|
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
|
||||||
|
<Text style={styles.primaryButtonText}>Grant Camera Permission</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.previewBox}>
|
||||||
|
<CameraView
|
||||||
|
ref={(ref) => {
|
||||||
|
cameraViewRef.current = ref;
|
||||||
|
actions.setCameraRef(ref);
|
||||||
|
}}
|
||||||
|
style={styles.previewCamera}
|
||||||
|
facing="back"
|
||||||
|
onCameraReady={() => actions.setCameraPreviewReady(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Camera Status</Text>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={styles.label}>Realtime</Text>
|
||||||
|
<Text style={[styles.status, state.socketConnected ? styles.online : styles.offline]}>
|
||||||
|
{state.socketConnected ? 'ONLINE' : 'OFFLINE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={styles.label}>Motion</Text>
|
||||||
|
<Text style={[styles.status, state.isMotionActive ? styles.online : styles.offline]}>
|
||||||
|
{state.isMotionActive ? 'ACTIVE' : 'IDLE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={styles.label}>Preview</Text>
|
||||||
|
<Text style={[styles.status, state.cameraPreviewReady ? styles.online : styles.offline]}>
|
||||||
|
{state.cameraPreviewReady ? 'READY' : 'NOT READY'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={styles.label}>Publishing</Text>
|
||||||
|
<Text style={[styles.status, state.cameraStatus === 'recording' ? styles.online : styles.offline]}>
|
||||||
|
{state.cameraStatus === 'recording' ? 'ACTIVE' : 'IDLE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Actions</Text>
|
||||||
|
{!state.isMotionActive ? (
|
||||||
|
<Pressable style={styles.primaryButton} onPress={() => void actions.startMotion()}>
|
||||||
|
<Text style={styles.primaryButtonText}>Simulate Motion Event</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={() => void actions.endMotion()}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Stop Motion Event</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={() => void actions.goOnline()}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Reconnect Realtime</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.infoText}>
|
||||||
|
This mobile build remains on the legacy frame-relay path. Add a native WebRTC stack before enabling
|
||||||
|
SIMPLE_STREAMING by default.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Logs</Text>
|
||||||
|
{state.activityLog.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>Awaiting events...</Text>
|
||||||
|
) : (
|
||||||
|
state.activityLog.map((item) => (
|
||||||
|
<Text key={item.id} style={styles.logLine}>
|
||||||
|
[{new Date(item.createdAt).toLocaleTimeString()}] {item.type}: {item.message}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientDashboard() {
|
||||||
|
const { state, actions } = useApp();
|
||||||
|
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
||||||
|
const [cameraIdInput, setCameraIdInput] = useState('');
|
||||||
|
const [renameTargetId, setRenameTargetId] = useState<string | null>(null);
|
||||||
|
const [renameInput, setRenameInput] = useState('');
|
||||||
|
|
||||||
|
const activeCameraLabel = useMemo(() => {
|
||||||
|
const linked = state.linkedCameras.find((camera) => camera.cameraDeviceId === state.activeCameraDeviceId);
|
||||||
|
return linked?.cameraName || linked?.cameraDeviceId || 'Live Feed Viewer';
|
||||||
|
}, [state.activeCameraDeviceId, state.linkedCameras]);
|
||||||
|
|
||||||
|
const isCameraLive = (cameraDeviceId: string) => {
|
||||||
|
const sessionId = state.cameraSessions?.[cameraDeviceId];
|
||||||
|
if (!sessionId) return false;
|
||||||
|
return state.connectedStreamSessionIds.includes(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRenameModal = (cameraDeviceId: string, currentName?: string | null) => {
|
||||||
|
setRenameTargetId(cameraDeviceId);
|
||||||
|
setRenameInput(currentName || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRename = async () => {
|
||||||
|
if (!renameTargetId) return;
|
||||||
|
await actions.renameLinkedCamera(renameTargetId, renameInput);
|
||||||
|
setRenameTargetId(null);
|
||||||
|
setRenameInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={styles.screenTitle}>Client Dashboard</Text>
|
||||||
|
<View style={styles.rowGap}>
|
||||||
|
<Pressable style={styles.outlineButton} onPress={() => setLinkModalOpen(true)}>
|
||||||
|
<Text style={styles.outlineButtonText}>Link Camera</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.iconButton} onPress={() => void actions.refreshClientData()}>
|
||||||
|
<Text style={styles.iconButtonText}>R</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Your Cameras</Text>
|
||||||
|
<ScrollView horizontal contentContainerStyle={styles.cameraRow} showsHorizontalScrollIndicator={false}>
|
||||||
|
{state.linkedCameras.length === 0 ? (
|
||||||
|
<View style={styles.emptyCameraCard}>
|
||||||
|
<Text style={styles.emptyText}>No cameras linked yet</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
state.linkedCameras.map((link) => {
|
||||||
|
const selected = state.activeCameraDeviceId === link.cameraDeviceId;
|
||||||
|
const live = isCameraLive(link.cameraDeviceId);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={link.id}
|
||||||
|
style={[styles.cameraCard, selected ? styles.cameraCardSelected : null]}>
|
||||||
|
<Text style={styles.cameraName}>{link.cameraName || link.cameraDeviceId}</Text>
|
||||||
|
<Text style={[styles.cameraStatus, link.cameraStatus === 'online' ? styles.online : styles.offline]}>
|
||||||
|
{(link.cameraStatus || 'offline').toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.cameraSubtext}>{live ? 'Live stream active' : 'Tap view to request stream'}</Text>
|
||||||
|
|
||||||
|
<View style={styles.cameraActionsRow}>
|
||||||
|
<Pressable style={styles.smallButton} onPress={() => void actions.selectCamera(link.cameraDeviceId)}>
|
||||||
|
<Text style={styles.smallButtonText}>View</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={styles.smallButton}
|
||||||
|
onPress={() => openRenameModal(link.cameraDeviceId, link.cameraName)}>
|
||||||
|
<Text style={styles.smallButtonText}>Rename</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.smallButton, styles.smallDangerButton]}
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Remove Camera',
|
||||||
|
`Remove \"${link.cameraName || link.cameraDeviceId}\" from linked cameras?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
void actions.deleteLinkedCamera(link.id);
|
||||||
},
|
},
|
||||||
header: {
|
},
|
||||||
marginBottom: 32,
|
],
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
<Text style={[styles.smallButtonText, styles.smallDangerText]}>Delete</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{state.activeCameraDeviceId ? (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={styles.cardTitle}>Live Feed: {activeCameraLabel}</Text>
|
||||||
|
<Pressable style={styles.iconButton} onPress={actions.closeStreamViewer}>
|
||||||
|
<Text style={styles.iconButtonText}>X</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.streamBox}>
|
||||||
|
{state.clientStreamMode === 'image' && state.clientFallbackFrame ? (
|
||||||
|
<Image source={{ uri: state.clientFallbackFrame }} style={styles.streamImage} resizeMode="contain" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.streamPlaceholder}>{state.clientPlaceholderText}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Recent Recordings</Text>
|
||||||
|
{state.recordings.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>No recordings found</Text>
|
||||||
|
) : (
|
||||||
|
state.recordings.slice(0, 5).map((recording) => (
|
||||||
|
<View key={recording.id} style={styles.recordingRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.recordingTitle}>{new Date(recording.createdAt).toLocaleString()}</Text>
|
||||||
|
<Text style={styles.recordingMeta}>
|
||||||
|
{recording.durationSeconds != null ? `${recording.durationSeconds}s` : 'Duration pending'} -{' '}
|
||||||
|
{recording.status ?? 'unknown'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={styles.smallButton}
|
||||||
|
disabled={recording.status !== 'ready'}
|
||||||
|
onPress={() => void actions.openRecording(recording.id)}>
|
||||||
|
<Text style={styles.smallButtonText}>Open</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<PromptModal
|
||||||
|
visible={linkModalOpen}
|
||||||
|
title="Link Camera"
|
||||||
|
placeholder="Camera Device ID"
|
||||||
|
value={cameraIdInput}
|
||||||
|
onChange={setCameraIdInput}
|
||||||
|
onCancel={() => {
|
||||||
|
setLinkModalOpen(false);
|
||||||
|
setCameraIdInput('');
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
void actions.linkCamera(cameraIdInput);
|
||||||
|
setLinkModalOpen(false);
|
||||||
|
setCameraIdInput('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PromptModal
|
||||||
|
visible={Boolean(renameTargetId)}
|
||||||
|
title="Rename Camera"
|
||||||
|
placeholder="Camera name"
|
||||||
|
value={renameInput}
|
||||||
|
onChange={setRenameInput}
|
||||||
|
onCancel={() => {
|
||||||
|
setRenameTargetId(null);
|
||||||
|
setRenameInput('');
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
void submitRename();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
const { state, actions } = useApp();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
actions.setPage(state.device?.role === 'camera' ? 'camera' : 'client');
|
||||||
|
return undefined;
|
||||||
|
}, [actions, state.device?.role]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SafeAreaView style={styles.safe}>{state.device?.role === 'camera' ? <CameraDashboard /> : <ClientDashboard />}</SafeAreaView>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 14,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
screenTitle: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
rowBetween: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
rowGap: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
padding: 32,
|
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
padding: 14,
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
// iOS Shadow
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
backgroundColor: '#111218',
|
||||||
shadowOpacity: 0.1,
|
gap: 10,
|
||||||
shadowRadius: 12,
|
|
||||||
// Android Shadow
|
|
||||||
elevation: 2,
|
|
||||||
},
|
},
|
||||||
cardTitle: {
|
cardTitle: {
|
||||||
marginTop: 16,
|
color: '#f9fafb',
|
||||||
marginBottom: 8,
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
cardDescription: {
|
previewBox: {
|
||||||
textAlign: 'center',
|
height: 220,
|
||||||
maxWidth: 240,
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
backgroundColor: '#08090d',
|
||||||
|
},
|
||||||
|
previewCamera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
permissionBox: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
backgroundColor: '#08090d',
|
||||||
|
padding: 12,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
online: {
|
||||||
|
color: '#34d399',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
color: '#f87171',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
outlineButton: {
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
outlineButtonText: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
height: 36,
|
||||||
|
width: 36,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
iconButtonText: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
logLine: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
cameraRow: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
emptyCameraCard: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
|
padding: 16,
|
||||||
|
minWidth: 240,
|
||||||
|
},
|
||||||
|
cameraCard: {
|
||||||
|
minWidth: 260,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#0d0f15',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
cameraCardSelected: {
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
backgroundColor: '#111b2f',
|
||||||
|
},
|
||||||
|
cameraName: {
|
||||||
|
color: '#f3f4f6',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
cameraStatus: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
cameraSubtext: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
cameraActionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
smallButton: {
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.16)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
height: 30,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
smallButtonText: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
smallDangerButton: {
|
||||||
|
borderColor: 'rgba(248,113,113,0.4)',
|
||||||
|
},
|
||||||
|
smallDangerText: {
|
||||||
|
color: '#fca5a5',
|
||||||
|
},
|
||||||
|
streamBox: {
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
backgroundColor: '#08090d',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
streamImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
streamPlaceholder: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
recordingRow: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
backgroundColor: '#0d0f15',
|
||||||
|
padding: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
recordingTitle: {
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
recordingMeta: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 360,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: '#111218',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: '#f3f4f6',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalInput: {
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.14)',
|
||||||
|
backgroundColor: '#0a0a0e',
|
||||||
|
color: '#f3f4f6',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
modalButton: {
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 8,
|
||||||
|
minWidth: 76,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
modalCancel: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.16)',
|
||||||
|
},
|
||||||
|
modalConfirm: {
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
},
|
||||||
|
modalCancelText: {
|
||||||
|
color: '#d1d5db',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalConfirmText: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,95 +1,143 @@
|
|||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { useApp } from '@/src/app-context';
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useTheme } from '@/context/ThemeContext';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const colorScheme = useColorScheme();
|
const { state, actions } = useApp();
|
||||||
const { themeMode, setThemeMode } = useTheme();
|
|
||||||
const theme = colorScheme ?? 'light';
|
|
||||||
|
|
||||||
const cardStyle = [
|
useFocusEffect(
|
||||||
styles.card,
|
React.useCallback(() => {
|
||||||
{
|
actions.setPage('settings');
|
||||||
shadowColor: Colors[theme].border,
|
return undefined;
|
||||||
borderColor: Colors[theme].border
|
}, [actions]),
|
||||||
}
|
);
|
||||||
];
|
|
||||||
|
const profileName = state.session?.user?.name || 'User';
|
||||||
|
const profileEmail = state.session?.user?.email || 'user@example.com';
|
||||||
|
const profileInitial = (profileName[0] || 'U').toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<SafeAreaView style={styles.safe}>
|
||||||
<ThemedView style={styles.header}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<ThemedText type="title">Settings</ThemedText>
|
<Text style={styles.title}>Settings</Text>
|
||||||
<ThemedText type="secondary">App preferences</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedView style={styles.sectionHeader}>
|
<View style={styles.profileCard}>
|
||||||
<ThemedText type="subtitle">Appearance</ThemedText>
|
<View style={styles.avatar}>
|
||||||
</ThemedView>
|
<Text style={styles.avatarText}>{profileInitial}</Text>
|
||||||
<ThemedView style={cardStyle} variant="card">
|
</View>
|
||||||
<TouchableOpacity
|
<View>
|
||||||
style={styles.item}
|
<Text style={styles.profileName}>{profileName}</Text>
|
||||||
onPress={() => setThemeMode('light')}
|
<Text style={styles.profileEmail}>{profileEmail}</Text>
|
||||||
>
|
</View>
|
||||||
<ThemedText>Light Mode</ThemedText>
|
</View>
|
||||||
{themeMode === 'light' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<View style={styles.menuCard}>
|
||||||
style={styles.item}
|
<Pressable style={styles.menuItem} onPress={actions.runDiagnostics}>
|
||||||
onPress={() => setThemeMode('dark')}
|
<Text style={styles.menuTitle}>Run Diagnostics</Text>
|
||||||
>
|
<Text style={styles.menuArrow}>{'>'}</Text>
|
||||||
<ThemedText>Dark Mode</ThemedText>
|
</Pressable>
|
||||||
{themeMode === 'dark' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
|
<View style={styles.divider} />
|
||||||
</TouchableOpacity>
|
<View style={styles.menuItem}>
|
||||||
|
<Text style={styles.menuTitle}>Device Information</Text>
|
||||||
|
<Text style={styles.menuArrow}>{'>'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<Pressable style={styles.signOutButton} onPress={() => void actions.signOut()}>
|
||||||
style={[styles.item, styles.lastItem]}
|
<Text style={styles.signOutText}>Sign Out</Text>
|
||||||
onPress={() => setThemeMode('system')}
|
</Pressable>
|
||||||
>
|
</ScrollView>
|
||||||
<ThemedText>System Default</ThemedText>
|
</SafeAreaView>
|
||||||
{themeMode === 'system' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ThemedView>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
safe: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 20,
|
backgroundColor: '#0a0a0c',
|
||||||
paddingTop: 80,
|
|
||||||
},
|
},
|
||||||
header: {
|
content: {
|
||||||
marginBottom: 32,
|
padding: 14,
|
||||||
|
gap: 14,
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
title: {
|
||||||
marginBottom: 12,
|
color: '#f9fafb',
|
||||||
marginTop: 24,
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
card: {
|
profileCard: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
backgroundColor: '#111218',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: '#1d4ed8',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
profileName: {
|
||||||
|
color: '#f3f4f6',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
profileEmail: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
marginTop: 3,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
menuCard: {
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
shadowOpacity: 0.05,
|
backgroundColor: '#111218',
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 2,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
item: {
|
menuItem: {
|
||||||
padding: 16,
|
minHeight: 52,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
paddingHorizontal: 14,
|
||||||
borderBottomColor: 'rgba(0,0,0,0.05)',
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
lastItem: {
|
menuTitle: {
|
||||||
borderBottomWidth: 0,
|
color: '#e5e7eb',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
menuArrow: {
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
signOutButton: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(248,113,113,0.5)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
signOutText: {
|
||||||
|
color: '#fca5a5',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,24 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider } from '@react-navigation/native';
|
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import 'react-native-reanimated';
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
import { ThemeProvider } from '@/context/ThemeContext';
|
import { AppProvider } from '@/src/app-context';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { ToastOverlay } from '@/src/components/toast-overlay';
|
||||||
|
|
||||||
export const unstable_settings = {
|
|
||||||
anchor: '(tabs)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<AppProvider>
|
||||||
<RootLayoutNav />
|
<View style={{ flex: 1 }}>
|
||||||
</ThemeProvider>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
);
|
<Stack.Screen name="index" />
|
||||||
}
|
<Stack.Screen name="auth" />
|
||||||
|
<Stack.Screen name="onboarding" />
|
||||||
function RootLayoutNav() {
|
<Stack.Screen name="(tabs)" />
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<ToastOverlay />
|
||||||
</NavigationThemeProvider>
|
<StatusBar style="light" />
|
||||||
|
</View>
|
||||||
|
</AppProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
175
MobileApp/app/auth.tsx
Normal file
175
MobileApp/app/auth.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
SafeAreaView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { useApp } from '@/src/app-context';
|
||||||
|
|
||||||
|
export default function AuthScreen() {
|
||||||
|
const { ready, state, actions } = useApp();
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color="#60a5fa" size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.session?.session) {
|
||||||
|
return <Redirect href={(state.deviceToken ? '/(tabs)' : '/onboarding') as any} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.container}>
|
||||||
|
<View style={styles.logoWrap}>
|
||||||
|
<View style={styles.logoBadge}>
|
||||||
|
<Text style={styles.logoIcon}>SC</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>SecureCam Mobile</Text>
|
||||||
|
<Text style={styles.subtitle}>Sign in to manage visual security.</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
placeholder="Email address"
|
||||||
|
placeholderTextColor="#6b7280"
|
||||||
|
value={state.authForm.email}
|
||||||
|
onChangeText={(value) => actions.setAuthField('email', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder="Password"
|
||||||
|
placeholderTextColor="#6b7280"
|
||||||
|
value={state.authForm.password}
|
||||||
|
onChangeText={(value) => actions.setAuthField('password', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{state.isRegistering ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Your name"
|
||||||
|
placeholderTextColor="#6b7280"
|
||||||
|
value={state.authForm.name}
|
||||||
|
onChangeText={(value) => actions.setAuthField('name', value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable style={styles.primaryButton} onPress={() => void actions.submitAuth()}>
|
||||||
|
<Text style={styles.primaryText}>{state.isRegistering ? 'Create Account' : 'Sign In'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={actions.toggleAuthMode}>
|
||||||
|
<Text style={styles.secondaryText}>
|
||||||
|
{state.isRegistering ? 'I already have an account' : 'Create an account'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 22,
|
||||||
|
},
|
||||||
|
logoWrap: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
logoBadge: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#1d4ed8',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
logoIcon: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#111218',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
padding: 18,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
backgroundColor: '#09090d',
|
||||||
|
color: '#f3f4f6',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
marginTop: 6,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryText: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryText: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
36
MobileApp/app/index.tsx
Normal file
36
MobileApp/app/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useApp } from '@/src/app-context';
|
||||||
|
|
||||||
|
export default function IndexRoute() {
|
||||||
|
const { ready, state } = useApp();
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color="#60a5fa" size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.session?.session) {
|
||||||
|
return <Redirect href={'/auth' as any} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.deviceToken) {
|
||||||
|
return <Redirect href={'/onboarding' as any} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Redirect href="/(tabs)" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loading: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Link } from 'expo-router';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
|
|
||||||
export default function ModalScreen() {
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedText type="title">This is a modal</ThemedText>
|
|
||||||
<Link href="/" dismissTo style={styles.link}>
|
|
||||||
<ThemedText type="link">Go to home screen</ThemedText>
|
|
||||||
</Link>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
marginTop: 15,
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
198
MobileApp/app/onboarding.tsx
Normal file
198
MobileApp/app/onboarding.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
SafeAreaView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { useApp } from '@/src/app-context';
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const { ready, state, actions } = useApp();
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color="#60a5fa" size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.session?.session) {
|
||||||
|
return <Redirect href={'/auth' as any} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.deviceToken) {
|
||||||
|
return <Redirect href="/(tabs)" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.container}>
|
||||||
|
<View style={styles.heading}>
|
||||||
|
<Text style={styles.title}>Configure Device</Text>
|
||||||
|
<Text style={styles.subtitle}>Set up this mobile dashboard role.</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.label}>Device Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g. Living Room Client"
|
||||||
|
placeholderTextColor="#6b7280"
|
||||||
|
value={state.onboardingForm.name}
|
||||||
|
onChangeText={(value) => actions.setOnboardingField('name', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Role</Text>
|
||||||
|
<View style={styles.roleRow}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.roleButton, state.onboardingForm.role === 'camera' ? styles.roleButtonActive : null]}
|
||||||
|
onPress={() => actions.selectRole('camera')}>
|
||||||
|
<Text style={[styles.roleText, state.onboardingForm.role === 'camera' ? styles.roleTextActive : null]}>
|
||||||
|
Camera
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.roleButton, state.onboardingForm.role === 'client' ? styles.roleButtonActive : null]}
|
||||||
|
onPress={() => actions.selectRole('client')}>
|
||||||
|
<Text style={[styles.roleText, state.onboardingForm.role === 'client' ? styles.roleTextActive : null]}>
|
||||||
|
Client
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Push Token (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="simulated_token_123"
|
||||||
|
placeholderTextColor="#6b7280"
|
||||||
|
value={state.onboardingForm.pushToken}
|
||||||
|
onChangeText={(value) => actions.setOnboardingField('pushToken', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable style={styles.primaryButton} onPress={() => void actions.registerDevice()}>
|
||||||
|
<Text style={styles.primaryText}>Complete Setup</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={() => void actions.loadSavedDevice()}>
|
||||||
|
<Text style={styles.secondaryText}>Load previously saved device</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0a0a0c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#111218',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
padding: 18,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
backgroundColor: '#09090d',
|
||||||
|
color: '#f3f4f6',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
roleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
roleButton: {
|
||||||
|
flex: 1,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#1f2230',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
roleButtonActive: {
|
||||||
|
backgroundColor: '#1d4ed8',
|
||||||
|
borderColor: '#2563eb',
|
||||||
|
},
|
||||||
|
roleText: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
roleTextActive: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
marginTop: 6,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
primaryText: {
|
||||||
|
color: '#f9fafb',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
secondaryText: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
2030
MobileApp/bun.lock
Normal file
2030
MobileApp/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
import { Href, Link } from 'expo-router';
|
|
||||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
|
||||||
import { type ComponentProps } from 'react';
|
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
target="_blank"
|
|
||||||
{...rest}
|
|
||||||
href={href}
|
|
||||||
onPress={async (event) => {
|
|
||||||
if (process.env.EXPO_OS !== 'web') {
|
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
|
||||||
event.preventDefault();
|
|
||||||
// Open the link in an in-app browser.
|
|
||||||
await openBrowserAsync(href, {
|
|
||||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
|
||||||
import { PlatformPressable } from '@react-navigation/elements';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
|
||||||
|
|
||||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
|
||||||
return (
|
|
||||||
<PlatformPressable
|
|
||||||
{...props}
|
|
||||||
onPressIn={(ev) => {
|
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
|
||||||
// Add a soft haptic feedback when pressing down on the tabs.
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
props.onPressIn?.(ev);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Animated from 'react-native-reanimated';
|
|
||||||
|
|
||||||
export function HelloWave() {
|
|
||||||
return (
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 32,
|
|
||||||
marginTop: -6,
|
|
||||||
animationName: {
|
|
||||||
'50%': { transform: [{ rotate: '25deg' }] },
|
|
||||||
},
|
|
||||||
animationIterationCount: 4,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
}}>
|
|
||||||
👋
|
|
||||||
</Animated.Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
import Animated, {
|
|
||||||
interpolate,
|
|
||||||
useAnimatedRef,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useScrollOffset,
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250;
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
headerImage: ReactElement;
|
|
||||||
headerBackgroundColor: { dark: string; light: string };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function ParallaxScrollView({
|
|
||||||
children,
|
|
||||||
headerImage,
|
|
||||||
headerBackgroundColor,
|
|
||||||
}: Props) {
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollOffset(scrollRef);
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{ backgroundColor, flex: 1 }}
|
|
||||||
scrollEventThrottle={16}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.header,
|
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}>
|
|
||||||
{headerImage}
|
|
||||||
</Animated.View>
|
|
||||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
height: HEADER_HEIGHT,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 32,
|
|
||||||
gap: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link' | 'secondary';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedText({
|
|
||||||
style,
|
|
||||||
lightColor,
|
|
||||||
darkColor,
|
|
||||||
type = 'default',
|
|
||||||
...rest
|
|
||||||
}: ThemedTextProps) {
|
|
||||||
const color = useThemeColor(
|
|
||||||
{ light: lightColor, dark: darkColor },
|
|
||||||
type === 'secondary' ? 'textSecondary' : 'text'
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
{ color },
|
|
||||||
type === 'default' ? styles.default : undefined,
|
|
||||||
type === 'title' ? styles.title : undefined,
|
|
||||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
|
||||||
type === 'subtitle' ? styles.subtitle : undefined,
|
|
||||||
type === 'link' ? styles.link : undefined,
|
|
||||||
type === 'secondary' ? styles.secondary : undefined,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
default: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
defaultSemiBold: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
lineHeight: 32,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
lineHeight: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#635bff', // Stripe blurple
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { View, type ViewProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedViewProps = ViewProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
variant?: 'default' | 'card';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedView({ style, lightColor, darkColor, variant = 'default', ...otherProps }: ThemedViewProps) {
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, variant === 'card' ? 'card' : 'background');
|
|
||||||
|
|
||||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.heading}
|
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
|
||||||
activeOpacity={0.8}>
|
|
||||||
<IconSymbol
|
|
||||||
name="chevron.right"
|
|
||||||
size={18}
|
|
||||||
weight="medium"
|
|
||||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
|
||||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
heading: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 24,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
|
||||||
import { StyleProp, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
weight = 'regular',
|
|
||||||
}: {
|
|
||||||
name: SymbolViewProps['name'];
|
|
||||||
size?: number;
|
|
||||||
color: string;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SymbolView
|
|
||||||
weight={weight}
|
|
||||||
tintColor={color}
|
|
||||||
resizeMode="scaleAspectFit"
|
|
||||||
name={name}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Fallback for using MaterialIcons on Android and web.
|
|
||||||
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
|
||||||
|
|
||||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
|
||||||
type IconSymbolName = keyof typeof MAPPING;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add your SF Symbols to Material Icons mappings here.
|
|
||||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
|
||||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
|
||||||
*/
|
|
||||||
const MAPPING = {
|
|
||||||
'house.fill': 'home',
|
|
||||||
'paperplane.fill': 'send',
|
|
||||||
'chevron.left.forwardslash.chevron.right': 'code',
|
|
||||||
'chevron.right': 'chevron-right',
|
|
||||||
'bell.fill': 'notifications',
|
|
||||||
'film.fill': 'video-library',
|
|
||||||
'gearshape.fill': 'settings',
|
|
||||||
'checkmark': 'check',
|
|
||||||
} as IconMapping;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
|
||||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
|
||||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
|
||||||
*/
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
name: IconSymbolName;
|
|
||||||
size?: number;
|
|
||||||
color: string | OpaqueColorValue;
|
|
||||||
style?: StyleProp<TextStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
const tintColorLight = '#635bff';
|
|
||||||
const tintColorDark = '#fff';
|
|
||||||
|
|
||||||
export const Colors = {
|
|
||||||
light: {
|
|
||||||
text: '#0a2540',
|
|
||||||
textSecondary: '#425466',
|
|
||||||
background: '#f6f9fc',
|
|
||||||
tint: tintColorLight,
|
|
||||||
icon: '#8898aa',
|
|
||||||
tabIconDefault: '#8898aa',
|
|
||||||
tabIconSelected: tintColorLight,
|
|
||||||
card: '#ffffff',
|
|
||||||
border: '#e6ebf1',
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
text: '#ECEDEE',
|
|
||||||
textSecondary: '#9BA1A6',
|
|
||||||
background: '#151718',
|
|
||||||
tint: tintColorDark,
|
|
||||||
icon: '#9BA1A6',
|
|
||||||
tabIconDefault: '#9BA1A6',
|
|
||||||
tabIconSelected: tintColorDark,
|
|
||||||
card: '#232526', // Fallback for dark mode
|
|
||||||
border: '#333333',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
|
||||||
ios: {
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
|
||||||
sans: 'system-ui',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
|
||||||
serif: 'ui-serif',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
|
||||||
rounded: 'ui-rounded',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
|
||||||
mono: 'ui-monospace',
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
sans: 'normal',
|
|
||||||
serif: 'serif',
|
|
||||||
rounded: 'normal',
|
|
||||||
mono: 'monospace',
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
import { useColorScheme as useNativeColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
|
||||||
themeMode: ThemeMode;
|
|
||||||
setThemeMode: (mode: ThemeMode) => void;
|
|
||||||
colorScheme: 'light' | 'dark'; // The actual active scheme
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType>({
|
|
||||||
themeMode: 'system',
|
|
||||||
setThemeMode: () => { },
|
|
||||||
colorScheme: 'light',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = 'user_theme_preference';
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const systemColorScheme = useNativeColorScheme();
|
|
||||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load preference from storage on mount
|
|
||||||
AsyncStorage.getItem(THEME_STORAGE_KEY).then((value) => {
|
|
||||||
if (value && (value === 'light' || value === 'dark' || value === 'system')) {
|
|
||||||
setThemeModeState(value as ThemeMode);
|
|
||||||
}
|
|
||||||
setIsReady(true);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setThemeMode = async (mode: ThemeMode) => {
|
|
||||||
setThemeModeState(mode);
|
|
||||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine effective color scheme
|
|
||||||
const colorScheme =
|
|
||||||
themeMode === 'system'
|
|
||||||
? (systemColorScheme ?? 'light')
|
|
||||||
: themeMode;
|
|
||||||
|
|
||||||
if (!isReady) {
|
|
||||||
return null; // Or a splash screen
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ themeMode, setThemeMode, colorScheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
return useContext(ThemeContext);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { useTheme } from '@/context/ThemeContext';
|
|
||||||
|
|
||||||
export function useColorScheme() {
|
|
||||||
const { colorScheme } = useTheme();
|
|
||||||
return colorScheme;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
|
||||||
*/
|
|
||||||
export function useColorScheme() {
|
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasHydrated(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const colorScheme = useRNColorScheme();
|
|
||||||
|
|
||||||
if (hasHydrated) {
|
|
||||||
return colorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about light and dark modes:
|
|
||||||
* https://docs.expo.dev/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
const colorFromProps = props[theme];
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps;
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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