## Protection Pre-Explainer: External Pointer Vorher: Pulse-Ring absolute-positioniert IM Screenshot — Position musste per-locale fine-tuned werden weil Apple-Dialog-Höhe variiert (DE/EN/FR/AR haben unterschiedliche Text-Längen → Dialog hat verschiedene Höhen → Erlauben-Button rutscht). Jetzt: animierter Pfeil + Label-Pill UNTER dem Screenshot. Dimensions- agnostic, funktioniert in allen 4 Sprachen ohne Locale-spezifische Magie. - ScreenshotPointer komplett refactored: caret-up + bouncing pill mit Button-Label-Text (z.B. 'Tippe "Erlauben"' / 'Tap "Allow"' / etc.) - onboardingAssets.ts: getPointerPosition deprecated/entfernt - ProtectionSlide nutzt neue API mit buttonLabelKey - 4 Locales: dialog_button_allow + dialog_button_continue - tap_marker_hint refined (kein "roter Marker"-Ref mehr) ## i18n-aware Screenshots en/fr/ar Permission-Dialog-Screenshots zur Map ergänzt. Resolver fällt auf de zurück wenn andere Sprache fehlt. ## Dynamic Sizing ProtectionSlide nutzt useWindowDimensions: height: min(320, max(200, screenH * 0.32)) → passt auf iPhone SE (213px) bis Pro Max (320px capped) ohne Scroll. OnboardingShell ScrollView-Padding reduziert (16→12 top, 24→16 bottom). ProtectionSlide-Spacing tightened. ## Blocker: lockedIn Fix Bug: `lockedIn = appDeletionLockActive` ignorierte URL-Filter-State — wenn User nur FC aktivierte (ohne URL-Filter), zeigte App grünen "Schutz aktiv"-Banner obwohl URL-Filter aus war. Fix: lockedIn = urlFilter && appDeletionLock → Beide müssen wirklich aktiv sein für den grünen Banner. ## LayerSwitchCard: lockedHint Prop Optional Hint-Text der unter dem active Layer angezeigt wird, z.B. "System-gesperrt. Nur in iOS-Einstellungen → Bildschirmzeit → Verwaltung durch ReBreak deaktivierbar.". Wird für iOS App-Lock-Card genutzt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
676 lines
23 KiB
TypeScript
676 lines
23 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 { 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 } 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 [permissionDeniedOpen, setPermissionDeniedOpen] = 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.
|
|
const lockedIn = urlFilterActive && 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]);
|
|
|
|
// 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') {
|
|
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;
|
|
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')}
|
|
/>
|
|
) : (
|
|
<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')}
|
|
/>
|
|
)}
|
|
</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, 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;
|
|
}}
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|