Files
frontend/src/screens/SentinelScreen.tsx
2026-01-31 21:21:55 -08:00

589 lines
18 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
SafeAreaView,
Animated,
Modal,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { SystemStatus, KillSwitchLog } from '../types';
import VaultScreen from './VaultScreen';
// Animation timing constants
const ANIMATION_DURATION = {
pulse: 1200,
glow: 1500,
rotate: 30000,
heartbeatPress: 150,
} as const;
// Icon names type for type safety
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
// Status configuration with nautical theme
const statusConfig: Record<SystemStatus, {
color: string;
label: string;
icon: StatusIconName;
description: string;
gradientColors: [string, string];
}> = {
normal: {
color: colors.sentinel.statusNormal,
label: 'ALL CLEAR',
icon: 'checkmark-circle',
description: 'The lighthouse burns bright. All systems nominal.',
gradientColors: ['#6BBF8A', '#4A9F6A'],
},
warning: {
color: colors.sentinel.statusWarning,
label: 'STORM WARNING',
icon: 'warning',
description: 'Anomaly detected. Captain\'s attention required.',
gradientColors: ['#E5B873', '#C99953'],
},
releasing: {
color: colors.sentinel.statusCritical,
label: 'RELEASE ACTIVE',
icon: 'alert-circle',
description: 'Legacy release protocol initiated.',
gradientColors: ['#E57373', '#C55353'],
},
};
// Mock data
const initialLogs: KillSwitchLog[] = [
{ id: '1', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-18T09:30:00') },
{ id: '2', action: 'SUBSCRIPTION_VERIFIED', timestamp: new Date('2024-01-17T00:00:00') },
{ id: '3', action: 'JOURNAL_ACTIVITY', timestamp: new Date('2024-01-16T15:42:00') },
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20:00') },
];
export default function SentinelScreen() {
const [status, setStatus] = useState<SystemStatus>('normal');
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
const [lastFlowActivity] = useState(new Date('2024-01-18T10:30:00'));
const [logs, setLogs] = useState<KillSwitchLog[]>(initialLogs);
const [pulseAnim] = useState(new Animated.Value(1));
const [glowAnim] = useState(new Animated.Value(0.5));
const [rotateAnim] = useState(new Animated.Value(0));
const [showVault, setShowVault] = useState(false);
useEffect(() => {
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: ANIMATION_DURATION.pulse,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: ANIMATION_DURATION.pulse,
useNativeDriver: true,
}),
])
);
pulseAnimation.start();
const glowAnimation = Animated.loop(
Animated.sequence([
Animated.timing(glowAnim, {
toValue: 1,
duration: ANIMATION_DURATION.glow,
useNativeDriver: true,
}),
Animated.timing(glowAnim, {
toValue: 0.5,
duration: ANIMATION_DURATION.glow,
useNativeDriver: true,
}),
])
);
glowAnimation.start();
const rotateAnimation = Animated.loop(
Animated.timing(rotateAnim, {
toValue: 1,
duration: ANIMATION_DURATION.rotate,
useNativeDriver: true,
})
);
rotateAnimation.start();
return () => {
pulseAnimation.stop();
glowAnimation.stop();
rotateAnimation.stop();
};
}, [pulseAnim, glowAnim, rotateAnim]);
const openVault = () => setShowVault(true);
const handleHeartbeat = () => {
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.15,
duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true,
}),
]).start();
const newLog: KillSwitchLog = {
id: Date.now().toString(),
action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date(),
};
setLogs((prevLogs) => [newLog, ...prevLogs]);
if (status === 'warning') {
setStatus('normal');
}
};
const formatDateTime = (date: Date) =>
date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) return `${Math.floor(hours / 24)} days ago`;
if (hours > 0) return `${hours}h ${minutes}m ago`;
return `${minutes}m ago`;
};
const currentStatus = statusConfig[status];
const spin = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
return (
<View style={styles.container}>
<LinearGradient
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerTitleRow}>
<FontAwesome5 name="anchor" size={24} color={colors.sentinel.primary} />
<Text style={styles.title}>LIGHTHOUSE</Text>
</View>
<Text style={styles.subtitle}>The Watchful Guardian</Text>
</View>
{/* Status Display */}
<View style={styles.statusContainer}>
<Animated.View
style={[
styles.statusCircleOuter,
{
transform: [{ scale: pulseAnim }],
opacity: glowAnim,
backgroundColor: `${currentStatus.color}20`,
},
]}
/>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<LinearGradient
colors={currentStatus.gradientColors}
style={styles.statusCircle}
>
<Ionicons name={currentStatus.icon} size={56} color="#fff" />
</LinearGradient>
</Animated.View>
<Text style={[styles.statusLabel, { color: currentStatus.color }]}>
{currentStatus.label}
</Text>
<Text style={styles.statusDescription}>
{currentStatus.description}
</Text>
</View>
{/* Ship Wheel Watermark */}
<View style={styles.wheelWatermark}>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<MaterialCommunityIcons
name="ship-wheel"
size={200}
color={colors.sentinel.primary}
style={{ opacity: 0.03 }}
/>
</Animated.View>
</View>
{/* Metrics Grid */}
<View style={styles.metricsGrid}>
<View style={styles.metricCard}>
<View style={styles.metricIconContainer}>
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
</View>
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
<Text style={styles.metricValue}>{formatTimeAgo(lastSubscriptionCheck)}</Text>
<Text style={styles.metricTime}>{formatDateTime(lastSubscriptionCheck)}</Text>
</View>
<View style={styles.metricCard}>
<View style={styles.metricIconContainer}>
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
</View>
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
<Text style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
</View>
</View>
{/* Shadow Vault Access */}
<View style={styles.vaultAccessCard}>
<View style={styles.vaultAccessIcon}>
<MaterialCommunityIcons name="treasure-chest" size={22} color={colors.nautical.teal} />
</View>
<View style={styles.vaultAccessContent}>
<Text style={styles.vaultAccessTitle}>Shadow Vault</Text>
<Text style={styles.vaultAccessText}>
Access sealed assets from the Lighthouse.
</Text>
</View>
<TouchableOpacity
style={styles.vaultAccessButton}
onPress={openVault}
activeOpacity={0.8}
accessibilityLabel="Open Shadow Vault"
accessibilityRole="button"
>
<Text style={styles.vaultAccessButtonText}>Open</Text>
</TouchableOpacity>
</View>
{/* Heartbeat Button */}
<TouchableOpacity
style={styles.heartbeatButton}
onPress={handleHeartbeat}
activeOpacity={0.9}
accessibilityLabel="Signal the watch - Confirm your presence"
accessibilityRole="button"
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.heartbeatGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<View style={styles.heartbeatContent}>
<MaterialCommunityIcons name="lighthouse" size={32} color="#fff" />
<View>
<Text style={styles.heartbeatText}>SIGNAL THE WATCH</Text>
<Text style={styles.heartbeatSubtext}>Confirm your presence, Captain</Text>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
{/* Watch Log */}
<View style={styles.logsSection}>
<View style={styles.logsSectionHeader}>
<Feather name="activity" size={18} color={colors.sentinel.primary} />
<Text style={styles.logsSectionTitle}>WATCH LOG</Text>
</View>
{logs.map((log) => (
<View key={log.id} style={styles.logItem}>
<View style={styles.logDot} />
<View style={styles.logContent}>
<Text style={styles.logAction}>{log.action}</Text>
<Text style={styles.logTime}>{formatDateTime(log.timestamp)}</Text>
</View>
</View>
))}
</View>
</ScrollView>
</SafeAreaView>
</LinearGradient>
{/* Vault Modal */}
<Modal
visible={showVault}
animationType="slide"
onRequestClose={() => setShowVault(false)}
>
<View style={styles.vaultModalContainer}>
{showVault ? <VaultScreen /> : null}
<TouchableOpacity
style={styles.vaultCloseButton}
onPress={() => setShowVault(false)}
activeOpacity={0.85}
accessibilityLabel="Close vault"
accessibilityRole="button"
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
gradient: { flex: 1 },
safeArea: { flex: 1 },
scrollView: { flex: 1 },
scrollContent: {
padding: spacing.lg,
paddingBottom: 120,
},
header: { marginBottom: spacing.xl },
headerTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
title: {
fontSize: typography.fontSize.xl,
fontWeight: '700',
color: colors.sentinel.text,
letterSpacing: typography.letterSpacing.widest,
fontFamily: typography.fontFamily.serif,
},
subtitle: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.textSecondary,
marginLeft: spacing.xl + spacing.sm,
fontStyle: 'italic',
},
statusContainer: {
alignItems: 'center',
paddingVertical: spacing.xl,
marginBottom: spacing.lg,
position: 'relative',
},
statusCircleOuter: {
position: 'absolute',
width: 170,
height: 170,
borderRadius: 85,
},
statusCircle: {
width: 140,
height: 140,
borderRadius: 70,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
...shadows.glow,
},
statusLabel: {
fontSize: typography.fontSize.xl,
fontWeight: '700',
letterSpacing: typography.letterSpacing.widest,
marginBottom: spacing.sm,
},
statusDescription: {
fontSize: typography.fontSize.base,
color: colors.sentinel.textSecondary,
textAlign: 'center',
maxWidth: 280,
fontStyle: 'italic',
},
wheelWatermark: {
position: 'absolute',
top: 200,
right: -60,
opacity: 0.5,
},
metricsGrid: {
flexDirection: 'row',
gap: spacing.md,
marginBottom: spacing.lg,
},
metricCard: {
flex: 1,
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
metricIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: `${colors.sentinel.primary}15`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
metricLabel: {
fontSize: typography.fontSize.xs,
color: colors.sentinel.textSecondary,
letterSpacing: typography.letterSpacing.wide,
marginBottom: spacing.xs,
fontWeight: '600',
},
metricValue: {
fontSize: typography.fontSize.md,
color: colors.sentinel.text,
fontWeight: '700',
marginBottom: spacing.xs,
},
metricTime: {
fontSize: typography.fontSize.xs,
color: colors.sentinel.textSecondary,
fontFamily: typography.fontFamily.mono,
},
heartbeatButton: {
borderRadius: borderRadius.xl,
overflow: 'hidden',
marginBottom: spacing.xl,
...shadows.medium,
},
heartbeatGradient: { padding: spacing.lg },
heartbeatContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.md,
},
heartbeatText: {
fontSize: typography.fontSize.lg,
fontWeight: '700',
color: '#fff',
letterSpacing: typography.letterSpacing.wider,
},
heartbeatSubtext: {
fontSize: typography.fontSize.sm,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
fontStyle: 'italic',
},
logsSection: {
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
logsSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.sentinel.cardBorder,
},
logsSectionTitle: {
fontSize: typography.fontSize.xs,
color: colors.sentinel.textSecondary,
letterSpacing: typography.letterSpacing.widest,
fontWeight: '700',
},
logItem: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: spacing.sm,
},
logDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.sentinel.primary,
marginTop: 6,
marginRight: spacing.md,
},
logContent: { flex: 1 },
logAction: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.text,
fontFamily: typography.fontFamily.mono,
fontWeight: '500',
marginBottom: 2,
},
logTime: {
fontSize: typography.fontSize.xs,
color: colors.sentinel.textSecondary,
fontFamily: typography.fontFamily.mono,
},
vaultAccessCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
vaultAccessIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `${colors.nautical.teal}20`,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
vaultAccessContent: { flex: 1 },
vaultAccessTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.sentinel.text,
marginBottom: 2,
},
vaultAccessText: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.textSecondary,
},
vaultAccessButton: {
backgroundColor: colors.nautical.teal,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
},
vaultAccessButtonText: {
color: colors.nautical.cream,
fontWeight: '700',
fontSize: typography.fontSize.sm,
},
vaultModalContainer: {
flex: 1,
backgroundColor: colors.vault.background,
},
vaultCloseButton: {
position: 'absolute',
top: spacing.xl + spacing.lg,
right: spacing.lg,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(26, 58, 74, 0.65)',
alignItems: 'center',
justifyContent: 'center',
},
});