chahinebrini b23bd6d29f feat(onboarding,protection): Duo-style flow + cooldown auto-disable fix + Family Controls live
## 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>
2026-05-17 17:48:05 +02:00

196 lines
5.3 KiB
TypeScript

import { useState } from 'react';
import { Alert, Platform, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection } from '../../../lib/protection';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
export function ProtectionSlide({
onDone,
current,
total,
}: {
/** Wird gerufen wenn URL-Filter erfolgreich aktiviert wurde. */
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
async function activate() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateUrlFilter();
if (!res.enabled) {
const isCodeFive =
Platform.OS === 'ios' &&
typeof res.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(res.error);
if (isCodeFive) {
setPermissionDeniedOpen(true);
return;
}
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
return;
}
// Schutz live → step='done'
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
onDone();
} catch (e: unknown) {
Alert.alert(
t('common.error'),
e instanceof Error ? e.message : t('common.unknown_error'),
);
} finally {
setActivating(false);
}
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.protection.cta_primary')}
onPrimary={activate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
<View
style={{
marginTop: 24,
padding: 16,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
gap: 12,
}}
>
<ProtectionRow
icon="globe-outline"
title={t('onboarding.protection.feat_blocklist_title')}
desc={t('onboarding.protection.feat_blocklist_desc')}
colors={colors}
/>
<ProtectionRow
icon={Platform.OS === 'ios' ? 'shield-checkmark-outline' : 'lock-closed-outline'}
title={t(
Platform.OS === 'ios'
? 'onboarding.protection.feat_ios_title'
: 'onboarding.protection.feat_android_title',
)}
desc={t(
Platform.OS === 'ios'
? 'onboarding.protection.feat_ios_desc'
: 'onboarding.protection.feat_android_desc',
)}
colors={colors}
/>
<ProtectionRow
icon="time-outline"
title={t('onboarding.protection.feat_cooldown_title')}
desc={t('onboarding.protection.feat_cooldown_desc')}
colors={colors}
/>
</View>
<Text
style={{
marginTop: 16,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 18,
color: colors.textMuted,
textAlign: 'center',
}}
>
{t('onboarding.protection.permission_note')}
</Text>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
onDone();
}
return res;
}}
/>
</OnboardingShell>
);
}
function ProtectionRow({
icon,
title,
desc,
colors,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
desc: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: 'rgba(0,122,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color={colors.brandOrange} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 14, color: colors.text }}>
{title}
</Text>
<Text
style={{
marginTop: 2,
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 18,
color: colors.textMuted,
}}
>
{desc}
</Text>
</View>
</View>
);
}