chahinebrini 8a6ab6fe64 feat(native/blocker): unified slot bar + single + button + auto-detect sheet
Single shared affordance for adding either a website-domain or a mail-
sender-domain. The per-section add buttons (one inside "Eigene Domains"
and one inside "Eigene Mails") are gone — replaced by a CustomFilter-
Overview card above both sections with:

- title "Eigene Filter" and a "X von 20" counter (free/pro: 10, legend:
  20 — sum of the two per-type buckets)
- a 2-colour progress pill: brandOrange for the web slice, success-green
  for the mail slice on top of the surface-elevated rest
- a 48×48 rounded-full TouchableOpacity on the right (brandOrange,
  ionicons add 24px, white) that opens the AddDomainSheet directly

AddDomainSheet was rewritten one more time: the Seite / E-Mail type
picker is gone. The user types one thing — domain or full address —
and a live preview shows which one we detected (Domain-Filter for a
bare host, Mail-Filter for input that contains "@", stripping to the
domain after the last @). The shape is also what we send: the body is
{ pattern } with no kind field. The backend (commit a2680f6) does the
authoritative auto-detect and sends back the resolved type with the
created row, so the frontend never has to guess in two places.

useCustomDomains.addDomain now treats kind as optional. When omitted,
the request body just carries pattern — when present it's still sent
through verbatim so any caller that wants to force a category still can.

DomainSection no longer renders a per-section add button when its onAdd
prop is undefined — domains and mails sections in blocker.tsx both
omit onAdd now. The mails section stays default-collapsed.

i18n: new keys custom_filter_overview_title / count + preview_web /
preview_mail / preview_invalid; tabs_web / tabs_mail removed since the
TypePicker is gone. type_web / type_mail kept in the locales as
inactive entries in case the type-picker comes back in a future
direct-add flow.
2026-05-16 02:54:38 +02:00

678 lines
23 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Animated, 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 { 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, 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,
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 urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
const lockedIn = appDeletionLockActive;
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]);
// ─── 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 {
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') {
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;
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 (
<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}
/>
{FAMILY_CONTROLS_AVAILABLE ? (
<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')}
/>
) : (
<View
style={{
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 16,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="lock-closed-outline" size={20} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.layers_app_lock_title')}
</Text>
<View
style={{
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.app_lock_coming_soon_badge')}
</Text>
</View>
</View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{t('blocker.app_lock_coming_soon_desc')}
</Text>
</View>
</View>
)}
</View>
)}
{/* CooldownBanner */}
{state.cooldown.active && (
<CooldownBanner
remainingFormatted={cooldownRemainingFormatted}
onCancel={handleCancelCooldown}
/>
)}
{/* Free: Erwartungs-Transparenz-Hinweis */}
{plan === 'free' && (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: colors.border,
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
}}
>
<Ionicons name="shield-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 17 }}>
{t('plan_limit.blocker_basic_protection')}
</Text>
</View>
)}
{/* Über-Limit Banner */}
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
<View
style={{
backgroundColor: 'rgba(217,119,6,0.08)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(217,119,6,0.2)',
gap: 4,
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#d97706' }}>
{t('plan_limit.blocker_domain_over_limit', {
used: tier.usedSlots,
plan: plan.charAt(0).toUpperCase() + plan.slice(1),
max: tier.domainLimit,
})}
</Text>
</View>
)}
{/* 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) => {
const result = await addDomain(pattern);
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}
/>
</>
) : 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,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
}}
>
{/* Left: title + counter + bar */}
<View style={{ flex: 1, gap: 6 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.custom_filter_overview_title')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.custom_filter_overview_count', { count: total, max })}
</Text>
{/* Split progress bar */}
<View
style={{
height: 7,
borderRadius: 999,
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>
{/* Legend dots */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, 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: 8, height: 8, borderRadius: 4, backgroundColor: colors.success }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{mailCount} Mail
</Text>
</View>
</View>
</View>
{/* Right: add button */}
<TouchableOpacity
onPress={onAddPress}
activeOpacity={0.85}
style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="add" size={26} color="#fff" />
</TouchableOpacity>
</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>
);
}