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:
chahinebrini 2026-05-16 10:34:48 +02:00
parent 6e34631246
commit 83b0d7a062
5 changed files with 376 additions and 2 deletions

View File

@ -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 { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuthStore } from '../../stores/auth';
import { useNotificationStore } from '../../stores/notifications';
import { useMailConsentStore } from '../../stores/mailConsent';
import { useColors } from '../../lib/theme';
import { NativeTabs } from '../../components/NativeTabs';
import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet';
import { ProtectionOnboardingSheet } from '../../components/ProtectionOnboardingSheet';
import { protection } from '../../lib/protection';
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
import { apiFetch } from '../../lib/api';
import { useQuery } from '@tanstack/react-query';
const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed';
type DmConvUnreadSlice = { unreadCount?: number };
export default function AppLayout() {
@ -29,6 +33,42 @@ export default function AppLayout() {
const rearmInFlightRef = 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
// React Query dedupes (no double fetch when both layouts mount).
const { data: dmConvs = [] } = useQuery<DmConvUnreadSlice[]>({
@ -196,6 +236,13 @@ export default function AppLayout() {
onConsented={markConsented}
/>
)}
{Platform.OS === 'android' && (
<ProtectionOnboardingSheet
visible={onboardingVisible}
onComplete={handleOnboardingComplete}
onSkip={handleOnboardingSkip}
/>
)}
<NativeTabs
sidebarAdaptable
hapticFeedbackEnabled

View File

@ -1,5 +1,5 @@
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 { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
@ -87,6 +87,14 @@ export default function BlockerScreen() {
});
}, [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() {
@ -242,6 +250,19 @@ export default function BlockerScreen() {
onActivate={handleActivateFamilyControls}
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
style={{

View 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>
);
}

View File

@ -293,6 +293,8 @@
"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_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_subtitle": "Aktive Einträge in der globalen Blockliste",
"delta_week": "diese Woche",
@ -345,6 +347,17 @@
"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."
},
"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": {
"title": "Mail-Schutz",
"subtitle": "Gambling-Mails automatisch blockieren",

View File

@ -293,6 +293,8 @@
"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_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_subtitle": "Active entries in the global blocklist",
"delta_week": "this week",
@ -345,6 +347,17 @@
"empty_web": "No custom domains yet.\nTap + to add one.",
"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": {
"title": "Mail Shield",
"subtitle": "Automatically block gambling emails",