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 { 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
|
||||
|
||||
@ -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={{
|
||||
|
||||
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_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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user