chahinebrini 72cd195d36 feat(blocker): VIP-Domain-Sektion + Cooldown-Friction beim Entfernen
Neue VIP-Sektion in der Blocker-Page: eigene Web-Custom-Domains als Tiles mit
Zaehler (X/10 Pro, X/20 Legend), Hinzufuegen frei. Entfernen geht durch einen
3-Klick-Friction-Flow (RemoveDomainSheet, gespiegelt vom Deactivation-Muster) —
kein Block-Abbau im Craving-Moment. free-Tier-Zweig entfernt (kein Free mehr).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:12:09 +02:00

688 lines
24 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Animated, AppState, Platform, ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } 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 { VipDomainList } from '../../components/blocker/VipDomainList';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet';
import { ProtectionOffSheet } from '../../components/ProtectionOffSheet';
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, type ColorScheme } 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,
countsByType,
limits,
addDomain,
submitDomain,
removeDomain,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist } = useBlocklistSync();
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
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);
const [mailOpen, setMailOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
const [protectionOffOpen, setProtectionOffOpen] = useState(false);
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
// "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
// müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird.
// "lockedIn" normal = URL-Filter UND App-Lock aktiv. Wenn Family Controls
// build-seitig nicht verfügbar ist (Distribution-Entitlement pending), kann
// es keinen App-Lock geben → dann reicht der URL-Filter allein für "geschützt".
const lockedIn =
urlFilterActive && (appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist.
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();
});
}, [urlFilterActive, syncBlocklist, refresh]);
// Wenn User aus System-Settings zurückkommt (z.B. nach a11y-Aktivierung) → State neu laden.
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (next === 'active') refresh();
});
return () => sub.remove();
}, [refresh]);
// ─── Activate-Handler pro Layer ──────────────────────────────────────
async function handleActivateUrlFilter() {
try {
const result = await activateUrlFilter();
console.log('[blocker] activateUrlFilter:', result);
if (!result.enabled) {
// iOS-spezifisch: NEFilterErrorDomain code 5 = User hat „Nicht erlauben"
// im System-Dialog getippt → iOS cached den Denied-State. Special-Sheet
// statt rohem Alert (Recovery via removeFromPreferences + Settings-Deep-Link).
const isPermissionDenied =
Platform.OS === 'ios' &&
typeof result.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(result.error);
if (isPermissionDenied) {
setPermissionDeniedOpen(true);
return result;
}
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 {
const sync = await syncBlocklist();
console.log('[blocker] post-activate sync:', sync);
if (sync.ok) {
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` = a11y-Berechtigung fehlt noch → System-Settings wurden
// geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap).
if (!result.enabled && result.error !== 'accessibility_pending') {
// iOS: NSCocoaErrorDomain code 4099 = XPC-Communication-Failure zum
// FamilyControls-Daemon (häufig nach „Don't Allow" oder bei iOS-Auth-
// Daemon-State-Issue). Recovery-Sheet statt rohem Alert — mit Anleitung
// (Restart Device / Settings / App reinstall).
const isXpcFailure =
Platform.OS === 'ios' &&
typeof result.error === 'string' &&
/NSCocoaErrorDomain:\s*4099/i.test(result.error);
if (isXpcFailure) {
setFamilyControlsErrorOpen(true);
return result;
}
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'));
}
}
async function handleRemoveWebDomain(id: string) {
const result = await removeDomain(id);
if (result.ok) {
const sync = await syncBlocklist();
if (sync.ok) refresh();
}
}
const bypassAlertShownRef = useRef(false);
useEffect(() => {
if (state?.phase !== 'recoveringFromBypass') {
bypassAlertShownRef.current = false;
return;
}
if (bypassAlertShownRef.current) return;
bypassAlertShownRef.current = true;
setProtectionOffOpen(true);
}, [state?.phase]);
// ─── Render ──────────────────────────────────────────────────────────
return (
<View className="flex-1 bg-neutral-50">
<AppHeader />
{loading && !state ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color="#737373" />
</View>
) : state ? (
<>
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: tabBarHeight + 80,
gap: 14,
}}
showsVerticalScrollIndicator={false}
>
{/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */}
{lockedIn ? (
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
) : (
<View style={{ gap: 10 }}>
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
{Platform.OS === 'android' ? (
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
state.layers.accessibility === true
? t('blocker.layers_a11y_subtitle_active')
: t('blocker.layers_a11y_subtitle_inactive')
}
active={state.layers.accessibility === true}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
) : FAMILY_CONTROLS_AVAILABLE ? (
/* iOS App-Lock nur zeigen wenn das Family-Controls-Entitlement
im Build aktiv ist. Distribution-Builds ohne Apple-Approval
→ Card ausblenden statt ein sandbox-blockiertes Feature
anzubieten (NSCocoaErrorDomain:4099). */
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
appDeletionLockActive
? t('blocker.layers_app_lock_subtitle_active')
: t('blocker.layers_app_lock_subtitle_inactive')
}
active={appDeletionLockActive}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
lockedHint={t('blocker.layers_app_lock_locked_hint')}
/>
) : null}
</View>
)}
{/* CooldownBanner */}
{state.cooldown.active && (
<CooldownBanner
remainingFormatted={cooldownRemainingFormatted}
onCancel={handleCancelCooldown}
/>
)}
{/* VIP-Liste: Meine geblockten Seiten */}
<VipDomainList
domains={domains}
webCount={countsByType.web}
webLimit={limits.web}
globalBlocklistCount={state.blocklistCount}
onAddPress={() => setAddSheetOpen(true)}
onRemoveDomain={handleRemoveWebDomain}
colors={colors}
/>
{/* Custom-Filter-Slot-Übersicht */}
<CustomFilterOverview
webCount={countsByType.web}
mailCount={countsByType.mail}
webLimit={limits.web}
mailLimit={limits.mail}
onAddPress={() => setAddSheetOpen(true)}
colors={colors}
t={t}
/>
{/* Section 1: Eigene Domains */}
<DomainSection
title={t('blocker.section_domains')}
count={countsByType.web}
max={limits.web}
atLimit={countsByType.web >= limits.web}
>
<DomainGrid
domains={domains}
tier={tier}
kind="web"
onSubmit={submitDomain}
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
/>
</DomainSection>
{/* Section 2: Eigene Mails */}
<DomainSection
title={t('blocker.section_mails')}
count={countsByType.mail}
max={limits.mail}
collapsible
open={mailOpen}
onToggle={() => setMailOpen((v) => !v)}
atLimit={countsByType.mail >= limits.mail}
>
<DomainGrid
domains={domains}
tier={tier}
kind="mail"
onSubmit={submitDomain}
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
/>
</DomainSection>
</ScrollView>
{/* Sheets */}
<AddDomainSheet
visible={addSheetOpen}
tier={tier}
onClose={() => {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (pattern, kind) => {
const result = await addDomain(pattern, kind);
if (result.ok) {
const sync = await syncBlocklist();
if (sync.ok) refresh();
}
return result;
}}
/>
<ProtectionDetailsSheet
visible={detailsOpen}
state={state}
onClose={() => setDetailsOpen(false)}
onRequestDeactivation={fromDetailsToExplainer}
onTalkToLyra={deflectToLyra}
/>
<DeactivationExplainerSheet
visible={explainerOpen}
onClose={() => setExplainerOpen(false)}
onBreathe={deflectToBreathe}
onStartCooldown={handleStartCooldown}
/>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
await refresh();
}
return res;
}}
/>
<PermissionDeniedSheet
visible={familyControlsErrorOpen}
onClose={() => setFamilyControlsErrorOpen(false)}
variant="family_controls"
onRetry={async () => {
const res = await protection.activateFamilyControls();
if (res.enabled) {
await refresh();
}
return res;
}}
/>
<ProtectionOffSheet
visible={protectionOffOpen}
onClose={() => setProtectionOffOpen(false)}
onReactivate={() => handleActivateUrlFilter()}
/>
</>
) : null}
</View>
);
}
// ─── CustomFilterOverview ─────────────────────────────────────────────────────
function CustomFilterOverview({
webCount,
mailCount,
webLimit,
mailLimit,
onAddPress,
colors,
t,
}: {
webCount: number;
mailCount: number;
webLimit: number;
mailLimit: number;
onAddPress: () => void;
colors: ColorScheme;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const total = webCount + mailCount;
const max = webLimit + mailLimit;
const webFillAnim = useRef(new Animated.Value(0)).current;
const mailFillAnim = useRef(new Animated.Value(0)).current;
const webRatio = max > 0 ? Math.min(webCount / max, 1) : 0;
const mailRatio = max > 0 ? Math.min(mailCount / max, 1) : 0;
useEffect(() => {
Animated.parallel([
Animated.timing(webFillAnim, { toValue: webRatio, duration: 380, useNativeDriver: false }),
Animated.timing(mailFillAnim, { toValue: mailRatio, duration: 380, useNativeDriver: false }),
]).start();
}, [webRatio, mailRatio]);
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,
}}
>
{/* Top row: title + legend on left, count badge + add button on right */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.custom_filter_overview_title')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: colors.brandOrange }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{webCount} Web
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: colors.success }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{mailCount} Mail
</Text>
</View>
<View style={{ flex: 1 }} />
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
}}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.custom_filter_overview_count', { count: total, max })}
</Text>
</View>
<TouchableOpacity
onPress={onAddPress}
activeOpacity={0.85}
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="add" size={18} color="#fff" />
</TouchableOpacity>
</View>
{/* Split progress bar — same height/pattern as DomainSection */}
<View
style={{
height: 5,
borderRadius: 3,
backgroundColor: colors.surfaceElevated,
flexDirection: 'row',
overflow: 'hidden',
}}
>
<Animated.View
style={{
height: '100%',
backgroundColor: colors.brandOrange,
width: webFillAnim.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }),
}}
/>
<Animated.View
style={{
height: '100%',
backgroundColor: colors.success,
width: mailFillAnim.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }),
}}
/>
</View>
</View>
);
}
// ─── DomainSection ────────────────────────────────────────────────────────────
function DomainSection({
title,
count,
max,
collapsible = false,
open = true,
onToggle,
atLimit,
children,
}: {
title: string;
count: number;
max: number;
collapsible?: boolean;
open?: boolean;
onToggle?: () => void;
atLimit: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const colors = useColors();
const fillAnim = useRef(new Animated.Value(0)).current;
const ratio = max > 0 ? Math.min(count / max, 1) : 0;
useEffect(() => {
Animated.timing(fillAnim, {
toValue: ratio,
duration: 380,
useNativeDriver: false,
}).start();
}, [ratio]);
const pct = ratio * 100;
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
const badgeBg = atLimit ? '#fee2e2' : colors.surfaceElevated;
const badgeFg = atLimit ? '#dc2626' : colors.textMuted;
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
}}
>
{/* Section Header */}
<TouchableOpacity
onPress={collapsible ? onToggle : undefined}
activeOpacity={collapsible ? 0.7 : 1}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
gap: 8,
}}
>
<Text style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{title}
</Text>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: badgeBg,
}}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: badgeFg }}>
{t('blocker.count_label', { count, max })}
</Text>
</View>
{collapsible && (
<Ionicons
name={open ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.textMuted}
/>
)}
</TouchableOpacity>
{(!collapsible || open) && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 12 }}>
{/* Progressbar */}
<View
style={{
height: 5,
borderRadius: 3,
backgroundColor: colors.surfaceElevated,
overflow: 'hidden',
}}
>
<Animated.View
style={{
height: '100%',
borderRadius: 3,
backgroundColor: barColor,
width: fillAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
}),
}}
/>
</View>
{/* Grid */}
{children}
</View>
)}
</View>
);
}