Files
Tracearr/apps/mobile/app/(tabs)/settings.tsx
Rephl3x 3015f48118
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Initial Upload
2025-12-17 12:32:50 +13:00

300 lines
8.6 KiB
TypeScript

/**
* Settings tab - notifications, app info
* Server selection is handled via the global header selector
*/
import { View, ScrollView, Pressable, Switch, Alert, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronRight, LogOut } from 'lucide-react-native';
import { useAuthStore } from '@/lib/authStore';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { colors } from '@/lib/theme';
import Constants from 'expo-constants';
function SettingsRow({
label,
value,
onPress,
showChevron,
leftIcon,
rightIcon,
destructive,
}: {
label: string;
value?: string;
onPress?: () => void;
showChevron?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
destructive?: boolean;
}) {
const content = (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
<View className="flex-row items-center flex-1">
{leftIcon && <View className="mr-3">{leftIcon}</View>}
<Text className={cn('text-base flex-1', destructive && 'text-destructive')}>{label}</Text>
</View>
<View className="flex-row items-center">
{value && <Text className="text-base text-muted-foreground text-right ml-4">{value}</Text>}
{rightIcon}
{showChevron && (
<ChevronRight size={20} color={colors.text.muted.dark} className="ml-2" />
)}
</View>
</View>
);
if (onPress) {
return (
<Pressable onPress={onPress} className="active:opacity-70 active:bg-background">
{content}
</Pressable>
);
}
return content;
}
function SettingsToggle({
label,
description,
value,
onValueChange,
disabled,
isLoading,
}: {
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
isLoading?: boolean;
}) {
return (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
<View className="flex-1 mr-4">
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
{description && (
<Text className={cn('text-xs text-muted-foreground mt-0.5', disabled && 'opacity-50')}>
{description}
</Text>
)}
</View>
{isLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
/>
)}
</View>
);
}
function SettingsSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-6 px-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
<Card className="p-0 overflow-hidden">{children}</Card>
</View>
);
}
function Divider() {
return <View className="h-px bg-border ml-4" />;
}
export default function SettingsScreen() {
const router = useRouter();
const queryClient = useQueryClient();
const {
activeServerId,
activeServer,
isLoading: isAuthLoading,
logout,
} = useAuthStore();
const appVersion = Constants.expoConfig?.version || '1.0.0';
// Fetch notification preferences
const {
data: preferences,
isLoading: isLoadingPrefs,
error: prefsError,
} = useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: api.notifications.getPreferences,
staleTime: 1000 * 60, // 1 minute
enabled: !!activeServerId,
});
// Update mutation for quick toggle
const updateMutation = useMutation({
mutationFn: api.notifications.updatePreferences,
onMutate: async (newData) => {
await queryClient.cancelQueries({
queryKey: ['notifications', 'preferences'],
});
const previousData = queryClient.getQueryData([
'notifications',
'preferences',
]);
queryClient.setQueryData(
['notifications', 'preferences'],
(old: typeof preferences) => (old ? { ...old, ...newData } : old)
);
return { previousData };
},
onError: (_err, _newData, context) => {
if (context?.previousData) {
queryClient.setQueryData(
['notifications', 'preferences'],
context.previousData
);
}
},
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: ['notifications', 'preferences'],
});
},
});
const handleDisconnect = () => {
Alert.alert(
'Disconnect from Server',
'Are you sure you want to disconnect? You will need to scan a QR code to reconnect.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: () => {
void (async () => {
await queryClient.cancelQueries();
await logout();
queryClient.clear();
})();
},
},
]
);
};
const handleTogglePush = (value: boolean) => {
updateMutation.mutate({ pushEnabled: value });
};
const navigateToNotificationSettings = () => {
router.push('/settings/notifications');
};
// Count enabled notification events for summary
const enabledEventCount = preferences
? [
preferences.onViolationDetected,
preferences.onStreamStarted,
preferences.onStreamStopped,
preferences.onConcurrentStreams,
preferences.onNewDevice,
preferences.onTrustScoreChanged,
preferences.onServerDown,
preferences.onServerUp,
].filter(Boolean).length
: 0;
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark }}
edges={['left', 'right', 'bottom']}
>
<ScrollView className="flex-1" contentContainerClassName="py-4">
{/* Connected Server Info */}
{activeServer && (
<SettingsSection title="Connected Server">
<SettingsRow label="Name" value={activeServer.name} />
<Divider />
<SettingsRow label="URL" value={activeServer.url} />
<Divider />
<SettingsRow
label="Disconnect"
onPress={handleDisconnect}
leftIcon={<LogOut size={20} color={colors.error} />}
destructive
/>
</SettingsSection>
)}
{/* Notification Settings */}
{activeServerId && (
<SettingsSection title="Notifications">
{prefsError ? (
<View className="px-4 py-3">
<Text className="text-destructive text-sm">
Failed to load notification settings
</Text>
</View>
) : (
<>
<SettingsToggle
label="Push Notifications"
description="Receive alerts on this device"
value={preferences?.pushEnabled ?? false}
onValueChange={handleTogglePush}
isLoading={isLoadingPrefs}
disabled={updateMutation.isPending}
/>
<Divider />
<SettingsRow
label="Notification Settings"
value={
preferences?.pushEnabled ? `${enabledEventCount} events enabled` : 'Disabled'
}
onPress={navigateToNotificationSettings}
showChevron
/>
<Text className="text-xs text-muted-foreground px-4 py-2 leading-4">
Configure which events trigger notifications, quiet hours, and filters.
</Text>
</>
)}
</SettingsSection>
)}
{/* App Info */}
<SettingsSection title="About">
<SettingsRow label="App Version" value={appVersion} />
<Divider />
<SettingsRow
label="Build"
value={(Constants.expoConfig?.extra?.buildNumber as string | undefined) ?? 'dev'}
/>
</SettingsSection>
{/* Loading indicator */}
{isAuthLoading && (
<View className="px-4 py-8 items-center">
<ActivityIndicator color={colors.cyan.core} />
</View>
)}
</ScrollView>
</SafeAreaView>
);
}