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

170 lines
4.2 KiB
TypeScript

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