feat(mobile): replace starter template with dashboard-driven app flow

This commit is contained in:
2026-03-07 10:20:00 +00:00
parent d03b22a99f
commit 64684eaae6
34 changed files with 4645 additions and 895 deletions

View File

@@ -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>

View 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,
},
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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',
},
});

View File

@@ -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,
},
});