diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx index fea154d..29c2ab8 100644 --- a/apps/rebreak-native/app/(app)/_layout.tsx +++ b/apps/rebreak-native/app/(app)/_layout.tsx @@ -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({ @@ -196,6 +236,13 @@ export default function AppLayout() { onConsented={markConsented} /> )} + {Platform.OS === 'android' && ( + + )} { + 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' ? ( + ) : ( void; + onSkip: () => void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + + const [vpnState, setVpnState] = useState('pending'); + const [a11yState, setA11yState] = useState('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 ( + + + + {t('protection_onboarding.sheet_intro')} + + + {/* Step 1 — VPN */} + + + {/* Step 2 — A11y */} + + + + + {/* Skip */} + + + {t('protection_onboarding.skip_cta')} + + + + + ); +} + +// ─── 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 ( + + {/* Header row */} + + {/* Step badge / checkmark */} + + {done ? ( + + ) : ( + + {stepNumber} + + )} + + + + + {title} + + + + {/* Lock icon when disabled */} + {disabled && ( + + )} + + + + {description} + + + {!done && ( + + {loading ? ( + + ) : ( + + {ctaLabel} + + )} + + )} + + ); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index e8c0665..9dc7e9b 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 07c28e3..5c43e67 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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",