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 { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
|
||||||
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
|
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
|
||||||
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
|
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 { useProtectionState } from '../../hooks/useProtectionState';
|
||||||
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
||||||
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
||||||
@ -66,6 +70,29 @@ export default function BlockerScreen() {
|
|||||||
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const [explainerOpen, setExplainerOpen] = 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 urlFilterActive = state?.layers.urlFilter === true;
|
||||||
const familyControlsActive = state?.layers.familyControls === true;
|
const familyControlsActive = state?.layers.familyControls === true;
|
||||||
@ -102,6 +129,17 @@ export default function BlockerScreen() {
|
|||||||
const result = await activateUrlFilter();
|
const result = await activateUrlFilter();
|
||||||
console.log('[blocker] activateUrlFilter:', result);
|
console.log('[blocker] activateUrlFilter:', result);
|
||||||
if (!result.enabled) {
|
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(
|
Alert.alert(
|
||||||
t('blocker.activate_url_failed_title'),
|
t('blocker.activate_url_failed_title'),
|
||||||
result.error ?? t('blocker.activate_url_failed_msg'),
|
result.error ?? t('blocker.activate_url_failed_msg'),
|
||||||
@ -226,6 +264,18 @@ export default function BlockerScreen() {
|
|||||||
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
|
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
|
{onboardingActive && !urlFilterActive ? (
|
||||||
|
<OnboardingTooltip
|
||||||
|
text={t('onboarding.block_spotlight.body')}
|
||||||
|
colors={colors}
|
||||||
|
arrowOffset={32}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<OnboardingGlow
|
||||||
|
active={onboardingActive && !urlFilterActive}
|
||||||
|
colors={colors}
|
||||||
|
radius={16}
|
||||||
|
>
|
||||||
<LayerSwitchCard
|
<LayerSwitchCard
|
||||||
icon="globe-outline"
|
icon="globe-outline"
|
||||||
title={t('blocker.layers_url_filter_title')}
|
title={t('blocker.layers_url_filter_title')}
|
||||||
@ -237,6 +287,7 @@ export default function BlockerScreen() {
|
|||||||
active={urlFilterActive}
|
active={urlFilterActive}
|
||||||
onActivate={handleActivateUrlFilter}
|
onActivate={handleActivateUrlFilter}
|
||||||
/>
|
/>
|
||||||
|
</OnboardingGlow>
|
||||||
{FAMILY_CONTROLS_AVAILABLE ? (
|
{FAMILY_CONTROLS_AVAILABLE ? (
|
||||||
<LayerSwitchCard
|
<LayerSwitchCard
|
||||||
icon="lock-closed-outline"
|
icon="lock-closed-outline"
|
||||||
@ -454,6 +505,18 @@ export default function BlockerScreen() {
|
|||||||
onBreathe={deflectToBreathe}
|
onBreathe={deflectToBreathe}
|
||||||
onStartCooldown={handleStartCooldown}
|
onStartCooldown={handleStartCooldown}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PermissionDeniedSheet
|
||||||
|
visible={permissionDeniedOpen}
|
||||||
|
onClose={() => setPermissionDeniedOpen(false)}
|
||||||
|
onRetry={async () => {
|
||||||
|
const res = await protection.resetUrlFilter();
|
||||||
|
if (res.enabled) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AppState } from 'react-native';
|
import { AppState, I18nManager } from 'react-native';
|
||||||
|
|
||||||
|
I18nManager.allowRTL(true);
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
@ -17,8 +19,6 @@ import {
|
|||||||
Nunito_800ExtraBold,
|
Nunito_800ExtraBold,
|
||||||
} from '@expo-google-fonts/nunito';
|
} from '@expo-google-fonts/nunito';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { CopilotProvider } from 'react-native-copilot';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
import { useRealtimeDebugStore } from '../stores/realtimeDebug';
|
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() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
@ -229,9 +207,7 @@ export default function RootLayout() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<CopilotShell>
|
|
||||||
<RootLayoutInner />
|
<RootLayoutInner />
|
||||||
</CopilotShell>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@ -106,6 +106,13 @@ export default function DebugScreen() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{me ? (
|
||||||
|
<OnboardingResetToggle
|
||||||
|
colors={colors}
|
||||||
|
currentStep={me.onboardingStep}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<CooldownTestModeToggle />
|
<CooldownTestModeToggle />
|
||||||
|
|
||||||
<RealtimeStatusCard />
|
<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 ────────────────────────────────────────────────────
|
// ─── Cooldown Test Mode ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CooldownTestModeToggle() {
|
function CooldownTestModeToggle() {
|
||||||
|
|||||||
@ -129,7 +129,7 @@ export default function OnboardingWelcomeScreen() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch('/api/profile/me/onboarding-step', {
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ step: 'nickname' }),
|
body: { step: 'nickname' },
|
||||||
});
|
});
|
||||||
invalidateMe();
|
invalidateMe();
|
||||||
router.replace('/profile/edit');
|
router.replace('/profile/edit');
|
||||||
@ -194,7 +194,7 @@ export default function OnboardingWelcomeScreen() {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
paddingTop: insets.top + 24,
|
paddingTop: insets.top + 24,
|
||||||
paddingBottom: insets.bottom + 20,
|
paddingBottom: insets.bottom + 36,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Hero-Icon */}
|
{/* Hero-Icon */}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
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
|
// TODO(sdk54): migrate to new expo-file-system class-based API — see Task #14
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CopilotStep, useCopilot, walkthroughable } from 'react-native-copilot';
|
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
|
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useMe } from '../../hooks/useMe';
|
import { useMe } from '../../hooks/useMe';
|
||||||
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
import { OnboardingTooltip, OnboardingGlow } from '../../components/OnboardingHint';
|
||||||
const WalkthroughView = walkthroughable(View);
|
|
||||||
|
|
||||||
export default function ProfileEditScreen() {
|
export default function ProfileEditScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -32,22 +30,8 @@ export default function ProfileEditScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { me, reload } = useMe();
|
const { me, reload } = useMe();
|
||||||
const copilot = useCopilot();
|
|
||||||
const tourStartedRef = useRef(false);
|
|
||||||
const onboardingActive = me?.onboardingStep === 'nickname';
|
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 = {
|
const INPUT_STYLE = {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
@ -123,16 +107,15 @@ export default function ProfileEditScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Onboarding-Übergang: Nickname → Block (Stage 3 kommt später).
|
// Onboarding-Übergang: Nickname → Block. Direkt zur Blocker-Page, dort
|
||||||
// Wenn nicht im Onboarding-Flow → normale Save/Back-Semantik.
|
// greift der nächste Spotlight.
|
||||||
if (onboardingActive) {
|
if (onboardingActive) {
|
||||||
await apiFetch('/api/profile/me/onboarding-step', {
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { step: 'block' },
|
body: { step: 'block' },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
copilot.stop().catch(() => {});
|
|
||||||
reload();
|
reload();
|
||||||
router.replace('/(app)');
|
router.replace('/(app)/blocker');
|
||||||
} else {
|
} else {
|
||||||
reload();
|
reload();
|
||||||
router.back();
|
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
|
const resolvedPreview = photoUri
|
||||||
? photoUri
|
? photoUri
|
||||||
: resolveAvatar(avatarId, nickname || (me?.nickname ?? ''));
|
: resolveAvatar(avatarId, me?.nickname ?? '');
|
||||||
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
nickname.trim() !== (me?.nickname ?? '') ||
|
nickname.trim() !== (me?.nickname ?? '') ||
|
||||||
@ -169,6 +155,10 @@ export default function ProfileEditScreen() {
|
|||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{onboardingActive ? (
|
||||||
|
// Während Onboarding kein Back — User soll den Step abschließen, nicht zurück.
|
||||||
|
<View style={{ width: 24, marginRight: 12 }} />
|
||||||
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
@ -177,8 +167,11 @@ export default function ProfileEditScreen() {
|
|||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<Text style={{ flex: 1, fontSize: 17, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
<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>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={save}
|
onPress={save}
|
||||||
@ -313,13 +306,18 @@ export default function ProfileEditScreen() {
|
|||||||
>
|
>
|
||||||
{t('profile.edit_nickname_label').toUpperCase()}
|
{t('profile.edit_nickname_label').toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
<CopilotStep
|
|
||||||
name="nickname"
|
{/* Onboarding-Hint: Tooltip-Bubble + Pulse-Glow um Input. Sichtbar nur
|
||||||
order={1}
|
wenn me.onboardingStep === 'nickname'. Verschwindet sobald User
|
||||||
|
speichert (Step → 'block'). */}
|
||||||
|
{onboardingActive ? (
|
||||||
|
<OnboardingTooltip
|
||||||
text={t('onboarding.nickname_spotlight.body')}
|
text={t('onboarding.nickname_spotlight.body')}
|
||||||
active={onboardingActive}
|
colors={colors}
|
||||||
>
|
/>
|
||||||
<WalkthroughView style={{ borderRadius: 12 }}>
|
) : null}
|
||||||
|
|
||||||
|
<OnboardingGlow active={onboardingActive} colors={colors}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={INPUT_STYLE}
|
style={INPUT_STYLE}
|
||||||
value={nickname}
|
value={nickname}
|
||||||
@ -330,9 +328,11 @@ export default function ProfileEditScreen() {
|
|||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
if (!saving && hasChanges && nickname.trim()) save();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</WalkthroughView>
|
</OnboardingGlow>
|
||||||
</CopilotStep>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@ -348,3 +348,4 @@ export default function ProfileEditScreen() {
|
|||||||
</KeyboardAwareScreen>
|
</KeyboardAwareScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -258,6 +258,7 @@ export default function SettingsScreen() {
|
|||||||
{ value: 'de', label: t('settings.language_de') },
|
{ value: 'de', label: t('settings.language_de') },
|
||||||
{ value: 'en', label: t('settings.language_en') },
|
{ value: 'en', label: t('settings.language_en') },
|
||||||
{ value: 'fr', label: t('settings.language_fr') },
|
{ value: 'fr', label: t('settings.language_fr') },
|
||||||
|
{ value: 'ar', label: t('settings.language_ar') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const voiceLabel =
|
const voiceLabel =
|
||||||
@ -321,6 +322,8 @@ export default function SettingsScreen() {
|
|||||||
? t('settings.language_de')
|
? t('settings.language_de')
|
||||||
: language === 'fr'
|
: language === 'fr'
|
||||||
? t('settings.language_fr')
|
? t('settings.language_fr')
|
||||||
|
: language === 'ar'
|
||||||
|
? t('settings.language_ar')
|
||||||
: t('settings.language_en'),
|
: t('settings.language_en'),
|
||||||
menu: {
|
menu: {
|
||||||
title: t('settings.language'),
|
title: t('settings.language'),
|
||||||
|
|||||||
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 de from '../locales/de.json';
|
||||||
import en from '../locales/en.json';
|
import en from '../locales/en.json';
|
||||||
import fr from '../locales/fr.json';
|
import fr from '../locales/fr.json';
|
||||||
|
import ar from '../locales/ar.json';
|
||||||
|
|
||||||
const deviceLocale = Localization.getLocales()[0]?.languageCode ?? 'en';
|
const deviceLocale = Localization.getLocales()[0]?.languageCode ?? 'en';
|
||||||
const initialLng =
|
const initialLng =
|
||||||
deviceLocale === 'de' ? 'de' : deviceLocale === 'fr' ? 'fr' : 'en';
|
deviceLocale === 'de'
|
||||||
|
? 'de'
|
||||||
|
: deviceLocale === 'fr'
|
||||||
|
? 'fr'
|
||||||
|
: deviceLocale === 'ar'
|
||||||
|
? 'ar'
|
||||||
|
: 'en';
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
de: { translation: de },
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
|
ar: { translation: ar },
|
||||||
},
|
},
|
||||||
lng: initialLng,
|
lng: initialLng,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
|||||||
@ -115,6 +115,22 @@ export const protection = {
|
|||||||
return RebreakProtection.activateUrlFilter();
|
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 }> {
|
async activateFamilyControls(): Promise<{ enabled: boolean; error?: string }> {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
// Android "App-Lock" = AccessibilityService als reiner Tamper-Lock (KEIN
|
// 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_title": "URL-Filter konnte nicht aktiviert werden",
|
||||||
"activate_url_failed_msg": "Unbekannter Fehler.\nDu kannst es nochmal versuchen oder System-Einstellungen prüfen.",
|
"activate_url_failed_msg": "Unbekannter Fehler.\nDu kannst es nochmal versuchen oder System-Einstellungen prüfen.",
|
||||||
"activate_settings_btn": "Einstellungen",
|
"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_title": "Schutz ist aus",
|
||||||
"protection_off_message": "Der Filter läuft gerade nicht, sollte aber an sein. Willst du ihn wieder einschalten?",
|
"protection_off_message": "Der Filter läuft gerade nicht, sollte aber an sein. Willst du ihn wieder einschalten?",
|
||||||
"reactivate_btn": "Wieder einschalten",
|
"reactivate_btn": "Wieder einschalten",
|
||||||
@ -349,8 +358,8 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"headline": "Willkommen bei rebreak.",
|
"headline": "Willkommen bei ReBreak.",
|
||||||
"subhead": "Wir helfen dir, raus aus dem Glücksspiel zu kommen — anonym, geschützt, gemeinsam.",
|
"subhead": "Dein Weg raus aus dem Glücksspiel — anonym, geschützt, und nicht allein.",
|
||||||
"bullet_anon_title": "Du bleibst anonym",
|
"bullet_anon_title": "Du bleibst anonym",
|
||||||
"bullet_anon_desc": "Du wählst einen Alias. Niemand sieht deinen echten Namen — auch wir nicht.",
|
"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",
|
"bullet_protect_title": "Dein Gerät wird geschützt",
|
||||||
@ -363,6 +372,10 @@
|
|||||||
"cta_loading": "Einen Moment...",
|
"cta_loading": "Einen Moment...",
|
||||||
"next_hint": "Im nächsten Schritt wählst du deinen Alias."
|
"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": {
|
"nickname_spotlight": {
|
||||||
"title": "Wähle deinen Alias",
|
"title": "Wähle deinen Alias",
|
||||||
"body": "Das ist dein einziger Name in rebreak. Niemand sieht deine Mail oder deinen echten Namen.",
|
"body": "Das ist dein einziger Name in rebreak. Niemand sieht deine Mail oder deinen echten Namen.",
|
||||||
@ -579,7 +592,7 @@
|
|||||||
"push_notifications": "Push-Benachrichtigungen",
|
"push_notifications": "Push-Benachrichtigungen",
|
||||||
"streak_reminders": "Streak-Erinnerungen",
|
"streak_reminders": "Streak-Erinnerungen",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"language_desc": "Deutsch / Englisch / Französisch",
|
"language_desc": "Deutsch / Englisch / Französisch / Arabisch",
|
||||||
"language_current": "Deutsch",
|
"language_current": "Deutsch",
|
||||||
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
|
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
|
||||||
"delete_account": "Konto löschen",
|
"delete_account": "Konto löschen",
|
||||||
@ -615,6 +628,7 @@
|
|||||||
"language_de": "Deutsch",
|
"language_de": "Deutsch",
|
||||||
"language_en": "English",
|
"language_en": "English",
|
||||||
"language_fr": "Français",
|
"language_fr": "Français",
|
||||||
|
"language_ar": "العربية",
|
||||||
"lyra_voice_default": "Standard",
|
"lyra_voice_default": "Standard",
|
||||||
"lyra_voice_1": "Stimme 1",
|
"lyra_voice_1": "Stimme 1",
|
||||||
"lyra_voice_2": "Stimme 2",
|
"lyra_voice_2": "Stimme 2",
|
||||||
@ -667,7 +681,9 @@
|
|||||||
"help_about": "Über Rebreak",
|
"help_about": "Über Rebreak",
|
||||||
"help_about_desc": "Mission, Datenschutz, DiGA-Pfad",
|
"help_about_desc": "Mission, Datenschutz, DiGA-Pfad",
|
||||||
"help_crisis": "Krisen-Hilfe",
|
"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": {
|
"device_limit": {
|
||||||
"title": "Geräte-Limit erreicht",
|
"title": "Geräte-Limit erreicht",
|
||||||
|
|||||||
@ -257,6 +257,15 @@
|
|||||||
"protection_stat_method_native": "Native",
|
"protection_stat_method_native": "Native",
|
||||||
"protection_stat_status": "Status",
|
"protection_stat_status": "Status",
|
||||||
"protection_stat_status_live": "Live",
|
"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_title": "Could not activate URL filter",
|
||||||
"activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.",
|
"activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.",
|
||||||
"activate_settings_btn": "Settings",
|
"activate_settings_btn": "Settings",
|
||||||
@ -349,8 +358,8 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"headline": "Welcome to rebreak.",
|
"headline": "Welcome to ReBreak.",
|
||||||
"subhead": "We help you get out of gambling — anonymously, protected, together.",
|
"subhead": "Your way out of gambling — anonymous, protected, and not alone.",
|
||||||
"bullet_anon_title": "You stay anonymous",
|
"bullet_anon_title": "You stay anonymous",
|
||||||
"bullet_anon_desc": "You pick an alias. No one sees your real name — not even us.",
|
"bullet_anon_desc": "You pick an alias. No one sees your real name — not even us.",
|
||||||
"bullet_protect_title": "Your device gets protected",
|
"bullet_protect_title": "Your device gets protected",
|
||||||
@ -363,6 +372,10 @@
|
|||||||
"cta_loading": "One moment...",
|
"cta_loading": "One moment...",
|
||||||
"next_hint": "Next, you'll pick your alias."
|
"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": {
|
"nickname_spotlight": {
|
||||||
"title": "Pick your alias",
|
"title": "Pick your alias",
|
||||||
"body": "This is your only name on rebreak. No one sees your email or real name.",
|
"body": "This is your only name on rebreak. No one sees your email or real name.",
|
||||||
@ -579,7 +592,7 @@
|
|||||||
"push_notifications": "Push notifications",
|
"push_notifications": "Push notifications",
|
||||||
"streak_reminders": "Streak reminders",
|
"streak_reminders": "Streak reminders",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"language_desc": "German / English / French",
|
"language_desc": "German / English / French / Arabic",
|
||||||
"language_current": "English",
|
"language_current": "English",
|
||||||
"upgrade_cta": "Upgrade to Pro — €29/year",
|
"upgrade_cta": "Upgrade to Pro — €29/year",
|
||||||
"delete_account": "Delete account",
|
"delete_account": "Delete account",
|
||||||
@ -615,6 +628,7 @@
|
|||||||
"language_de": "Deutsch",
|
"language_de": "Deutsch",
|
||||||
"language_en": "English",
|
"language_en": "English",
|
||||||
"language_fr": "Français",
|
"language_fr": "Français",
|
||||||
|
"language_ar": "العربية",
|
||||||
"lyra_voice_default": "Default",
|
"lyra_voice_default": "Default",
|
||||||
"lyra_voice_1": "Voice 1",
|
"lyra_voice_1": "Voice 1",
|
||||||
"lyra_voice_2": "Voice 2",
|
"lyra_voice_2": "Voice 2",
|
||||||
@ -667,7 +681,9 @@
|
|||||||
"help_about": "About Rebreak",
|
"help_about": "About Rebreak",
|
||||||
"help_about_desc": "Mission, privacy, DiGA path",
|
"help_about_desc": "Mission, privacy, DiGA path",
|
||||||
"help_crisis": "Crisis help",
|
"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": {
|
"device_limit": {
|
||||||
"title": "Device limit reached",
|
"title": "Device limit reached",
|
||||||
|
|||||||
@ -260,6 +260,15 @@
|
|||||||
"activate_url_failed_title": "Impossible d'activer le filtre URL",
|
"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_url_failed_msg": "Erreur inconnue.\nVous pouvez réessayer ou vérifier les réglages système.",
|
||||||
"activate_settings_btn": "Paramètres",
|
"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_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 ?",
|
"protection_off_message": "Le filtre ne fonctionne pas alors qu'il devrait être actif. Voulez-vous le réactiver ?",
|
||||||
"reactivate_btn": "Réactiver",
|
"reactivate_btn": "Réactiver",
|
||||||
@ -347,8 +356,8 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"headline": "Bienvenue sur rebreak.",
|
"headline": "Bienvenue sur ReBreak.",
|
||||||
"subhead": "On t'aide à sortir du jeu — anonymement, en sécurité, ensemble.",
|
"subhead": "Ta voie hors du jeu — anonyme, protégée, et pas seul·e.",
|
||||||
"bullet_anon_title": "Tu restes anonyme",
|
"bullet_anon_title": "Tu restes anonyme",
|
||||||
"bullet_anon_desc": "Tu choisis un alias. Personne ne voit ton vrai nom — nous non plus.",
|
"bullet_anon_desc": "Tu choisis un alias. Personne ne voit ton vrai nom — nous non plus.",
|
||||||
"bullet_protect_title": "Ton appareil est protégé",
|
"bullet_protect_title": "Ton appareil est protégé",
|
||||||
@ -361,6 +370,10 @@
|
|||||||
"cta_loading": "Un instant...",
|
"cta_loading": "Un instant...",
|
||||||
"next_hint": "À l'étape suivante, tu choisiras ton alias."
|
"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": {
|
"nickname_spotlight": {
|
||||||
"title": "Choisis ton alias",
|
"title": "Choisis ton alias",
|
||||||
"body": "C'est ton seul nom sur rebreak. Personne ne voit ton e-mail ni ton vrai nom.",
|
"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",
|
"push_notifications": "Notifications push",
|
||||||
"streak_reminders": "Rappels de série",
|
"streak_reminders": "Rappels de série",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"language_desc": "Allemand / Anglais / Français",
|
"language_desc": "Allemand / Anglais / Français / Arabe",
|
||||||
"language_current": "Français",
|
"language_current": "Français",
|
||||||
"upgrade_cta": "Passer à Pro — 29 €/an",
|
"upgrade_cta": "Passer à Pro — 29 €/an",
|
||||||
"delete_account": "Supprimer le compte",
|
"delete_account": "Supprimer le compte",
|
||||||
@ -602,6 +615,7 @@
|
|||||||
"language_de": "Deutsch",
|
"language_de": "Deutsch",
|
||||||
"language_en": "English",
|
"language_en": "English",
|
||||||
"language_fr": "Français",
|
"language_fr": "Français",
|
||||||
|
"language_ar": "العربية",
|
||||||
"lyra_voice_default": "Par défaut",
|
"lyra_voice_default": "Par défaut",
|
||||||
"lyra_voice_1": "Voix 1",
|
"lyra_voice_1": "Voix 1",
|
||||||
"lyra_voice_2": "Voix 2",
|
"lyra_voice_2": "Voix 2",
|
||||||
@ -654,7 +668,9 @@
|
|||||||
"help_about": "À propos de Rebreak",
|
"help_about": "À propos de Rebreak",
|
||||||
"help_about_desc": "Mission, confidentialité, parcours DiGA",
|
"help_about_desc": "Mission, confidentialité, parcours DiGA",
|
||||||
"help_crisis": "Aide en crise",
|
"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": {
|
"device_limit": {
|
||||||
"title": "Limite d'appareils atteinte",
|
"title": "Limite d'appareils atteinte",
|
||||||
|
|||||||
@ -89,6 +89,54 @@ public class RebreakProtectionModule: Module {
|
|||||||
return result
|
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 ─────────
|
// ───────── activateFamilyControls: NUR FC + denyAppRemoval ─────────
|
||||||
|
|
||||||
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
|
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
|
||||||
|
|||||||
@ -19,6 +19,18 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
|
|||||||
*/
|
*/
|
||||||
activateUrlFilter(): Promise<{ enabled: boolean; error?: string }>;
|
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).
|
* iOS: aktiviert NUR Family Controls (Auth + denyAppRemoval = der Lock).
|
||||||
* Triggert iOS-Dialog "Bildschirmzeit verwalten".
|
* Triggert iOS-Dialog "Bildschirmzeit verwalten".
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { I18nManager } from 'react-native';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import i18n from '../lib/i18n';
|
import i18n from '../lib/i18n';
|
||||||
|
|
||||||
export type AppLanguage = 'de' | 'en' | 'fr';
|
export type AppLanguage = 'de' | 'en' | 'fr' | 'ar';
|
||||||
|
|
||||||
const STORAGE_KEY = '@rebreak/language';
|
const STORAGE_KEY = '@rebreak/language';
|
||||||
|
|
||||||
@ -12,19 +14,36 @@ type LanguageState = {
|
|||||||
init: () => Promise<void>;
|
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) => ({
|
export const useLanguageStore = create<LanguageState>((set) => ({
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
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);
|
await i18n.changeLanguage(stored);
|
||||||
set({ language: stored });
|
set({ language: stored });
|
||||||
} else {
|
} else {
|
||||||
// Kein expliziter Wert gespeichert — i18n.ts hat bereits via deviceLocale
|
// Kein expliziter Wert gespeichert — i18n.ts hat bereits via deviceLocale
|
||||||
// initialisiert (Localization.getLocales()). NICHT auf 'en' overriden.
|
// initialisiert (Localization.getLocales()). NICHT auf 'en' overriden.
|
||||||
const detected =
|
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 });
|
set({ language: detected as AppLanguage });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -33,5 +52,12 @@ export const useLanguageStore = create<LanguageState>((set) => ({
|
|||||||
await AsyncStorage.setItem(STORAGE_KEY, lang);
|
await AsyncStorage.setItem(STORAGE_KEY, lang);
|
||||||
await i18n.changeLanguage(lang);
|
await i18n.changeLanguage(lang);
|
||||||
set({ language: 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