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:
parent
38a8517259
commit
1c9e67c256
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
169
apps/rebreak-native/components/OnboardingHint.tsx
Normal file
169
apps/rebreak-native/components/OnboardingHint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
apps/rebreak-native/components/PermissionDeniedSheet.tsx
Normal file
180
apps/rebreak-native/components/PermissionDeniedSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
1151
apps/rebreak-native/locales/ar.json
Normal file
1151
apps/rebreak-native/locales/ar.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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".
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user