feat(native/protection): android a11y banner status + 2-step onboarding sheet
- Blocker banner: show real accessibility status on Android (active/inactive) instead of the iOS Family-Controls "bald verfügbar" fallback - AppState listener refreshes state when user returns from system settings - New ProtectionOnboardingSheet: enforced order VPN → a11y because once a11y is on it locks VPN settings access. Step 2 disabled until step 1 done. Skip is allowed; storage flag set only after both steps complete. - i18n: blocker.layers_a11y_subtitle_active/inactive + protection_onboarding.* Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6e34631246
commit
83b0d7a062
@ -1,19 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { View, ActivityIndicator, AppState, Platform } from 'react-native';
|
import { View, ActivityIndicator, AppState, Platform } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import { useNotificationStore } from '../../stores/notifications';
|
import { useNotificationStore } from '../../stores/notifications';
|
||||||
import { useMailConsentStore } from '../../stores/mailConsent';
|
import { useMailConsentStore } from '../../stores/mailConsent';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { NativeTabs } from '../../components/NativeTabs';
|
import { NativeTabs } from '../../components/NativeTabs';
|
||||||
import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet';
|
import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet';
|
||||||
|
import { ProtectionOnboardingSheet } from '../../components/ProtectionOnboardingSheet';
|
||||||
import { protection } from '../../lib/protection';
|
import { protection } from '../../lib/protection';
|
||||||
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
|
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed';
|
||||||
|
|
||||||
type DmConvUnreadSlice = { unreadCount?: number };
|
type DmConvUnreadSlice = { unreadCount?: number };
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
@ -29,6 +33,42 @@ export default function AppLayout() {
|
|||||||
const rearmInFlightRef = useRef(false);
|
const rearmInFlightRef = useRef(false);
|
||||||
const bypassNotifiedRef = useRef(false);
|
const bypassNotifiedRef = useRef(false);
|
||||||
|
|
||||||
|
// Android-only: Onboarding-Sheet bis beide Layer eingerichtet sind
|
||||||
|
const [onboardingVisible, setOnboardingVisible] = useState(false);
|
||||||
|
|
||||||
|
const checkAndShowOnboarding = useCallback(async () => {
|
||||||
|
if (Platform.OS !== 'android') return;
|
||||||
|
const completed = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||||
|
if (completed === '1') return;
|
||||||
|
const layers = await protection.getDeviceState().catch(() => null);
|
||||||
|
if (!layers) return;
|
||||||
|
const vpnActive = layers.vpn === true;
|
||||||
|
const a11yActive = layers.accessibility === true;
|
||||||
|
if (vpnActive && a11yActive) {
|
||||||
|
await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOnboardingVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || Platform.OS !== 'android') return;
|
||||||
|
checkAndShowOnboarding();
|
||||||
|
const sub = AppState.addEventListener('change', (next) => {
|
||||||
|
if (next === 'active') checkAndShowOnboarding();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [session, checkAndShowOnboarding]);
|
||||||
|
|
||||||
|
async function handleOnboardingComplete() {
|
||||||
|
await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1');
|
||||||
|
setOnboardingVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOnboardingSkip() {
|
||||||
|
setOnboardingVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Unread DMs → badge on the Chat tab. Same query key chat.tsx uses, so
|
// Unread DMs → badge on the Chat tab. Same query key chat.tsx uses, so
|
||||||
// React Query dedupes (no double fetch when both layouts mount).
|
// React Query dedupes (no double fetch when both layouts mount).
|
||||||
const { data: dmConvs = [] } = useQuery<DmConvUnreadSlice[]>({
|
const { data: dmConvs = [] } = useQuery<DmConvUnreadSlice[]>({
|
||||||
@ -196,6 +236,13 @@ export default function AppLayout() {
|
|||||||
onConsented={markConsented}
|
onConsented={markConsented}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{Platform.OS === 'android' && (
|
||||||
|
<ProtectionOnboardingSheet
|
||||||
|
visible={onboardingVisible}
|
||||||
|
onComplete={handleOnboardingComplete}
|
||||||
|
onSkip={handleOnboardingSkip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable
|
sidebarAdaptable
|
||||||
hapticFeedbackEnabled
|
hapticFeedbackEnabled
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Animated, ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } from 'react-native';
|
import { Animated, AppState, Platform, ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -87,6 +87,14 @@ export default function BlockerScreen() {
|
|||||||
});
|
});
|
||||||
}, [urlFilterActive, syncBlocklist, 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 ──────────────────────────────────────
|
// ─── Activate-Handler pro Layer ──────────────────────────────────────
|
||||||
|
|
||||||
async function handleActivateUrlFilter() {
|
async function handleActivateUrlFilter() {
|
||||||
@ -242,6 +250,19 @@ export default function BlockerScreen() {
|
|||||||
onActivate={handleActivateFamilyControls}
|
onActivate={handleActivateFamilyControls}
|
||||||
warning={t('blocker.layers_app_lock_warning')}
|
warning={t('blocker.layers_app_lock_warning')}
|
||||||
/>
|
/>
|
||||||
|
) : 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')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
280
apps/rebreak-native/components/ProtectionOnboardingSheet.tsx
Normal file
280
apps/rebreak-native/components/ProtectionOnboardingSheet.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { AppState, Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { FormSheet } from './FormSheet';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
import { protection } from '../lib/protection';
|
||||||
|
|
||||||
|
type StepState = 'pending' | 'done';
|
||||||
|
|
||||||
|
export function ProtectionOnboardingSheet({
|
||||||
|
visible,
|
||||||
|
onComplete,
|
||||||
|
onSkip,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
const [vpnState, setVpnState] = useState<StepState>('pending');
|
||||||
|
const [a11yState, setA11yState] = useState<StepState>('pending');
|
||||||
|
const [vpnLoading, setVpnLoading] = useState(false);
|
||||||
|
const [a11yLoading, setA11yLoading] = useState(false);
|
||||||
|
|
||||||
|
const vpnDone = vpnState === 'done';
|
||||||
|
const a11yDone = a11yState === 'done';
|
||||||
|
|
||||||
|
// AppState-Listener: User kehrt aus System-Settings zurück → State neu abfragen
|
||||||
|
const refreshInFlightRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (refreshInFlightRef.current) return;
|
||||||
|
refreshInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
const layers = await protection.getDeviceState();
|
||||||
|
const vpnActive = layers.vpn === true;
|
||||||
|
const a11yActive = layers.accessibility === true;
|
||||||
|
if (vpnActive) setVpnState('done');
|
||||||
|
if (a11yActive && vpnActive) setA11yState('done');
|
||||||
|
} finally {
|
||||||
|
refreshInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
const sub = AppState.addEventListener('change', (next) => {
|
||||||
|
if (next === 'active') refresh();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Beide Steps done → onComplete
|
||||||
|
useEffect(() => {
|
||||||
|
if (vpnDone && a11yDone) {
|
||||||
|
const t = setTimeout(onComplete, 400);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [vpnDone, a11yDone, onComplete]);
|
||||||
|
|
||||||
|
async function handleVpnStep() {
|
||||||
|
setVpnLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await protection.activateUrlFilter();
|
||||||
|
if (result.enabled) setVpnState('done');
|
||||||
|
} finally {
|
||||||
|
setVpnLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleA11yStep() {
|
||||||
|
if (!vpnDone) return;
|
||||||
|
setA11yLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await protection.activateFamilyControls();
|
||||||
|
if (result.enabled) setA11yState('done');
|
||||||
|
} finally {
|
||||||
|
setA11yLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSheet
|
||||||
|
visible={visible}
|
||||||
|
onClose={onSkip}
|
||||||
|
title={t('protection_onboarding.sheet_title')}
|
||||||
|
initialHeightPct={0.58}
|
||||||
|
minHeightPct={0.35}
|
||||||
|
backdropOpacity={0.18}
|
||||||
|
dismissOnBackdrop={false}
|
||||||
|
growWithKeyboard={false}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 16, gap: 12 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.textMuted,
|
||||||
|
lineHeight: 19,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('protection_onboarding.sheet_intro')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Step 1 — VPN */}
|
||||||
|
<StepCard
|
||||||
|
stepNumber={1}
|
||||||
|
title={t('protection_onboarding.step_vpn_title')}
|
||||||
|
description={t('protection_onboarding.step_vpn_desc')}
|
||||||
|
state={vpnState}
|
||||||
|
loading={vpnLoading}
|
||||||
|
disabled={false}
|
||||||
|
ctaLabel={t('protection_onboarding.step_vpn_cta')}
|
||||||
|
onPress={handleVpnStep}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Step 2 — A11y */}
|
||||||
|
<StepCard
|
||||||
|
stepNumber={2}
|
||||||
|
title={t('protection_onboarding.step_a11y_title')}
|
||||||
|
description={t('protection_onboarding.step_a11y_desc')}
|
||||||
|
state={a11yState}
|
||||||
|
loading={a11yLoading}
|
||||||
|
disabled={!vpnDone}
|
||||||
|
ctaLabel={t('protection_onboarding.step_a11y_cta')}
|
||||||
|
onPress={handleA11yStep}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Skip */}
|
||||||
|
<View style={{ alignItems: 'center', paddingBottom: 8 }}>
|
||||||
|
<Text
|
||||||
|
onPress={onSkip}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: colors.textMuted,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('protection_onboarding.skip_cta')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</FormSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── StepCard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||||
|
import type { ColorScheme } from '../lib/theme';
|
||||||
|
|
||||||
|
function StepCard({
|
||||||
|
stepNumber,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
state,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
ctaLabel,
|
||||||
|
onPress,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
stepNumber: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
state: StepState;
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
ctaLabel: string;
|
||||||
|
onPress: () => void;
|
||||||
|
colors: ColorScheme;
|
||||||
|
}) {
|
||||||
|
const done = state === 'done';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: done ? '#16a34a40' : disabled ? colors.border : colors.border,
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
opacity: disabled ? 0.48 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
{/* Step badge / checkmark */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: done ? '#16a34a' : disabled ? colors.surfaceElevated : colors.brandOrange + '18',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? (
|
||||||
|
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: disabled ? colors.textMuted : colors.brandOrange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stepNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lock icon when disabled */}
|
||||||
|
{disabled && (
|
||||||
|
<Ionicons name="lock-closed" size={16} color={colors.textMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.textMuted,
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!done && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={{
|
||||||
|
backgroundColor: disabled ? colors.surfaceElevated : colors.brandOrange,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color={disabled ? colors.textMuted : '#fff'} />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: disabled ? colors.textMuted : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -293,6 +293,8 @@
|
|||||||
"layers_app_lock_subtitle_active": "Verriegelt — Abschalten nur über die Abkühlphase",
|
"layers_app_lock_subtitle_active": "Verriegelt — Abschalten nur über die Abkühlphase",
|
||||||
"layers_app_lock_subtitle_inactive": "Verhindert, dass du ReBreak oder den Filter im Impuls abschaltest",
|
"layers_app_lock_subtitle_inactive": "Verhindert, dass du ReBreak oder den Filter im Impuls abschaltest",
|
||||||
"layers_app_lock_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
|
"layers_app_lock_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
|
||||||
|
"layers_a11y_subtitle_active": "Eingabehilfe aktiv — App-Schutz armiert",
|
||||||
|
"layers_a11y_subtitle_inactive": "Eingabehilfe nicht aktiviert — jetzt einrichten",
|
||||||
"kpi_global_label": "Geblockte Domains weltweit",
|
"kpi_global_label": "Geblockte Domains weltweit",
|
||||||
"kpi_global_subtitle": "Aktive Einträge in der globalen Blockliste",
|
"kpi_global_subtitle": "Aktive Einträge in der globalen Blockliste",
|
||||||
"delta_week": "diese Woche",
|
"delta_week": "diese Woche",
|
||||||
@ -345,6 +347,17 @@
|
|||||||
"empty_web": "Noch keine eigenen Domains.\nTippe + um eine hinzuzufügen.",
|
"empty_web": "Noch keine eigenen Domains.\nTippe + um eine hinzuzufügen.",
|
||||||
"empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
|
"empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
|
||||||
},
|
},
|
||||||
|
"protection_onboarding": {
|
||||||
|
"sheet_title": "Schutz einrichten",
|
||||||
|
"sheet_intro": "Richte beide Schutz-Layer ein — in dieser Reihenfolge. Sobald der App-Schutz aktiv ist, kannst du VPN-Einstellungen nicht mehr öffnen.",
|
||||||
|
"step_vpn_title": "VPN-Schutz aktivieren",
|
||||||
|
"step_vpn_desc": "Blockiert Glücksspiel-Domains DNS-weit auf deinem Gerät. Muss zuerst eingerichtet werden.",
|
||||||
|
"step_vpn_cta": "VPN-Schutz aktivieren",
|
||||||
|
"step_a11y_title": "App-Sperre aktivieren",
|
||||||
|
"step_a11y_desc": "Verhindert, dass du ReBreak oder den VPN-Schutz im Impuls abschaltest. Nur nach dem VPN-Schritt verfügbar.",
|
||||||
|
"step_a11y_cta": "App-Sperre aktivieren",
|
||||||
|
"skip_cta": "Später einrichten"
|
||||||
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
"title": "Mail-Schutz",
|
"title": "Mail-Schutz",
|
||||||
"subtitle": "Gambling-Mails automatisch blockieren",
|
"subtitle": "Gambling-Mails automatisch blockieren",
|
||||||
|
|||||||
@ -293,6 +293,8 @@
|
|||||||
"layers_app_lock_subtitle_active": "Locked — disable only via the cooldown",
|
"layers_app_lock_subtitle_active": "Locked — disable only via the cooldown",
|
||||||
"layers_app_lock_subtitle_inactive": "Stops you from switching off ReBreak or the filter on impulse",
|
"layers_app_lock_subtitle_inactive": "Stops you from switching off ReBreak or the filter on impulse",
|
||||||
"layers_app_lock_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
|
"layers_app_lock_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
|
||||||
|
"layers_a11y_subtitle_active": "Accessibility active — app protection armed",
|
||||||
|
"layers_a11y_subtitle_inactive": "Accessibility not enabled — set it up now",
|
||||||
"kpi_global_label": "Domains blocked worldwide",
|
"kpi_global_label": "Domains blocked worldwide",
|
||||||
"kpi_global_subtitle": "Active entries in the global blocklist",
|
"kpi_global_subtitle": "Active entries in the global blocklist",
|
||||||
"delta_week": "this week",
|
"delta_week": "this week",
|
||||||
@ -345,6 +347,17 @@
|
|||||||
"empty_web": "No custom domains yet.\nTap + to add one.",
|
"empty_web": "No custom domains yet.\nTap + to add one.",
|
||||||
"empty_mail": "No mail domains yet. Tap + to block an email address or domain."
|
"empty_mail": "No mail domains yet. Tap + to block an email address or domain."
|
||||||
},
|
},
|
||||||
|
"protection_onboarding": {
|
||||||
|
"sheet_title": "Set up protection",
|
||||||
|
"sheet_intro": "Set up both protection layers in this order. Once the app lock is active, you won't be able to open VPN settings anymore.",
|
||||||
|
"step_vpn_title": "Enable VPN protection",
|
||||||
|
"step_vpn_desc": "Blocks gambling domains system-wide via DNS on your device. Must be set up first.",
|
||||||
|
"step_vpn_cta": "Enable VPN protection",
|
||||||
|
"step_a11y_title": "Enable app lock",
|
||||||
|
"step_a11y_desc": "Prevents you from impulsively disabling ReBreak or the VPN filter. Only available after the VPN step.",
|
||||||
|
"step_a11y_cta": "Enable app lock",
|
||||||
|
"skip_cta": "Set up later"
|
||||||
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
"title": "Mail Shield",
|
"title": "Mail Shield",
|
||||||
"subtitle": "Automatically block gambling emails",
|
"subtitle": "Automatically block gambling emails",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user