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>
181 lines
5.3 KiB
TypeScript
181 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|