feat(mobile): replace starter template with dashboard-driven app flow
This commit is contained in:
@@ -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 { Platform } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useApp } from '@/src/app-context';
|
||||
|
||||
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 (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
tabBarInactiveTintColor: Colors[colorScheme ?? 'light'].tabIconDefault,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarStyle: Platform.select({
|
||||
default: {
|
||||
backgroundColor: Colors[colorScheme ?? 'light'].background,
|
||||
borderTopColor: Colors[colorScheme ?? 'light'].border,
|
||||
borderTopWidth: 1,
|
||||
elevation: 0,
|
||||
paddingTop: 8,
|
||||
},
|
||||
}),
|
||||
headerStyle: { backgroundColor: '#0f1015' },
|
||||
headerTintColor: '#f9fafb',
|
||||
tabBarStyle: { backgroundColor: '#0f1015', borderTopColor: 'rgba(255,255,255,0.12)' },
|
||||
tabBarActiveTintColor: '#60a5fa',
|
||||
tabBarInactiveTintColor: '#6b7280',
|
||||
sceneStyle: { backgroundColor: '#0a0a0c' },
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
title: state.device?.role === 'camera' ? 'Camera' : 'Dashboard',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="speedometer-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="alerts"
|
||||
name="activity"
|
||||
options={{
|
||||
title: 'Alerts',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="bell.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="clips"
|
||||
options={{
|
||||
title: 'Clips',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="film.fill" color={color} />,
|
||||
title: 'Activity',
|
||||
tabBarBadge: unreadCount > 0 ? unreadCount : undefined,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="notifications-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="gearshape.fill" color={color} />,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</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 { 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';
|
||||
import { useApp } from '@/src/app-context';
|
||||
|
||||
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 (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.header}>
|
||||
<ThemedText type="title">Your Cameras</ThemedText>
|
||||
<ThemedText type="secondary">Manage your indoor security devices</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView
|
||||
style={[styles.card, {
|
||||
shadowColor: Colors[theme].border,
|
||||
borderColor: Colors[theme].border
|
||||
}]}
|
||||
variant="card"
|
||||
>
|
||||
<IconSymbol size={48} name="house.fill" color={Colors[theme].icon} />
|
||||
<ThemedText type="subtitle" style={styles.cardTitle}>No Cameras Added</ThemedText>
|
||||
<ThemedText style={styles.cardDescription} type="secondary">
|
||||
Add a camera to start monitoring your home.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>{title}</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#6b7280"
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable style={[styles.modalButton, styles.modalCancel]} onPress={onCancel}>
|
||||
<Text style={styles.modalCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.modalButton, styles.modalConfirm]} onPress={onConfirm}>
|
||||
<Text style={styles.modalConfirmText}>Save</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraDashboard() {
|
||||
const { state, actions } = useApp();
|
||||
const cameraViewRef = useRef<CameraView | null>(null);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
|
||||
// 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);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}>
|
||||
<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({
|
||||
container: {
|
||||
safe: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
paddingTop: 80,
|
||||
backgroundColor: '#0a0a0c',
|
||||
},
|
||||
header: {
|
||||
marginBottom: 32,
|
||||
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: {
|
||||
padding: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
// iOS Shadow
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
// Android Shadow
|
||||
elevation: 2,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: '#111218',
|
||||
gap: 10,
|
||||
},
|
||||
cardTitle: {
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
color: '#f9fafb',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardDescription: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 240,
|
||||
previewBox: {
|
||||
height: 220,
|
||||
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 { 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';
|
||||
import { useApp } from '@/src/app-context';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { themeMode, setThemeMode } = useTheme();
|
||||
const theme = colorScheme ?? 'light';
|
||||
const { state, actions } = useApp();
|
||||
|
||||
const cardStyle = [
|
||||
styles.card,
|
||||
{
|
||||
shadowColor: Colors[theme].border,
|
||||
borderColor: Colors[theme].border
|
||||
}
|
||||
];
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
actions.setPage('settings');
|
||||
return undefined;
|
||||
}, [actions]),
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.header}>
|
||||
<ThemedText type="title">Settings</ThemedText>
|
||||
<ThemedText type="secondary">App preferences</ThemedText>
|
||||
</ThemedView>
|
||||
const profileName = state.session?.user?.name || 'User';
|
||||
const profileEmail = state.session?.user?.email || 'user@example.com';
|
||||
const profileInitial = (profileName[0] || 'U').toUpperCase();
|
||||
|
||||
<ThemedView style={styles.sectionHeader}>
|
||||
<ThemedText type="subtitle">Appearance</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={cardStyle} variant="card">
|
||||
<TouchableOpacity
|
||||
style={styles.item}
|
||||
onPress={() => setThemeMode('light')}
|
||||
>
|
||||
<ThemedText>Light Mode</ThemedText>
|
||||
{themeMode === 'light' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
|
||||
</TouchableOpacity>
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Settings</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.item}
|
||||
onPress={() => setThemeMode('dark')}
|
||||
>
|
||||
<ThemedText>Dark Mode</ThemedText>
|
||||
{themeMode === 'dark' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
|
||||
</TouchableOpacity>
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{profileInitial}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.profileName}>{profileName}</Text>
|
||||
<Text style={styles.profileEmail}>{profileEmail}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.item, styles.lastItem]}
|
||||
onPress={() => setThemeMode('system')}
|
||||
>
|
||||
<ThemedText>System Default</ThemedText>
|
||||
{themeMode === 'system' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
<View style={styles.menuCard}>
|
||||
<Pressable style={styles.menuItem} onPress={actions.runDiagnostics}>
|
||||
<Text style={styles.menuTitle}>Run Diagnostics</Text>
|
||||
<Text style={styles.menuArrow}>{'>'}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.divider} />
|
||||
<View style={styles.menuItem}>
|
||||
<Text style={styles.menuTitle}>Device Information</Text>
|
||||
<Text style={styles.menuArrow}>{'>'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.signOutButton} onPress={() => void actions.signOut()}>
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
paddingTop: 80,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionHeader: {
|
||||
marginBottom: 12,
|
||||
marginTop: 24,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
item: {
|
||||
padding: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(0,0,0,0.05)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
lastItem: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
safe: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0a0c',
|
||||
},
|
||||
content: {
|
||||
padding: 14,
|
||||
gap: 14,
|
||||
},
|
||||
title: {
|
||||
color: '#f9fafb',
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
},
|
||||
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,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: '#111218',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
minHeight: 52,
|
||||
paddingHorizontal: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
menuTitle: {
|
||||
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 { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { ThemeProvider } from '@/context/ThemeContext';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
import { AppProvider } from '@/src/app-context';
|
||||
import { ToastOverlay } from '@/src/components/toast-overlay';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RootLayoutNav />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
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>
|
||||
<StatusBar style="auto" />
|
||||
</NavigationThemeProvider>
|
||||
<AppProvider>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="auth" />
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
<ToastOverlay />
|
||||
<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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user