import { useState, useCallback, useRef } from 'react' import { View, Text, StyleSheet, Pressable, ScrollView, ActivityIndicator, Platform } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronDown, ChevronUp, Activity, CheckCircle2, ScrollText, XCircle, AlertTriangle } from 'lucide-react-native' import { colors, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import { startDiagnosticFetchTimeout, type DiagnosticFetchTimeout } from '../src/diagnostics/diagnostic-fetch-timeout' import { formatEndpoint, testHostReachability, unreachableHostDetail } from '../src/diagnostics/host-reachability' import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' type DiagnosticStatus = 'idle' | 'running' | 'done' type CheckResult = { label: string status: 'pass' | 'fail' | 'warn' detail: string } function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': return case 'fail': return case 'warn': return } } export default function TroubleshootScreen() { const router = useRouter() const insets = useSafeAreaInsets() const [expandedId, setExpandedId] = useState(null) const [diagnosticStatus, setDiagnosticStatus] = useState('idle') const [checks, setChecks] = useState([]) const abortRef = useRef(false) const diagnosticRunRef = useRef(0) const activeInternetCheckRef = useRef(null) const setTroubleshootRootRef = useCallback((node: View | null): void => { if (node !== null) { return } // Why: diagnostics can outlive the screen; cancel the active run when the // route detaches without a passive cleanup-only Effect. abortRef.current = true diagnosticRunRef.current += 1 activeInternetCheckRef.current?.dispose() activeInternetCheckRef.current = null }, []) const toggleSection = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)) }, []) const runDiagnostics = useCallback(async () => { const runId = diagnosticRunRef.current + 1 diagnosticRunRef.current = runId abortRef.current = false activeInternetCheckRef.current?.dispose() activeInternetCheckRef.current = null setDiagnosticStatus('running') setChecks([]) const results: CheckResult[] = [] const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId try { const hosts = await loadHosts() results.push( hosts.length > 0 ? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` } : { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' } ) } catch { results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' }) } if (!isCurrentRun()) { return } setChecks([...results]) const internetCheck = startDiagnosticFetchTimeout(5000) activeInternetCheckRef.current = internetCheck try { const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', { signal: internetCheck.signal }) if (!isCurrentRun()) { return } results.push( resp.ok ? { label: 'Internet', status: 'pass', detail: 'Connected' } : { label: 'Internet', status: 'warn', detail: 'Unexpected response' } ) } catch { if (!isCurrentRun()) { return } results.push({ label: 'Internet', status: 'fail', detail: 'No connection' }) } finally { internetCheck.dispose() if (activeInternetCheckRef.current === internetCheck) { activeInternetCheckRef.current = null } } if (!isCurrentRun()) { return } setChecks([...results]) try { const hosts = await loadHosts() for (const host of hosts) { if (!isCurrentRun()) { return } const reachable = await testHostReachability(host.endpoint) if (!isCurrentRun()) { return } results.push({ label: host.name, status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` : unreachableHostDetail(host.endpoint) }) setChecks([...results]) } } catch { results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' }) } if (!isCurrentRun()) { return } results.push({ label: 'Platform', status: 'pass', detail: `${Platform.OS} ${Platform.Version ?? ''}` }) setChecks([...results]) setDiagnosticStatus('done') }, []) return ( router.back()}> Troubleshooting [ styles.diagnosticButton, pressed && styles.diagnosticButtonPressed, diagnosticStatus === 'running' && styles.diagnosticButtonDisabled ]} onPress={runDiagnostics} disabled={diagnosticStatus === 'running'} > {diagnosticStatus === 'running' ? ( ) : ( )} {diagnosticStatus === 'running' ? 'Running…' : diagnosticStatus === 'done' ? 'Run again' : 'Run diagnostics'} [ styles.diagnosticButton, pressed && styles.diagnosticButtonPressed ]} onPress={() => router.push('/connection-log')} > View connection log {checks.length > 0 && ( {checks.map((check, i) => ( {i > 0 && } {check.label} {check.detail} ))} )} Common issues {troubleshootCommonIssues.map((section, i) => ( {i > 0 && } [styles.accordionHeader, pressed && styles.rowPressed]} onPress={() => toggleSection(section.id)} > {section.icon} {section.title} {expandedId === section.id ? ( ) : ( )} {expandedId === section.id && ( {section.steps.map((step, j) => ( {step} ))} )} ))} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase, padding: spacing.lg }, topRow: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.lg }, backButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginRight: spacing.sm }, heading: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, scroll: { flex: 1 }, scrollContent: { paddingBottom: spacing.xl }, diagnosticButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, backgroundColor: colors.bgRaised, borderRadius: 10, paddingVertical: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg }, diagnosticButtonPressed: { opacity: 0.7 }, diagnosticButtonDisabled: { opacity: 0.5 }, diagnosticButtonLabel: { fontSize: typography.bodySize, fontWeight: '600', color: colors.textPrimary }, checkRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.sm + 2, paddingHorizontal: spacing.md + 2 }, checkLabel: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, checkDetail: { flex: 1, textAlign: 'right', fontSize: typography.metaSize, color: colors.textMuted }, checkDetailFail: { color: colors.statusRed }, sectionHeading: { fontSize: typography.metaSize, fontWeight: '600', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: spacing.sm, marginTop: spacing.sm, paddingHorizontal: spacing.xs }, section: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden', marginBottom: spacing.lg }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle, marginHorizontal: spacing.md }, rowPressed: { backgroundColor: colors.bgRaised }, accordionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2, paddingVertical: spacing.md, paddingHorizontal: spacing.md + 2 }, accordionTitle: { flex: 1, fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, accordionBody: { paddingHorizontal: spacing.md + 2, paddingBottom: spacing.md, gap: spacing.xs + 2 }, stepRow: { flexDirection: 'row', gap: spacing.sm }, bullet: { fontSize: typography.metaSize, color: colors.textMuted, lineHeight: 18 }, stepText: { flex: 1, fontSize: typography.metaSize, color: colors.textMuted, lineHeight: 18 } })