chahinebrini db0aa6d24e feat(native): Protection Onboarding v2 + Devices + ProtectionSlide
- ProtectionOnboardingSheet: Android a11y 2-step flow mit tamper-arm nach Return
- ProtectionDetailsSheet: cleanup, iOS/Android split, locked-state logic
- ProtectionSlide: neuer Onboarding-Slide für Protection-Intro
- _layout.tsx: reconcileVpn on app-foreground (Android VPN self-heal)
- devices.tsx: Two-device approval flow
- useProtectionState: applyCooldownDisableIfElapsed, forceDisable on cooldown-end
- iOS module Info.plist: bundle version bumps
- app.config.ts: minor config updates
- tmp/.deploy-runtimes: build-time metrics aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 04:30:20 +02:00

493 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
Animated,
ActivityIndicator,
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import type { ProtectionState } from '../../lib/protection';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { HalfDonut } from '../common/HalfDonut';
import { useBlockerStatsStore } from '../../stores/blockerStats';
type Props = {
visible: boolean;
state: ProtectionState;
/** True wenn Gerät MDM-managed ist — versteckt Cooldown-CTA, zeigt Trustee-Hinweis. */
mdmManaged?: boolean;
onClose: () => void;
onRequestDeactivation: () => void;
onTalkToLyra: () => void;
};
// Brand colors
const HERO_COLOR = '#f97316'; // orange-500 (counter accent)
const SEG_REVIEW = '#f59e0b';
const SEG_APPROVED = '#16a34a';
const SEG_REJECTED = '#ef4444';
export function ProtectionDetailsSheet({
visible,
state,
mdmManaged,
onClose,
onRequestDeactivation,
}: Props) {
const { t, i18n } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const stats = useBlockerStatsStore((s) => s.stats);
const loadingStats = useBlockerStatsStore((s) => s.loading);
const refreshStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale);
useEffect(() => {
if (!visible) return;
refreshStatsIfStale(90_000).catch(() => {});
}, [visible, refreshStatsIfStale]);
const globalCount = stats?.current ?? state.blocklistCount;
const weeklyAdded = stats?.weeklyAdded ?? 0;
const monthlyAdded = stats?.monthlyAdded ?? 0;
const myInReview = stats?.mySubmissions?.inReview ?? 0;
const myApproved = stats?.mySubmissions?.approved ?? 0;
const myRejected = stats?.mySubmissions?.rejected ?? 0;
const avgPerUser = stats?.avgPerUser ?? 0;
const avgWait = stats?.avgApprovalWaitDays ?? 0;
return (
<FormSheet
visible={visible}
onClose={onClose}
title={t('blocker.details_title')}
initialHeightPct={0.9}
minHeightPct={0.3}
navHeaderOffset={84}
safeAreaBottom={false}
>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 32, gap: 18 }}
showsVerticalScrollIndicator
>
{loadingStats && !stats ? (
<View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="#737373" />
</View>
) : null}
{/* HERO Globale geblockte Domains: Counter (slow, color) + 2 Delta-Badges */}
<View
style={{
padding: 20,
borderRadius: 16,
backgroundColor: '#0a0a0a',
gap: 14,
}}
>
<View>
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_700Bold', letterSpacing: 0.5, textTransform: 'uppercase' }}>
{t('blocker.kpi_global_label')}
</Text>
<AnimatedCounter
value={globalCount}
locale={localeTag}
durationMs={2400}
style={{ fontSize: 38, color: HERO_COLOR, fontFamily: 'Nunito_900Black', letterSpacing: -1, marginTop: 2 }}
/>
</View>
<View style={{ flexDirection: 'row', gap: 10 }}>
<DeltaBadge value={weeklyAdded} label={t('blocker.delta_week')} locale={localeTag} />
<DeltaBadge value={monthlyAdded} label={t('blocker.delta_month')} locale={localeTag} />
</View>
</View>
{/* SUBMISSIONS Half Donut mit center-number + center-legend */}
<View
style={{
padding: 18,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
gap: 8,
}}
>
<View>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t('blocker.kpi_submissions_title')}
</Text>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
{t('blocker.kpi_submissions_subtitle')}
</Text>
</View>
<HalfDonut
segments={[
{ value: myInReview, color: SEG_REVIEW },
{ value: myApproved, color: SEG_APPROVED },
{ value: myRejected, color: SEG_REJECTED },
]}
centerValue={myInReview + myApproved + myRejected}
centerLabel={t('blocker.kpi_my_submissions')}
/>
{/* Centered Legend */}
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 14,
marginTop: 4,
}}
>
<LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} />
<LegendItem color={SEG_APPROVED} label={t('blocker.status_approved')} value={myApproved} />
<LegendItem color={SEG_REJECTED} label={t('blocker.status_rejected')} value={myRejected} />
</View>
</View>
{/* AVG KPIs kleiner */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<KpiCard
icon="person-circle-outline"
label={t('blocker.kpi_avg_per_user')}
value={avgPerUser}
locale={localeTag}
decimals={1}
/>
<KpiCard
icon="time-outline"
label={t('blocker.kpi_avg_wait')}
value={avgWait}
locale={localeTag}
decimals={1}
suffix={t('blocker.kpi_days_suffix')}
/>
</View>
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
<View style={{ alignSelf: 'stretch', gap: 8, marginTop: 4 }}>
<View
style={{
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
}}
>
<Text
style={{
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
letterSpacing: 0.5,
textTransform: 'uppercase',
}}
>
{t('blocker.faq_heading')}
</Text>
<Ionicons name="help-circle-outline" size={18} color={colors.textMuted} />
</View>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<FaqItem
key={n}
question={t(`blocker.faq${n}_q`)}
answer={t(`blocker.faq${n}_a`)}
/>
))}
</View>
{mdmManaged ? (
/* MDM-Modus: Cooldown-Flow nicht möglich — Trustee-Hinweis statt Button */
<View
style={{
marginTop: 4,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1.5,
borderColor: colors.border,
backgroundColor: colors.surface,
gap: 8,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="shield-checkmark-outline" size={18} color={colors.textMuted} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.mdm_deactivate_title')}
</Text>
</View>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
{t('blocker.mdm_deactivate_body')}
</Text>
</View>
) : (
/* Normal-Modus: Cooldown-Flow */
<TouchableOpacity
onPress={onRequestDeactivation}
activeOpacity={0.75}
style={{ marginTop: 4 }}
>
<View style={{
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1.5,
borderColor: HERO_COLOR,
backgroundColor: colors.surface,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}>
<Ionicons name="lock-open-outline" size={18} color={HERO_COLOR} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
{t('blocker.more_info_title')}
</Text>
</View>
</TouchableOpacity>
)}
</ScrollView>
</FormSheet>
);
}
// ─── Animated Counter ──────────────────────────────────────────────────────
function AnimatedCounter({
value,
locale,
decimals = 0,
durationMs = 1200,
style,
}: {
value: number;
locale: string;
decimals?: number;
durationMs?: number;
style?: any;
}) {
const anim = useRef(new Animated.Value(0)).current;
const [display, setDisplay] = useState(0);
useEffect(() => {
anim.setValue(0);
const listener = anim.addListener(({ value: v }) => {
setDisplay(v * value);
});
Animated.timing(anim, {
toValue: 1,
duration: durationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => anim.removeListener(listener);
}, [value, anim, durationMs]);
const formatted = display.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return <Text style={style}>{formatted}</Text>;
}
// ─── Delta Badge (e.g. "+25 diese Woche ↗") ────────────────────────────────
function DeltaBadge({
value,
label,
locale,
}: {
value: number;
label: string;
locale: string;
}) {
const formatted = `+${value.toLocaleString(locale)}`;
return (
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
backgroundColor: '#1f1f1f',
borderWidth: 1,
borderColor: '#262626',
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(22,163,74,0.15)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="arrow-up" size={14} color="#22c55e" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 13, color: '#fff', fontFamily: 'Nunito_900Black', letterSpacing: -0.3 }}>
{formatted}
</Text>
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{label}
</Text>
</View>
</View>
);
}
// ─── KPI Card (small) ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
locale,
decimals = 0,
suffix,
}: {
icon: any;
label: string;
value: number;
locale: string;
decimals?: number;
suffix?: string;
}) {
const colors = useColors();
return (
<View
style={{
flex: 1,
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name={icon} size={14} color={colors.textMuted} />
<Text style={{ flex: 1, fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
{label}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 4 }}>
<AnimatedCounter
value={value}
locale={locale}
decimals={decimals}
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.3 }}
/>
{suffix ? (
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
) : null}
</View>
</View>
);
}
// ─── Legend Item (compact, centered row) ───────────────────────────────────
function LegendItem({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
const colors = useColors();
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{value}</Text>
</View>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{label}</Text>
</View>
);
}
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
function FaqItem({ question, answer }: { question: string; answer: string }) {
const colors = useColors();
const [open, setOpen] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(rotateAnim, {
toValue: open ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start();
}, [open, rotateAnim]);
const rotate = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '180deg'],
});
return (
<View
style={{
alignSelf: 'stretch',
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: colors.bg,
}}
>
<TouchableOpacity
onPress={() => setOpen((v) => !v)}
activeOpacity={0.75}
>
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 18 }}>
{question}
</Text>
</View>
<Animated.View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate }],
}}
>
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
</Animated.View>
</View>
</TouchableOpacity>
{open && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
{answer}
</Text>
</View>
)}
</View>
);
}