Initial Upload
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
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
This commit is contained in:
43
apps/mobile/app/settings/_layout.tsx
Normal file
43
apps/mobile/app/settings/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Settings stack navigator layout
|
||||
* Provides navigation between settings sub-screens
|
||||
*/
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { Pressable } from 'react-native';
|
||||
import { ChevronLeft } from 'lucide-react-native';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background.dark,
|
||||
},
|
||||
headerTintColor: colors.text.primary.dark,
|
||||
headerTitleStyle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerBackTitle: 'Back',
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||
<ChevronLeft size={28} color={colors.text.primary.dark} />
|
||||
</Pressable>
|
||||
),
|
||||
contentStyle: {
|
||||
backgroundColor: colors.background.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="notifications"
|
||||
options={{
|
||||
title: 'Notification Settings',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
538
apps/mobile/app/settings/notifications.tsx
Normal file
538
apps/mobile/app/settings/notifications.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Notification Settings Screen
|
||||
* Per-device push notification configuration
|
||||
*/
|
||||
import { View, ScrollView, Switch, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
ShieldAlert,
|
||||
Play,
|
||||
Square,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
AlertTriangle,
|
||||
ServerCrash,
|
||||
ServerCog,
|
||||
Moon,
|
||||
Flame,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/authStore';
|
||||
import { colors } from '@/lib/theme';
|
||||
import type { NotificationPreferences } from '@tracearr/shared';
|
||||
|
||||
// Rule types for violation filtering
|
||||
const RULE_TYPES = [
|
||||
{ value: 'impossible_travel', label: 'Impossible Travel' },
|
||||
{ value: 'simultaneous_locations', label: 'Simultaneous Locations' },
|
||||
{ value: 'device_velocity', label: 'Device Velocity' },
|
||||
{ value: 'concurrent_streams', label: 'Concurrent Streams' },
|
||||
{ value: 'geo_restriction', label: 'Geo Restriction' },
|
||||
] as const;
|
||||
|
||||
// Severity levels
|
||||
const SEVERITY_LEVELS = [
|
||||
{ value: 1, label: 'All (Low, Warning, High)' },
|
||||
{ value: 2, label: 'Warning & High only' },
|
||||
{ value: 3, label: 'High severity only' },
|
||||
] as const;
|
||||
|
||||
function Divider() {
|
||||
return <View className="h-px bg-border ml-4" />;
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View className="mb-6">
|
||||
<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 SettingRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-row justify-between items-center px-4 py-3 min-h-[52px]">
|
||||
<View className="flex-1 mr-4">
|
||||
<View className="flex-row items-center">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={18}
|
||||
color={disabled ? colors.text.muted.dark : colors.text.secondary.dark}
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
|
||||
</View>
|
||||
{description && (
|
||||
<Text className={cn('text-xs text-muted-foreground mt-0.5', Icon && 'ml-7', disabled && 'opacity-50')}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<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 SelectRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
options: ReadonlyArray<{ value: number; label: string }>;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const currentOption = options.find((o) => o.value === value);
|
||||
|
||||
const handlePress = () => {
|
||||
if (disabled) return;
|
||||
|
||||
Alert.alert(
|
||||
label,
|
||||
undefined,
|
||||
options.map((option) => ({
|
||||
text: option.label,
|
||||
onPress: () => onChange(option.value),
|
||||
style: option.value === value ? 'cancel' : 'default',
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
className={cn('px-4 py-3 min-h-[52px]', 'active:opacity-70')}
|
||||
>
|
||||
<Text className={cn('text-sm text-muted-foreground mb-1', disabled && 'opacity-50')}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text className={cn('text-base', disabled && 'opacity-50')}>
|
||||
{currentOption?.label ?? 'Select...'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectRow({
|
||||
selectedValues,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
selectedValues: string[];
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
onChange: (values: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toggleValue = (value: string) => {
|
||||
if (disabled) return;
|
||||
if (selectedValues.includes(value)) {
|
||||
onChange(selectedValues.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...selectedValues, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = selectedValues.length === 0;
|
||||
|
||||
return (
|
||||
<View className="px-4 py-3">
|
||||
<View className="flex-row flex-wrap gap-2.5">
|
||||
<Pressable
|
||||
onPress={() => onChange([])}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full border',
|
||||
allSelected
|
||||
? 'bg-cyan-core border-cyan-core'
|
||||
: 'border-border bg-card',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
'text-sm',
|
||||
allSelected ? 'text-background' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
All Types
|
||||
</Text>
|
||||
</Pressable>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => toggleValue(option.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full border',
|
||||
isSelected
|
||||
? 'bg-cyan-core border-cyan-core'
|
||||
: 'border-border bg-card',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isSelected ? 'text-background' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitStatus({
|
||||
remainingMinute,
|
||||
remainingHour,
|
||||
maxPerMinute,
|
||||
maxPerHour,
|
||||
}: {
|
||||
remainingMinute?: number;
|
||||
remainingHour?: number;
|
||||
maxPerMinute: number;
|
||||
maxPerHour: number;
|
||||
}) {
|
||||
return (
|
||||
<View className="px-4 py-3">
|
||||
<Text className="text-sm text-muted-foreground mb-2">Current Rate Limit Status</Text>
|
||||
<View className="flex-row gap-4">
|
||||
<View className="flex-1 p-3 rounded-lg bg-surface">
|
||||
<Text className="text-xs text-muted-foreground mb-1">Per Minute</Text>
|
||||
<Text className="text-lg font-semibold">
|
||||
{remainingMinute ?? maxPerMinute} / {maxPerMinute}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 p-3 rounded-lg bg-surface">
|
||||
<Text className="text-xs text-muted-foreground mb-1">Per Hour</Text>
|
||||
<Text className="text-lg font-semibold">
|
||||
{remainingHour ?? maxPerHour} / {maxPerHour}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationSettingsScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const { activeServerId } = useAuthStore();
|
||||
|
||||
// Fetch current preferences (per-device, not per-server)
|
||||
const {
|
||||
data: preferences,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['notifications', 'preferences'],
|
||||
queryFn: api.notifications.getPreferences,
|
||||
enabled: !!activeServerId, // Still need auth
|
||||
});
|
||||
|
||||
// Update mutation with optimistic updates
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: api.notifications.updatePreferences,
|
||||
onMutate: async (newData) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['notifications', 'preferences'] });
|
||||
const previousData = queryClient.getQueryData<NotificationPreferences>([
|
||||
'notifications',
|
||||
'preferences',
|
||||
]);
|
||||
queryClient.setQueryData(['notifications', 'preferences'], (old: NotificationPreferences | undefined) =>
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Test notification mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: api.notifications.sendTest,
|
||||
onSuccess: (result) => {
|
||||
Alert.alert(
|
||||
result.success ? 'Test Sent' : 'Test Failed',
|
||||
result.message
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
Alert.alert('Error', error.message || 'Failed to send test notification');
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdate = (
|
||||
key: keyof Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>,
|
||||
value: boolean | number | string[]
|
||||
) => {
|
||||
updateMutation.mutate({ [key]: value });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color={colors.cyan.core} />
|
||||
<Text className="mt-4 text-muted-foreground">Loading preferences...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !preferences) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-xl font-semibold text-center mb-2">
|
||||
Unable to Load Preferences
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-center">
|
||||
{error instanceof Error ? error.message : 'An error occurred'}
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const pushEnabled = preferences.pushEnabled;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||
<ScrollView className="flex-1" contentContainerClassName="p-4">
|
||||
{/* Master Toggle */}
|
||||
<SettingsSection title="Push Notifications">
|
||||
<SettingRow
|
||||
icon={Bell}
|
||||
label="Enable Push Notifications"
|
||||
description="Receive alerts on this device"
|
||||
value={pushEnabled}
|
||||
onValueChange={(v) => handleUpdate('pushEnabled', v)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Event Toggles */}
|
||||
<SettingsSection title="Notification Events">
|
||||
<SettingRow
|
||||
icon={ShieldAlert}
|
||||
label="Violation Detected"
|
||||
description="Rule violation triggered"
|
||||
value={preferences.onViolationDetected}
|
||||
onValueChange={(v) => handleUpdate('onViolationDetected', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={Play}
|
||||
label="Stream Started"
|
||||
description="New playback began"
|
||||
value={preferences.onStreamStarted}
|
||||
onValueChange={(v) => handleUpdate('onStreamStarted', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={Square}
|
||||
label="Stream Stopped"
|
||||
description="Playback ended"
|
||||
value={preferences.onStreamStopped}
|
||||
onValueChange={(v) => handleUpdate('onStreamStopped', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={Monitor}
|
||||
label="Concurrent Streams"
|
||||
description="User exceeded stream limit"
|
||||
value={preferences.onConcurrentStreams}
|
||||
onValueChange={(v) => handleUpdate('onConcurrentStreams', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={Smartphone}
|
||||
label="New Device"
|
||||
description="New device detected for user"
|
||||
value={preferences.onNewDevice}
|
||||
onValueChange={(v) => handleUpdate('onNewDevice', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={AlertTriangle}
|
||||
label="Trust Score Changed"
|
||||
description="User trust score degraded"
|
||||
value={preferences.onTrustScoreChanged}
|
||||
onValueChange={(v) => handleUpdate('onTrustScoreChanged', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={ServerCrash}
|
||||
label="Server Down"
|
||||
description="Media server unreachable"
|
||||
value={preferences.onServerDown}
|
||||
onValueChange={(v) => handleUpdate('onServerDown', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={ServerCog}
|
||||
label="Server Up"
|
||||
description="Media server back online"
|
||||
value={preferences.onServerUp}
|
||||
onValueChange={(v) => handleUpdate('onServerUp', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Violation Filters - Only show if violation notifications are enabled */}
|
||||
{pushEnabled && preferences.onViolationDetected && (
|
||||
<SettingsSection title="Violation Filters">
|
||||
<MultiSelectRow
|
||||
selectedValues={preferences.violationRuleTypes}
|
||||
options={RULE_TYPES}
|
||||
onChange={(values) => handleUpdate('violationRuleTypes', values)}
|
||||
/>
|
||||
<Divider />
|
||||
<SelectRow
|
||||
label="Minimum Severity"
|
||||
value={preferences.violationMinSeverity}
|
||||
options={SEVERITY_LEVELS}
|
||||
onChange={(value) => handleUpdate('violationMinSeverity', value)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<SettingsSection title="Quiet Hours">
|
||||
<SettingRow
|
||||
icon={Moon}
|
||||
label="Enable Quiet Hours"
|
||||
description="Pause non-critical notifications during set hours"
|
||||
value={preferences.quietHoursEnabled}
|
||||
onValueChange={(v) => handleUpdate('quietHoursEnabled', v)}
|
||||
disabled={!pushEnabled}
|
||||
/>
|
||||
{pushEnabled && preferences.quietHoursEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<View className="px-4 py-3">
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Start Time</Text>
|
||||
<Text className="text-base">{preferences.quietHoursStart ?? '23:00'}</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground mx-4">to</Text>
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">End Time</Text>
|
||||
<Text className="text-base">{preferences.quietHoursEnd ?? '08:00'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-xs text-muted-foreground mt-2">
|
||||
Timezone: {preferences.quietHoursTimezone || 'UTC'}
|
||||
</Text>
|
||||
</View>
|
||||
<Divider />
|
||||
<SettingRow
|
||||
icon={Flame}
|
||||
label="Override for Critical"
|
||||
description="High-severity violations still notify during quiet hours"
|
||||
value={preferences.quietHoursOverrideCritical}
|
||||
onValueChange={(v) => handleUpdate('quietHoursOverrideCritical', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<SettingsSection title="Rate Limiting">
|
||||
<RateLimitStatus
|
||||
remainingMinute={preferences.rateLimitStatus?.remainingMinute}
|
||||
remainingHour={preferences.rateLimitStatus?.remainingHour}
|
||||
maxPerMinute={preferences.maxPerMinute}
|
||||
maxPerHour={preferences.maxPerHour}
|
||||
/>
|
||||
<Divider />
|
||||
<View className="px-4 py-2">
|
||||
<Text className="text-xs text-muted-foreground leading-4">
|
||||
Rate limits prevent notification spam. Current limits: {preferences.maxPerMinute}/min, {preferences.maxPerHour}/hour.
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Test Notification */}
|
||||
<View className="mt-2 mb-4">
|
||||
<Button
|
||||
onPress={() => testMutation.mutate()}
|
||||
disabled={!pushEnabled || testMutation.isPending}
|
||||
className={cn(!pushEnabled && 'opacity-50')}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={colors.background.dark} />
|
||||
) : (
|
||||
<Text className="text-background font-semibold">Send Test Notification</Text>
|
||||
)}
|
||||
</Button>
|
||||
<Text className="text-xs text-muted-foreground text-center mt-2">
|
||||
Verify that push notifications are working correctly
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user