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>
170 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|