rebreak-monorepo/apps/rebreak-native/components/PermissionDeniedSheet.tsx
chahinebrini 1c9e67c256 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>
2026-05-17 15:44:32 +02:00

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>
);
}