import { useCallback, useEffect, useRef, useState } from 'react'; import { ScrollView, Text, View, Alert, ActivityIndicator } from 'react-native'; import { useRouter } from 'expo-router'; import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; import { useTranslation } from 'react-i18next'; import { Ionicons } from '@expo/vector-icons'; import { AppHeader } from '../../components/AppHeader'; import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard'; import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard'; import { CooldownBanner } from '../../components/blocker/CooldownBanner'; import { DomainGrid } from '../../components/blocker/DomainGrid'; import { AddDomainSheet } from '../../components/blocker/AddDomainSheet'; import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet'; import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet'; import { useProtectionState } from '../../hooks/useProtectionState'; import { useCustomDomains } from '../../hooks/useCustomDomains'; import { useBlocklistSync } from '../../hooks/useBlocklistSync'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; import { useColors } from '../../lib/theme'; export default function BlockerScreen() { const router = useRouter(); const { t } = useTranslation(); const colors = useColors(); // react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View // erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding // hinterm Tab-Bar verschwinden. const tabBarHeight = useBottomTabBarHeight(); const { state, loading, cooldownRemainingFormatted, refresh, activateUrlFilter, activateFamilyControls, requestDeactivation, cancelDeactivation, } = useProtectionState(); const plan = state?.plan ?? 'free'; const { domains, tier, addDomain, submitDomain, refresh: refreshDomains, } = useCustomDomains(plan); const { sync: syncBlocklist } = useBlocklistSync(); // Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen. // Bei domain_rejected wird die Row backend-seitig hard-deleted → refetch // entfernt sie aus der Liste. Zusätzlich blocklist.bin neu syncen damit // die lokale Hash-Liste nicht aus dem Tritt gerät. const onDomainChange = useCallback(async () => { await refreshDomains(); if (urlFilterActiveRef.current) { const sync = await syncBlocklist(); console.log('[blocker] resync after domain change:', sync); await refresh(); } }, [refreshDomains, syncBlocklist, refresh]); useDomainSubmissionRealtime(onDomainChange, true); // Sheet-States const [addSheetOpen, setAddSheetOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [explainerOpen, setExplainerOpen] = useState(false); // Layer-Status (auf iOS): urlFilter + familyControls. // AppDeletionLock=true bedeutet "locked in" → keine Switches mehr, nur Cooldown-Pfad. const urlFilterActive = state?.layers.urlFilter === true; const familyControlsActive = state?.layers.familyControls === true; const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true; const lockedIn = appDeletionLockActive; // Ref damit onDomainChange nicht neu rendert bei jedem urlFilter-Toggle const urlFilterActiveRef = useRef(urlFilterActive); useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); // Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist und // blocklist.bin leer/stale sein könnte. Dedupe via Ref damit wir nicht // bei jedem Re-Render neu syncen. const syncedOnceRef = useRef(false); useEffect(() => { if (!urlFilterActive) return; if (syncedOnceRef.current) return; syncedOnceRef.current = true; syncBlocklist().then((res) => { console.log('[blocker] auto-sync on mount:', res); if (res.ok) refresh(); // Stats-Card neu rendern mit aktuellem Count }); }, [urlFilterActive, syncBlocklist, refresh]); // ─── Activate-Handler pro Layer ────────────────────────────────────── async function handleActivateUrlFilter() { try { const result = await activateUrlFilter(); console.log('[blocker] activateUrlFilter:', result); if (!result.enabled) { Alert.alert( t('blocker.activate_url_failed_title'), result.error ?? t('blocker.activate_url_failed_msg'), [ { text: t('common.ok') }, { text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() }, ], ); } else { // Filter ist aktiv aber blocklist.bin ist initial leer — sofort syncen! // Sonst zeigt iOS "Läuft" aber blockt nichts. const sync = await syncBlocklist(); console.log('[blocker] post-activate sync:', sync); if (sync.ok) { // Stats-Card neu rendern mit dem frisch geschriebenen Count await refresh(); } else { Alert.alert( t('blocker.sync_list_failed_title'), sync.error ?? t('blocker.sync_list_failed_msg'), ); } } return result; } catch (e: any) { console.error('[blocker] activateUrlFilter threw:', e); Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); return { enabled: false }; } } async function handleActivateFamilyControls() { try { const result = await activateFamilyControls(); console.log('[blocker] activateFamilyControls:', result); // `accessibility_pending` = die a11y-Berechtigung fehlt noch und wir haben // grad die System-Settings geöffnet → das IST das Feedback. Kein Fehler- // Modal (sonst Modal-Loop bei jedem Tap). a11y wird nur beim ersten // Einrichten geholt; danach ist das hier ein 1-Tap-Arm ohne Dialog. if (!result.enabled && result.error !== 'accessibility_pending') { Alert.alert( t('blocker.activate_app_lock_failed_title'), result.error ?? t('blocker.activate_app_lock_failed_msg'), ); } return result; } catch (e: any) { console.error('[blocker] activateFamilyControls threw:', e); Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); } return { enabled: false }; } // ─── 3-Click Cooldown-Trigger ──────────────────────────────────────── function openDetails() { setDetailsOpen(true); } function fromDetailsToExplainer() { setDetailsOpen(false); setTimeout(() => setExplainerOpen(true), 250); } function deflectToLyra() { setDetailsOpen(false); setTimeout(() => router.push('/lyra' as any), 250); } function deflectToBreathe() { setExplainerOpen(false); setTimeout(() => router.push('/urge' as any), 250); } async function handleStartCooldown(reason: string) { await requestDeactivation(reason); } async function handleCancelCooldown() { try { await cancelDeactivation(); } catch (e: any) { Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed')); } } const bypassAlertShownRef = useRef(false); useEffect(() => { if (state?.phase !== 'recoveringFromBypass') { bypassAlertShownRef.current = false; return; } if (bypassAlertShownRef.current) return; bypassAlertShownRef.current = true; // Schutz-Filter ist aus, sollte aber an sein → Reaktivierung setzt NUR den // VPN/Filter wieder (kein a11y-Prompt — das passiert nur beim ersten Mal). Alert.alert( t('blocker.protection_off_title'), t('blocker.protection_off_message'), [ { text: t('common.ok'), style: 'cancel' }, { text: t('blocker.reactivate_btn'), onPress: () => { void handleActivateUrlFilter(); } }, ], ); }, [state?.phase, t]); // ─── Render ────────────────────────────────────────────────────────── return ( {loading && !state ? ( ) : state ? ( <> {/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */} {lockedIn ? ( ) : ( // FC nicht aktiv → User kann pro Layer einzeln togglen {FAMILY_CONTROLS_AVAILABLE ? ( ) : ( {t('blocker.layers_app_lock_title')} {t('blocker.app_lock_coming_soon_badge')} {t('blocker.app_lock_coming_soon_desc')} )} )} {/* CooldownBanner — nur wenn Cooldown läuft */} {state.cooldown.active && ( )} {/* Free: Erwartungs-Transparenz-Hinweis */} {plan === 'free' && ( {t('plan_limit.blocker_basic_protection')} )} {/* Über-Limit: Custom-Domain-Banner */} {tier.atLimit && tier.usedSlots > tier.domainLimit && ( {t('plan_limit.blocker_domain_over_limit', { used: tier.usedSlots, plan: plan.charAt(0).toUpperCase() + plan.slice(1), max: tier.domainLimit, })} )} {/* Domain Grid mit inline + Button neben SlotPill */} setAddSheetOpen(true)} onSubmit={submitDomain} onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))} /> {/* Sheets */} { setAddSheetOpen(false); refreshDomains(); }} onAdd={async (d) => { const result = await addDomain(d); if (result.ok) { // Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen const sync = await syncBlocklist(); if (sync.ok) refresh(); // Stats-Card mit neuem Count refreshen } return result; }} /> setDetailsOpen(false)} onRequestDeactivation={fromDetailsToExplainer} onTalkToLyra={deflectToLyra} /> setExplainerOpen(false)} onBreathe={deflectToBreathe} onStartCooldown={handleStartCooldown} /> ) : null} ); }