feat(onboarding,protection,i18n): spotlight POC, arabic locale, NEFilter recovery

State of work before Duo-style onboarding pivot. Includes work that will be
partly reverted in the next commit (see refactor follow-up).

Onboarding (will be partly reverted):
- Custom Tooltip+Glow spotlight (components/OnboardingHint.tsx)
- Spotlight wiring in app/profile/edit.tsx (nickname-input glow + step-progress
  header, onSubmitEditing auto-save, save-handler routes to /(app)/blocker)
- Spotlight wiring in app/(app)/blocker.tsx (URL-filter LayerSwitchCard wrapped
  + auto-PATCH step='done' when filter activates)
- Routing-gate branches in (app)/_layout.tsx (welcome → /onboarding/welcome,
  nickname → /profile/edit)
- Debug-Reset-Toggle in /debug (welcome|nickname|block|done buttons + redirect)

Will stay (reused in Duo flow):
- Welcome-Screen app/onboarding/welcome.tsx (will become Slide 1)
- Avatar-fix in profile/edit (Dicebear seed stays stable while typing)

i18n + RTL:
- Arabic locale (locales/ar.json, full translation incl. onboarding keys)
- I18nManager.allowRTL(true) + applyRTL helper in stores/language.ts
- Language-Picker option for العربية in settings
- New keys: onboarding.welcome.*, step_progress, nickname_spotlight.*,
  block_spotlight.*, permission_denied.*, language.*, rtl_restart.* (de/en/fr/ar)

NEFilter Permission Recovery (iOS):
- Swift resetUrlFilter() — removeFromPreferences + fresh saveToPreferences to
  bypass iOS's cached denied-state (NEFilterErrorDomain code 5)
- TS module def + lib/protection.ts wrapper
- components/PermissionDeniedSheet.tsx — branded recovery sheet with retry +
  app-settings:// deep-link + fallback hint
- Wired in (app)/blocker.tsx handleActivateUrlFilter (code-5 detection)

Misc:
- Bug fix in onboarding/welcome.tsx: apiFetch body was double-stringified (sent
  as JSON string instead of object → 400 invalid_step)
- Bug fix in profile/edit.tsx: avatar preview Dicebear seed switched from live
  nickname (changed every keystroke) to stable me?.nickname

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-17 15:44:32 +02:00
parent 38a8517259
commit 1c9e67c256
17 changed files with 1936 additions and 110 deletions

View File

@ -12,6 +12,10 @@ import { DomainGrid } from '../../components/blocker/DomainGrid';
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet';
import { OnboardingTooltip, OnboardingGlow } from '../../components/OnboardingHint';
import { useMe, invalidateMe } from '../../hooks/useMe';
import { apiFetch } from '../../lib/api';
import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
@ -66,6 +70,29 @@ export default function BlockerScreen() {
const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
// Onboarding Stage 3: Spotlight um URL-Filter-Toggle wenn me.onboardingStep === 'block'.
// Sobald urlFilter aktiv ist → PATCH step='done' → Tour vorbei.
const { me } = useMe();
const onboardingActive = me?.onboardingStep === 'block';
const stepCompletedRef = useRef(false);
// Sobald URL-Filter aktiv ist während wir im Onboarding-Stage 'block' sind,
// → PATCH step='done' (Tour vorbei). One-shot via ref damit's nicht doppelt feuert
// wenn `me` mehrfach updated.
useEffect(() => {
if (!onboardingActive) return;
if (!state?.layers.urlFilter) return;
if (stepCompletedRef.current) return;
stepCompletedRef.current = true;
apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
})
.then(() => invalidateMe())
.catch((e) => console.warn('[blocker] failed to mark onboarding done:', e));
}, [onboardingActive, state?.layers.urlFilter]);
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
@ -102,6 +129,17 @@ export default function BlockerScreen() {
const result = await activateUrlFilter();
console.log('[blocker] activateUrlFilter:', result);
if (!result.enabled) {
// iOS-spezifisch: NEFilterErrorDomain code 5 = User hat „Nicht erlauben"
// im System-Dialog getippt → iOS cached den Denied-State. Special-Sheet
// statt rohem Alert (Recovery via removeFromPreferences + Settings-Deep-Link).
const isPermissionDenied =
Platform.OS === 'ios' &&
typeof result.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(result.error);
if (isPermissionDenied) {
setPermissionDeniedOpen(true);
return result;
}
Alert.alert(
t('blocker.activate_url_failed_title'),
result.error ?? t('blocker.activate_url_failed_msg'),
@ -226,17 +264,30 @@ export default function BlockerScreen() {
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
) : (
<View style={{ gap: 10 }}>
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
{onboardingActive && !urlFilterActive ? (
<OnboardingTooltip
text={t('onboarding.block_spotlight.body')}
colors={colors}
arrowOffset={32}
/>
) : null}
<OnboardingGlow
active={onboardingActive && !urlFilterActive}
colors={colors}
radius={16}
>
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
</OnboardingGlow>
{FAMILY_CONTROLS_AVAILABLE ? (
<LayerSwitchCard
icon="lock-closed-outline"
@ -454,6 +505,18 @@ export default function BlockerScreen() {
onBreathe={deflectToBreathe}
onStartCooldown={handleStartCooldown}
/>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
await refresh();
}
return res;
}}
/>
</>
) : null}
</View>

View File

@ -1,5 +1,7 @@
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { AppState, I18nManager } from 'react-native';
I18nManager.allowRTL(true);
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
@ -17,8 +19,6 @@ import {
Nunito_800ExtraBold,
} from '@expo-google-fonts/nunito';
import { supabase } from '../lib/supabase';
import { CopilotProvider } from 'react-native-copilot';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useRealtimeDebugStore } from '../stores/realtimeDebug';
@ -200,28 +200,6 @@ function RootLayoutInner() {
);
}
function CopilotShell({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
return (
<CopilotProvider
overlay="svg"
animated
backdropColor="rgba(15, 23, 42, 0.78)"
arrowColor="#1c1c1e"
stopOnOutsideClick={false}
margin={8}
labels={{
skip: t('common.cancel'),
previous: t('common.back'),
next: t('common.continue'),
finish: t('onboarding.nickname_spotlight.finish'),
}}
>
{children as any}
</CopilotProvider>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
@ -229,9 +207,7 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<SafeAreaProvider>
<CopilotShell>
<RootLayoutInner />
</CopilotShell>
<RootLayoutInner />
</SafeAreaProvider>
</ActionSheetProvider>
</QueryClientProvider>

View File

@ -106,6 +106,13 @@ export default function DebugScreen() {
/>
) : null}
{me ? (
<OnboardingResetToggle
colors={colors}
currentStep={me.onboardingStep}
/>
) : null}
<CooldownTestModeToggle />
<RealtimeStatusCard />
@ -658,6 +665,124 @@ function PlanOverrideToggle({
);
}
// ─── Onboarding Reset ──────────────────────────────────────────────────────
const ONBOARDING_STEPS = ['welcome', 'nickname', 'block', 'done'] as const;
type OnboardingStepValue = (typeof ONBOARDING_STEPS)[number];
function OnboardingResetToggle({
colors,
currentStep,
}: {
colors: import('../lib/theme').ColorScheme;
currentStep: OnboardingStepValue;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function setStep(step: OnboardingStepValue) {
if (loading || step === currentStep) return;
setLoading(true);
try {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step },
});
invalidateMe();
if (step === 'welcome') {
router.replace('/onboarding/welcome');
} else if (step === 'nickname') {
router.replace('/profile/edit');
} else if (step === 'block') {
router.replace('/(app)/blocker');
} else if (step === 'done') {
router.replace('/(app)');
}
} catch (e: unknown) {
Alert.alert('Fehler', e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="rocket-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Onboarding-Step
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
PATCH /api/profile/me/onboarding-step aktuell: {currentStep}
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', gap: 6 }}>
{ONBOARDING_STEPS.map((step) => {
const isActive = step === currentStep;
return (
<TouchableOpacity
key={step}
onPress={() => setStep(step)}
disabled={loading || isActive}
activeOpacity={0.7}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 10,
alignItems: 'center',
backgroundColor: isActive ? colors.brandOrange : colors.surfaceElevated,
opacity: loading ? 0.5 : 1,
}}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: isActive ? '#ffffff' : colors.textMuted,
textTransform: 'capitalize',
}}
>
{step}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
// ─── Cooldown Test Mode ────────────────────────────────────────────────────
function CooldownTestModeToggle() {

View File

@ -129,7 +129,7 @@ export default function OnboardingWelcomeScreen() {
try {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: JSON.stringify({ step: 'nickname' }),
body: { step: 'nickname' },
});
invalidateMe();
router.replace('/profile/edit');
@ -194,7 +194,7 @@ export default function OnboardingWelcomeScreen() {
flex: 1,
paddingHorizontal: 24,
paddingTop: insets.top + 24,
paddingBottom: insets.bottom + 20,
paddingBottom: insets.bottom + 36,
}}
>
{/* Hero-Icon */}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import {
View,
Text,
@ -16,15 +16,13 @@ import * as ImagePicker from 'expo-image-picker';
// TODO(sdk54): migrate to new expo-file-system class-based API — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { useTranslation } from 'react-i18next';
import { CopilotStep, useCopilot, walkthroughable } from 'react-native-copilot';
import { useColors } from '../../lib/theme';
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
import { resolveAvatar } from '../../lib/resolveAvatar';
import { apiFetch } from '../../lib/api';
import { useMe } from '../../hooks/useMe';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
const WalkthroughView = walkthroughable(View);
import { OnboardingTooltip, OnboardingGlow } from '../../components/OnboardingHint';
export default function ProfileEditScreen() {
const router = useRouter();
@ -32,22 +30,8 @@ export default function ProfileEditScreen() {
const { t } = useTranslation();
const colors = useColors();
const { me, reload } = useMe();
const copilot = useCopilot();
const tourStartedRef = useRef(false);
const onboardingActive = me?.onboardingStep === 'nickname';
// Spotlight-Tour einmalig starten wenn User im Onboarding-Flow auf dieser Page landet.
// useCopilot.start ist async; wir setzen ein ref-Flag damit es nicht doppelt feuert
// wenn `me` mehrfach updated (z.B. nach reload).
useEffect(() => {
if (!onboardingActive || tourStartedRef.current) return;
tourStartedRef.current = true;
const t = setTimeout(() => {
copilot.start().catch(() => {});
}, 350); // kurzer Delay damit Layout fertig measure'd ist
return () => clearTimeout(t);
}, [onboardingActive]);
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
@ -123,16 +107,15 @@ export default function ProfileEditScreen() {
},
});
// Onboarding-Übergang: Nickname → Block (Stage 3 kommt später).
// Wenn nicht im Onboarding-Flow → normale Save/Back-Semantik.
// Onboarding-Übergang: Nickname → Block. Direkt zur Blocker-Page, dort
// greift der nächste Spotlight.
if (onboardingActive) {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'block' },
}).catch(() => {});
copilot.stop().catch(() => {});
reload();
router.replace('/(app)');
router.replace('/(app)/blocker');
} else {
reload();
router.back();
@ -146,9 +129,12 @@ export default function ProfileEditScreen() {
}
}
// Avatar-Preview: Dicebear-Seed muss STABIL bleiben während User den Nickname tippt
// — sonst wechselt das Bild bei jedem Keystroke. Daher den gespeicherten `me?.nickname`
// als Seed nehmen (nicht den live-getippten lokalen `nickname`-State).
const resolvedPreview = photoUri
? photoUri
: resolveAvatar(avatarId, nickname || (me?.nickname ?? ''));
: resolveAvatar(avatarId, me?.nickname ?? '');
const hasChanges =
nickname.trim() !== (me?.nickname ?? '') ||
@ -169,16 +155,23 @@ export default function ProfileEditScreen() {
backgroundColor: colors.bg,
}}
>
<TouchableOpacity
onPress={() => router.back()}
hitSlop={10}
activeOpacity={0.5}
style={{ marginRight: 12 }}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
{onboardingActive ? (
// Während Onboarding kein Back — User soll den Step abschließen, nicht zurück.
<View style={{ width: 24, marginRight: 12 }} />
) : (
<TouchableOpacity
onPress={() => router.back()}
hitSlop={10}
activeOpacity={0.5}
style={{ marginRight: 12 }}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
)}
<Text style={{ flex: 1, fontSize: 17, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t('profile.edit_title')}
{onboardingActive
? t('onboarding.step_progress', { current: 2, total: 3 })
: t('profile.edit_title')}
</Text>
<TouchableOpacity
onPress={save}
@ -313,26 +306,33 @@ export default function ProfileEditScreen() {
>
{t('profile.edit_nickname_label').toUpperCase()}
</Text>
<CopilotStep
name="nickname"
order={1}
text={t('onboarding.nickname_spotlight.body')}
active={onboardingActive}
>
<WalkthroughView style={{ borderRadius: 12 }}>
<TextInput
style={INPUT_STYLE}
value={nickname}
onChangeText={setNickname}
placeholder={t('auth.nicknamePlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
maxLength={32}
returnKeyType="done"
/>
</WalkthroughView>
</CopilotStep>
{/* Onboarding-Hint: Tooltip-Bubble + Pulse-Glow um Input. Sichtbar nur
wenn me.onboardingStep === 'nickname'. Verschwindet sobald User
speichert (Step 'block'). */}
{onboardingActive ? (
<OnboardingTooltip
text={t('onboarding.nickname_spotlight.body')}
colors={colors}
/>
) : null}
<OnboardingGlow active={onboardingActive} colors={colors}>
<TextInput
style={INPUT_STYLE}
value={nickname}
onChangeText={setNickname}
placeholder={t('auth.nicknamePlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
maxLength={32}
returnKeyType="done"
onSubmitEditing={() => {
if (!saving && hasChanges && nickname.trim()) save();
}}
/>
</OnboardingGlow>
<Text
style={{
marginTop: 6,
@ -348,3 +348,4 @@ export default function ProfileEditScreen() {
</KeyboardAwareScreen>
);
}

View File

@ -258,6 +258,7 @@ export default function SettingsScreen() {
{ value: 'de', label: t('settings.language_de') },
{ value: 'en', label: t('settings.language_en') },
{ value: 'fr', label: t('settings.language_fr') },
{ value: 'ar', label: t('settings.language_ar') },
];
const voiceLabel =
@ -321,7 +322,9 @@ export default function SettingsScreen() {
? t('settings.language_de')
: language === 'fr'
? t('settings.language_fr')
: t('settings.language_en'),
: language === 'ar'
? t('settings.language_ar')
: t('settings.language_en'),
menu: {
title: t('settings.language'),
actions: langOptions.map((opt) => ({

View File

@ -0,0 +1,169 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { ColorScheme } from '../lib/theme';
/**
* Custom-Spotlight für den interaktiven Onboarding-Flow (statt react-native-copilot).
* Zwei wiederverwendbare Bausteine:
*
* - <OnboardingTooltip text="..." colors={colors} />
* Orange Bubble + Arrow-Down (zeigt auf das nächste UI-Element).
* Fade-in + Spring-Bounce beim Mount.
*
* - <OnboardingGlow active colors={colors}>{children}</OnboardingGlow>
* Pulsierender Border-Glow um das Target. Wenn active=false Children
* werden ohne Extra-View durchgereicht (kein Layout-Shift).
*
* Genutzt in:
* - app/profile/edit.tsx (Stage 2: Nickname)
* - app/(app)/blocker.tsx (Stage 3: Schutz aktivieren)
*/
export function OnboardingTooltip({
text,
colors,
arrowOffset = 22,
}: {
text: string;
colors: ColorScheme;
/** X-Offset des Pfeils unten in px (Default 22 = links-aligned mit etwas Indent). */
arrowOffset?: number;
}) {
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-6)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 350,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
friction: 6,
tension: 80,
}),
]).start();
}, []);
return (
<Animated.View
style={{
opacity,
transform: [{ translateY }],
marginBottom: 6,
}}
>
<View
style={{
backgroundColor: colors.brandOrange,
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
}}
>
<Ionicons name="sparkles" size={16} color="#ffffff" style={{ marginTop: 2 }} />
<Text
style={{
flex: 1,
fontFamily: 'Nunito_600SemiBold',
fontSize: 13,
lineHeight: 19,
color: '#ffffff',
}}
>
{text}
</Text>
</View>
{/* Arrow-Down */}
<View
style={{
alignSelf: 'flex-start',
marginLeft: arrowOffset,
marginTop: -1,
width: 0,
height: 0,
borderLeftWidth: 8,
borderRightWidth: 8,
borderTopWidth: 8,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
borderTopColor: colors.brandOrange,
}}
/>
</Animated.View>
);
}
export function OnboardingGlow({
active,
colors,
children,
radius = 14,
}: {
active: boolean;
colors: ColorScheme;
children: React.ReactNode;
/** Border-Radius. Default 14. Anpassen wenn das Target eckiger/runder ist. */
radius?: number;
}) {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!active) return;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, {
toValue: 1,
duration: 1200,
useNativeDriver: false,
easing: Easing.inOut(Easing.cubic),
}),
Animated.timing(pulse, {
toValue: 0,
duration: 1200,
useNativeDriver: false,
easing: Easing.inOut(Easing.cubic),
}),
]),
);
loop.start();
return () => loop.stop();
}, [active]);
if (!active) return <>{children}</>;
const borderColor = pulse.interpolate({
inputRange: [0, 1],
outputRange: ['rgba(0,122,255,0.35)', 'rgba(0,122,255,0.95)'],
}) as unknown as string;
const shadowOpacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.15, 0.45],
}) as unknown as number;
return (
<Animated.View
style={{
borderRadius: radius,
borderWidth: 2,
borderColor,
padding: 2,
shadowColor: colors.brandOrange,
shadowOffset: { width: 0, height: 0 },
shadowRadius: 12,
shadowOpacity,
}}
>
{children}
</Animated.View>
);
}

View File

@ -0,0 +1,180 @@
import { useState } from 'react';
import { Linking, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../lib/theme';
import { FormSheet } from './FormSheet';
/**
* iOS-spezifischer Recovery-Sheet wenn NEFilter-Aktivierung mit
* NEFilterErrorDomain code 5 (Permission denied) fehlschlägt.
*
* iOS cached "Refuser" einmalig der System-Dialog kommt beim normalen
* activateUrlFilter nicht mehr. Sheet bietet zwei Recovery-Pfade:
* 1. Erneut versuchen" ruft protection.resetUrlFilter() (Swift macht
* removeFromPreferences + frisches saveToPreferences meist frischer Dialog)
* 2. Einstellungen öffnen" Deep-Link via Linking.openURL('app-settings:')
* wenn auch Reset nicht hilft (z.B. Screen-Time-Restrictions)
*
* Plus ein 3-Step-Fallback-Hinweis für den Härtefall (App neu installieren).
*/
export function PermissionDeniedSheet({
visible,
onClose,
onRetry,
}: {
visible: boolean;
onClose: () => void;
/** wird aufgerufen wenn User „Erneut versuchen" tappt — soll protection.resetUrlFilter() callen */
onRetry: () => Promise<{ enabled: boolean; error?: string }>;
}) {
const colors = useColors();
const { t } = useTranslation();
const [retrying, setRetrying] = useState(false);
async function handleRetry() {
if (retrying) return;
setRetrying(true);
try {
const res = await onRetry();
if (res.enabled) {
// Erfolg — Sheet kann schließen, Outer-Page refresht den Status selbst.
onClose();
}
// Bei error: Sheet bleibt offen, User kann „Einstellungen öffnen" oder erneut versuchen.
} finally {
setRetrying(false);
}
}
function openSettings() {
Linking.openURL('app-settings:').catch(() => {});
}
return (
<FormSheet
visible={visible}
onClose={onClose}
title={t('blocker.permission_denied.title')}
initialHeightPct={0.62}
minHeightPct={0.35}
>
<View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16 }}>
{/* Icon-Header */}
<View style={{ alignItems: 'center', marginTop: 8, marginBottom: 16 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: 'rgba(245,158,11,0.12)',
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.30)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="shield-outline" size={32} color={colors.warning} />
</View>
</View>
{/* Body */}
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 14,
lineHeight: 21,
color: colors.text,
textAlign: 'center',
marginBottom: 18,
}}
>
{t('blocker.permission_denied.body')}
</Text>
{/* Primary Retry */}
<TouchableOpacity
onPress={handleRetry}
disabled={retrying}
activeOpacity={0.85}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 10,
opacity: retrying ? 0.6 : 1,
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 15,
color: '#ffffff',
}}
>
{retrying
? t('blocker.permission_denied.retry_loading')
: t('blocker.permission_denied.retry_cta')}
</Text>
</TouchableOpacity>
{/* Secondary Settings */}
<TouchableOpacity
onPress={openSettings}
activeOpacity={0.7}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 16,
}}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 14,
color: colors.text,
}}
>
{t('blocker.permission_denied.settings_cta')}
</Text>
</TouchableOpacity>
{/* Fallback-Hinweis */}
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 14,
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
color: colors.textMuted,
letterSpacing: 0.5,
textTransform: 'uppercase',
marginBottom: 6,
}}
>
{t('blocker.permission_denied.fallback_label')}
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 18,
color: colors.textMuted,
}}
>
{t('blocker.permission_denied.fallback_body')}
</Text>
</View>
</View>
</FormSheet>
);
}

View File

@ -4,16 +4,24 @@ import * as Localization from 'expo-localization';
import de from '../locales/de.json';
import en from '../locales/en.json';
import fr from '../locales/fr.json';
import ar from '../locales/ar.json';
const deviceLocale = Localization.getLocales()[0]?.languageCode ?? 'en';
const initialLng =
deviceLocale === 'de' ? 'de' : deviceLocale === 'fr' ? 'fr' : 'en';
deviceLocale === 'de'
? 'de'
: deviceLocale === 'fr'
? 'fr'
: deviceLocale === 'ar'
? 'ar'
: 'en';
i18n.use(initReactI18next).init({
resources: {
de: { translation: de },
en: { translation: en },
fr: { translation: fr },
ar: { translation: ar },
},
lng: initialLng,
fallbackLng: 'en',

View File

@ -115,6 +115,22 @@ export const protection = {
return RebreakProtection.activateUrlFilter();
},
/**
* iOS-only Workaround: User hat "Nicht erlauben" beim NEFilter-System-Dialog
* getippt iOS cached den Denied-State + zeigt den Dialog beim erneuten
* activateUrlFilter NICHT mehr (NEFilterErrorDomain code 5 silent).
* resetUrlFilter macht removeFromPreferences VOR saveToPreferences iOS
* behandelt das als frischen Request System-Dialog kommt erneut.
*
* Auf Android no-op (gibt einfach activateUrlFilter zurück, dort kein Cache).
*/
async resetUrlFilter(): Promise<{ enabled: boolean; error?: string }> {
if (Platform.OS === "android") {
return this.activateUrlFilter();
}
return RebreakProtection.resetUrlFilter();
},
async activateFamilyControls(): Promise<{ enabled: boolean; error?: string }> {
if (Platform.OS === "android") {
// Android "App-Lock" = AccessibilityService als reiner Tamper-Lock (KEIN

File diff suppressed because it is too large Load Diff

View File

@ -260,6 +260,15 @@
"activate_url_failed_title": "URL-Filter konnte nicht aktiviert werden",
"activate_url_failed_msg": "Unbekannter Fehler.\nDu kannst es nochmal versuchen oder System-Einstellungen prüfen.",
"activate_settings_btn": "Einstellungen",
"permission_denied": {
"title": "Schutz wurde abgelehnt",
"body": "iOS hat den Filter nicht installiert, weil im System-Dialog „Nicht erlauben\" getippt wurde. Wir können es nochmal versuchen — diesmal bitte „Erlauben\".",
"retry_cta": "Erneut versuchen",
"retry_loading": "Einen Moment...",
"settings_cta": "Einstellungen öffnen",
"fallback_label": "Wenn der Dialog nicht kommt",
"fallback_body": "Einstellungen → Bildschirmzeit → Inhalt & Datenschutz prüfen (VPN/Filter müssen erlaubt sein). Notfalls: App deinstallieren + via TestFlight neu installieren."
},
"protection_off_title": "Schutz ist aus",
"protection_off_message": "Der Filter läuft gerade nicht, sollte aber an sein. Willst du ihn wieder einschalten?",
"reactivate_btn": "Wieder einschalten",
@ -349,8 +358,8 @@
},
"onboarding": {
"welcome": {
"headline": "Willkommen bei rebreak.",
"subhead": "Wir helfen dir, raus aus dem Glücksspiel zu kommen — anonym, geschützt, gemeinsam.",
"headline": "Willkommen bei ReBreak.",
"subhead": "Dein Weg raus aus dem Glücksspiel — anonym, geschützt, und nicht allein.",
"bullet_anon_title": "Du bleibst anonym",
"bullet_anon_desc": "Du wählst einen Alias. Niemand sieht deinen echten Namen — auch wir nicht.",
"bullet_protect_title": "Dein Gerät wird geschützt",
@ -363,6 +372,10 @@
"cta_loading": "Einen Moment...",
"next_hint": "Im nächsten Schritt wählst du deinen Alias."
},
"step_progress": "Schritt %{current} von %{total}",
"block_spotlight": {
"body": "Aktiviere jetzt den Schutz. Tippe hier — iOS fragt dich, ob ReBreak Glücksspielseiten blockieren darf."
},
"nickname_spotlight": {
"title": "Wähle deinen Alias",
"body": "Das ist dein einziger Name in rebreak. Niemand sieht deine Mail oder deinen echten Namen.",
@ -579,7 +592,7 @@
"push_notifications": "Push-Benachrichtigungen",
"streak_reminders": "Streak-Erinnerungen",
"language": "Sprache",
"language_desc": "Deutsch / Englisch / Französisch",
"language_desc": "Deutsch / Englisch / Französisch / Arabisch",
"language_current": "Deutsch",
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
"delete_account": "Konto löschen",
@ -615,6 +628,7 @@
"language_de": "Deutsch",
"language_en": "English",
"language_fr": "Français",
"language_ar": "العربية",
"lyra_voice_default": "Standard",
"lyra_voice_1": "Stimme 1",
"lyra_voice_2": "Stimme 2",
@ -667,7 +681,9 @@
"help_about": "Über Rebreak",
"help_about_desc": "Mission, Datenschutz, DiGA-Pfad",
"help_crisis": "Krisen-Hilfe",
"help_crisis_desc": "Externe Beratungsstellen & Notfall-Nummern"
"help_crisis_desc": "Externe Beratungsstellen & Notfall-Nummern",
"rtl_restart_title": "Neustart erforderlich",
"rtl_restart_body": "Bitte schließe die App und öffne sie erneut, damit die neue Sprachrichtung greift."
},
"device_limit": {
"title": "Geräte-Limit erreicht",

View File

@ -257,6 +257,15 @@
"protection_stat_method_native": "Native",
"protection_stat_status": "Status",
"protection_stat_status_live": "Live",
"permission_denied": {
"title": "Protection was denied",
"body": "iOS didn't install the filter because \"Don't Allow\" was tapped in the system dialog. We can try again — this time please tap \"Allow\".",
"retry_cta": "Try again",
"retry_loading": "One moment...",
"settings_cta": "Open Settings",
"fallback_label": "If the dialog doesn't appear",
"fallback_body": "Settings → Screen Time → Content & Privacy — VPN/Filter must be allowed. As a last resort: delete the app and reinstall via TestFlight."
},
"activate_url_failed_title": "Could not activate URL filter",
"activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.",
"activate_settings_btn": "Settings",
@ -349,8 +358,8 @@
},
"onboarding": {
"welcome": {
"headline": "Welcome to rebreak.",
"subhead": "We help you get out of gambling — anonymously, protected, together.",
"headline": "Welcome to ReBreak.",
"subhead": "Your way out of gambling — anonymous, protected, and not alone.",
"bullet_anon_title": "You stay anonymous",
"bullet_anon_desc": "You pick an alias. No one sees your real name — not even us.",
"bullet_protect_title": "Your device gets protected",
@ -363,6 +372,10 @@
"cta_loading": "One moment...",
"next_hint": "Next, you'll pick your alias."
},
"step_progress": "Step %{current} of %{total}",
"block_spotlight": {
"body": "Activate protection now. Tap here — iOS will ask if ReBreak can block gambling sites."
},
"nickname_spotlight": {
"title": "Pick your alias",
"body": "This is your only name on rebreak. No one sees your email or real name.",
@ -579,7 +592,7 @@
"push_notifications": "Push notifications",
"streak_reminders": "Streak reminders",
"language": "Language",
"language_desc": "German / English / French",
"language_desc": "German / English / French / Arabic",
"language_current": "English",
"upgrade_cta": "Upgrade to Pro — €29/year",
"delete_account": "Delete account",
@ -615,6 +628,7 @@
"language_de": "Deutsch",
"language_en": "English",
"language_fr": "Français",
"language_ar": "العربية",
"lyra_voice_default": "Default",
"lyra_voice_1": "Voice 1",
"lyra_voice_2": "Voice 2",
@ -667,7 +681,9 @@
"help_about": "About Rebreak",
"help_about_desc": "Mission, privacy, DiGA path",
"help_crisis": "Crisis help",
"help_crisis_desc": "External counselling & emergency numbers"
"help_crisis_desc": "External counselling & emergency numbers",
"rtl_restart_title": "Restart required",
"rtl_restart_body": "Please close the app and reopen it to apply the new text direction."
},
"device_limit": {
"title": "Device limit reached",

View File

@ -260,6 +260,15 @@
"activate_url_failed_title": "Impossible d'activer le filtre URL",
"activate_url_failed_msg": "Erreur inconnue.\nVous pouvez réessayer ou vérifier les réglages système.",
"activate_settings_btn": "Paramètres",
"permission_denied": {
"title": "Protection refusée",
"body": "iOS n'a pas installé le filtre car « Refuser » a été touché dans la fenêtre système. On peut réessayer — cette fois, touche « Autoriser ».",
"retry_cta": "Réessayer",
"retry_loading": "Un instant...",
"settings_cta": "Ouvrir les Réglages",
"fallback_label": "Si la fenêtre n'apparaît plus",
"fallback_body": "Réglages → Temps d'écran → Contenu et confidentialité — VPN/Filtre doivent être autorisés. En dernier recours : supprime l'app et réinstalle via TestFlight."
},
"protection_off_title": "La protection est désactivée",
"protection_off_message": "Le filtre ne fonctionne pas alors qu'il devrait être actif. Voulez-vous le réactiver ?",
"reactivate_btn": "Réactiver",
@ -347,8 +356,8 @@
},
"onboarding": {
"welcome": {
"headline": "Bienvenue sur rebreak.",
"subhead": "On t'aide à sortir du jeu — anonymement, en sécurité, ensemble.",
"headline": "Bienvenue sur ReBreak.",
"subhead": "Ta voie hors du jeu — anonyme, protégée, et pas seul·e.",
"bullet_anon_title": "Tu restes anonyme",
"bullet_anon_desc": "Tu choisis un alias. Personne ne voit ton vrai nom — nous non plus.",
"bullet_protect_title": "Ton appareil est protégé",
@ -361,6 +370,10 @@
"cta_loading": "Un instant...",
"next_hint": "À l'étape suivante, tu choisiras ton alias."
},
"step_progress": "Étape %{current} sur %{total}",
"block_spotlight": {
"body": "Active la protection maintenant. Touche ici — iOS te demandera si ReBreak peut bloquer les sites de jeux."
},
"nickname_spotlight": {
"title": "Choisis ton alias",
"body": "C'est ton seul nom sur rebreak. Personne ne voit ton e-mail ni ton vrai nom.",
@ -566,7 +579,7 @@
"push_notifications": "Notifications push",
"streak_reminders": "Rappels de série",
"language": "Langue",
"language_desc": "Allemand / Anglais / Français",
"language_desc": "Allemand / Anglais / Français / Arabe",
"language_current": "Français",
"upgrade_cta": "Passer à Pro — 29 €/an",
"delete_account": "Supprimer le compte",
@ -602,6 +615,7 @@
"language_de": "Deutsch",
"language_en": "English",
"language_fr": "Français",
"language_ar": "العربية",
"lyra_voice_default": "Par défaut",
"lyra_voice_1": "Voix 1",
"lyra_voice_2": "Voix 2",
@ -654,7 +668,9 @@
"help_about": "À propos de Rebreak",
"help_about_desc": "Mission, confidentialité, parcours DiGA",
"help_crisis": "Aide en crise",
"help_crisis_desc": "Services d'écoute & numéros d'urgence"
"help_crisis_desc": "Services d'écoute & numéros d'urgence",
"rtl_restart_title": "Redémarrage requis",
"rtl_restart_body": "Veuillez fermer l'application et la rouvrir pour appliquer le nouveau sens de lecture."
},
"device_limit": {
"title": "Limite d'appareils atteinte",

View File

@ -89,6 +89,54 @@ public class RebreakProtectionModule: Module {
return result
}
// resetUrlFilter: cached "denied" State löschen + frischer Dialog
//
// Apple's NEFilterManager cached den "permission denied"-State wenn User einmal
// "Nicht erlauben" getippt hat danach zeigt iOS den System-Dialog beim erneuten
// saveToPreferences NICHT mehr (code 5 silent). Workaround: removeFromPreferences
// löscht die alte (denied) Config komplett, sodass der nächste saveToPreferences
// als brandneuer Permission-Request behandelt wird frischer System-Dialog.
//
// Apple-undokumentiert, aber Community-erprobt. Wenn iOS den Dialog trotzdem nicht
// zeigt (z.B. wegen Screen-Time-Restrictions), bleibt nur Settings.app-Recovery.
AsyncFunction("resetUrlFilter") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [resetUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
// Best-effort remove schlägt fehl wenn nichts existiert, das ist OK.
SharedLogStore.append("🧹 [resetUrlFilter] removeFromPreferences (clear denied-cache)...")
do {
try await manager.removeFromPreferences()
} catch {
SharedLogStore.append(" removeFromPreferences ignored: \(error.localizedDescription)")
}
// Frische Config setzen + neu speichern iOS zeigt jetzt frischen Dialog.
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences (fresh System-Dialog)...")
try await manager.saveToPreferences()
enabled = manager.isEnabled
SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))")
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ resetUrlFilter failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
return result
}
// activateFamilyControls: NUR FC + denyAppRemoval
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in

View File

@ -19,6 +19,18 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
*/
activateUrlFilter(): Promise<{ enabled: boolean; error?: string }>;
/**
* iOS: nach "Nicht erlauben" beim NEFilter-Permission-Dialog hat iOS den
* Denied-State gecached und zeigt beim erneuten activateUrlFilter() den
* Dialog nicht mehr (code 5 silent). resetUrlFilter() macht ein
* removeFromPreferences vor dem saveToPreferences iOS behandelt das als
* brandneuen Permission-Request frischer System-Dialog.
*
* Nicht aufrufen wenn der User schon einmal "Erlauben" getippt hat dann
* würde ein unnötiger Dialog kommen. Nur als Workaround bei code 5 nutzen.
*/
resetUrlFilter(): Promise<{ enabled: boolean; error?: string }>;
/**
* iOS: aktiviert NUR Family Controls (Auth + denyAppRemoval = der Lock).
* Triggert iOS-Dialog "Bildschirmzeit verwalten".

View File

@ -1,8 +1,10 @@
import { Alert } from 'react-native';
import { I18nManager } from 'react-native';
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import i18n from '../lib/i18n';
export type AppLanguage = 'de' | 'en' | 'fr';
export type AppLanguage = 'de' | 'en' | 'fr' | 'ar';
const STORAGE_KEY = '@rebreak/language';
@ -12,19 +14,36 @@ type LanguageState = {
init: () => Promise<void>;
};
function applyRTL(lang: AppLanguage) {
const isRTL = lang === 'ar';
if (I18nManager.isRTL !== isRTL) {
I18nManager.forceRTL(isRTL);
return true;
}
return false;
}
export const useLanguageStore = create<LanguageState>((set) => ({
language: 'en',
init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored === 'de' || stored === 'en' || stored === 'fr') {
if (stored === 'de' || stored === 'en' || stored === 'fr' || stored === 'ar') {
applyRTL(stored);
await i18n.changeLanguage(stored);
set({ language: stored });
} else {
// Kein expliziter Wert gespeichert — i18n.ts hat bereits via deviceLocale
// initialisiert (Localization.getLocales()). NICHT auf 'en' overriden.
const detected =
i18n.language === 'de' ? 'de' : i18n.language === 'fr' ? 'fr' : 'en';
i18n.language === 'de'
? 'de'
: i18n.language === 'fr'
? 'fr'
: i18n.language === 'ar'
? 'ar'
: 'en';
applyRTL(detected as AppLanguage);
set({ language: detected as AppLanguage });
}
},
@ -33,5 +52,12 @@ export const useLanguageStore = create<LanguageState>((set) => ({
await AsyncStorage.setItem(STORAGE_KEY, lang);
await i18n.changeLanguage(lang);
set({ language: lang });
const needsReload = applyRTL(lang);
if (needsReload) {
Alert.alert(
i18n.t('settings.rtl_restart_title'),
i18n.t('settings.rtl_restart_body'),
);
}
},
}));