## Duo-Style Onboarding (Foundation + alle Slides) Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom vorherigen Iteration. Slides leben unter `components/onboarding/slides/`. - Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar), LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar - Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me), DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors), Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall- Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate + PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1) - State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume- on-launch via slideFromStep(me.onboardingStep) - Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding - Backend Profile.onboardingStep enum extended: welcome | account | plan | pre_protection | done (+ legacy nickname/block) - Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch durch Protection-Slide für NEFilter/VPN-Aktivierung - Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary, Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis), Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions ## Cooldown Auto-Disable Race-Fix Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS- Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET auto-resolved beim ersten expired-Hit; zweiter Call in applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable nie aufgerufen. - useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen statt redundantem API-Call. Atomarer, race-frei. - AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein Race). - lib/protection.forceDisable: console.log für Debug-Visibility. ## iOS NEFilter Robust-Disable (Native) `removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings- UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern: 1. loadFromPreferences 2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon) 3. removeFromPreferences (Config-Eintrag aus Settings) Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in PermissionDeniedSheet's resetUrlFilter genutzt (analog). ## Family Controls jetzt immer aktiv Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert). `familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr). "Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist jetzt voll funktional auf iOS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
84 lines
2.3 KiB
TypeScript
84 lines
2.3 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Animated, Easing, Text, View } from 'react-native';
|
|
import { RiveAvatar, type Emotion } from '../RiveAvatar';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
/**
|
|
* Lyra-Mascot (animiertes Rive-Avatar) links + Speech-Bubble rechts.
|
|
* Fade+slide-in beim Mount und bei text-change (key-prop verwenden für Re-Animate).
|
|
*
|
|
* Layout entspricht Duolingo's Duo-Speech-Pattern: Avatar quadratisch links,
|
|
* Bubble organisch rechts mit "Tail" zum Avatar zeigend.
|
|
*/
|
|
export function LyraBubble({
|
|
text,
|
|
emotion = 'idle',
|
|
}: {
|
|
text: string;
|
|
/** Lyra-Emotion fürs Rive (idle, happy, thinking, empathy). */
|
|
emotion?: Emotion;
|
|
}) {
|
|
const colors = useColors();
|
|
const opacity = useRef(new Animated.Value(0)).current;
|
|
const translateX = useRef(new Animated.Value(-12)).current;
|
|
|
|
useEffect(() => {
|
|
opacity.setValue(0);
|
|
translateX.setValue(-12);
|
|
Animated.parallel([
|
|
Animated.timing(opacity, {
|
|
toValue: 1,
|
|
duration: 400,
|
|
useNativeDriver: true,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
Animated.spring(translateX, {
|
|
toValue: 0,
|
|
useNativeDriver: true,
|
|
friction: 7,
|
|
tension: 80,
|
|
}),
|
|
]).start();
|
|
}, [text, opacity, translateX]);
|
|
|
|
return (
|
|
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
|
|
<View style={{ marginTop: 4 }}>
|
|
<RiveAvatar emotion={emotion} size="md" />
|
|
</View>
|
|
|
|
<Animated.View
|
|
style={{
|
|
flex: 1,
|
|
opacity,
|
|
transform: [{ translateX }],
|
|
}}
|
|
>
|
|
{/* Speech-Bubble */}
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 16,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
// Tail würde via custom-path gerendert — hier reicht der reine
|
|
// Bubble-Look ohne Tail (cleaner auf RN, Duolingo macht's auf iOS
|
|
// auch ohne harten Pixel-Tail).
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
fontSize: 16,
|
|
lineHeight: 23,
|
|
color: colors.text,
|
|
}}
|
|
>
|
|
{text}
|
|
</Text>
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
}
|