chahinebrini 33aa3464b8 feat(onboarding): protection pointer redesign + i18n screenshots + lockedIn fix
## Protection Pre-Explainer: External Pointer

Vorher: Pulse-Ring absolute-positioniert IM Screenshot — Position musste
per-locale fine-tuned werden weil Apple-Dialog-Höhe variiert (DE/EN/FR/AR
haben unterschiedliche Text-Längen → Dialog hat verschiedene Höhen →
Erlauben-Button rutscht).

Jetzt: animierter Pfeil + Label-Pill UNTER dem Screenshot. Dimensions-
agnostic, funktioniert in allen 4 Sprachen ohne Locale-spezifische Magie.

- ScreenshotPointer komplett refactored: caret-up + bouncing pill mit
  Button-Label-Text (z.B. 'Tippe "Erlauben"' / 'Tap "Allow"' / etc.)
- onboardingAssets.ts: getPointerPosition deprecated/entfernt
- ProtectionSlide nutzt neue API mit buttonLabelKey
- 4 Locales: dialog_button_allow + dialog_button_continue
- tap_marker_hint refined (kein "roter Marker"-Ref mehr)

## i18n-aware Screenshots

en/fr/ar Permission-Dialog-Screenshots zur Map ergänzt. Resolver fällt
auf de zurück wenn andere Sprache fehlt.

## Dynamic Sizing

ProtectionSlide nutzt useWindowDimensions:
  height: min(320, max(200, screenH * 0.32))
→ passt auf iPhone SE (213px) bis Pro Max (320px capped) ohne Scroll.

OnboardingShell ScrollView-Padding reduziert (16→12 top, 24→16 bottom).
ProtectionSlide-Spacing tightened.

## Blocker: lockedIn Fix

Bug: `lockedIn = appDeletionLockActive` ignorierte URL-Filter-State —
wenn User nur FC aktivierte (ohne URL-Filter), zeigte App grünen "Schutz
aktiv"-Banner obwohl URL-Filter aus war. Fix:
  lockedIn = urlFilter && appDeletionLock
→ Beide müssen wirklich aktiv sein für den grünen Banner.

## LayerSwitchCard: lockedHint Prop

Optional Hint-Text der unter dem active Layer angezeigt wird, z.B.
"System-gesperrt. Nur in iOS-Einstellungen → Bildschirmzeit → Verwaltung
durch ReBreak deaktivierbar.". Wird für iOS App-Lock-Card genutzt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:58:56 +02:00

266 lines
8.5 KiB
TypeScript

import { useState } from 'react';
import { Alert, Image, Platform, Text, useWindowDimensions, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection } from '../../../lib/protection';
import { getPermissionScreenshot } from '../../../lib/onboardingAssets';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
import { ScreenshotPointer } from '../ScreenshotPointer';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
import i18n from '../../../lib/i18n';
/**
* Onboarding Step Protection — 2 Phasen, beide mit Pre-Explainer-Modal das
* den iOS-Permission-Dialog vorzeigt + Pulse-Marker auf den "Erlauben"-Button.
*
* ┌──────────────────────────────────────────────────────────────┐
* │ Phase A: preexplain_url │
* │ Lyra: "Gleich kommt iOS-Dialog. Tippe ERLAUBEN." │
* │ Screenshot vom NEFilter-Dialog + roter Pulse auf "Erlauben" │
* │ CTA "Aktivieren" → triggers protection.activateUrlFilter() │
* │ │
* │ Phase B: preexplain_lock │
* │ Lyra: "Jetzt App-Schutz. Tippe FORTFAHREN." │
* │ Screenshot vom Screen-Time-Dialog + roter Pulse │
* │ CTA "Aktivieren" → triggers protection.activateFamilyControls │
* │ │
* │ Phase C: done → onDone() │
* └──────────────────────────────────────────────────────────────┘
*
* Wenn URL-Filter fehlschlägt mit code 5 → PermissionDeniedSheet öffnet sich
* (Retry-Pfad via resetUrlFilter()). Family-Controls hat keinen analogen
* Recovery-Sheet — User muss in Settings → Bildschirmzeit den App-Zugriff
* gewähren.
*/
type Phase = 'preexplain_url' | 'preexplain_lock' | 'done';
export function ProtectionSlide({
onDone,
current,
total,
}: {
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [phase, setPhase] = useState<Phase>('preexplain_url');
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
async function activateUrlFilter() {
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;
}
// Filter live → weiter zur Phase B (App-Lock)
setPhase('preexplain_lock');
} finally {
setActivating(false);
}
}
async function activateAppLock() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateFamilyControls();
if (!res.enabled) {
// Family Controls fehlgeschlagen → User Info aber Tour-Done (URL-Filter
// läuft schon, das ist der Hauptschutz; App-Lock ist optional)
Alert.alert(
t('onboarding.protection.applock_failed_title'),
res.error ?? t('onboarding.protection.applock_failed_msg'),
[
{
text: t('onboarding.protection.applock_skip'),
style: 'cancel',
onPress: () => finishProtectionStep(),
},
{ text: t('common.retry'), onPress: activateAppLock },
],
);
return;
}
finishProtectionStep();
} finally {
setActivating(false);
}
}
async function finishProtectionStep() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
setPhase('done');
onDone();
}
return phase === 'preexplain_url' ? (
<PreExplainer
key="url"
dialog="url_filter"
lyraBodyKey="onboarding.lyra.protection_url.body"
titleKey="onboarding.protection.url_title"
ctaKey="onboarding.protection.cta_primary"
activating={activating}
onActivate={activateUrlFilter}
current={current}
total={total}
>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) setPhase('preexplain_lock');
return res;
}}
/>
</PreExplainer>
) : phase === 'preexplain_lock' ? (
<PreExplainer
key="lock"
dialog="screen_time"
lyraBodyKey="onboarding.lyra.protection_lock.body"
titleKey="onboarding.protection.lock_title"
ctaKey="onboarding.protection.cta_primary"
activating={activating}
onActivate={activateAppLock}
current={current}
total={total}
/>
) : null;
}
// ─── PreExplainer (shared) ───────────────────────────────────────────────────
function PreExplainer({
dialog,
lyraBodyKey,
titleKey,
ctaKey,
activating,
onActivate,
current,
total,
children,
}: {
dialog: 'url_filter' | 'screen_time';
lyraBodyKey: string;
titleKey: string;
ctaKey: string;
activating: boolean;
onActivate: () => void;
current: number;
total: number;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const colors = useColors();
const { height: screenH } = useWindowDimensions();
const lang = i18n.language || 'de';
const screenshot = getPermissionScreenshot(dialog, lang);
const buttonLabelKey =
dialog === 'url_filter'
? 'onboarding.protection.dialog_button_allow'
: 'onboarding.protection.dialog_button_continue';
// Dynamische Screenshot-Höhe: Auf kleinen Phones (SE/mini ~667-844 pt)
// capped damit alles + CTA-Bar ohne Scroll passt. Auf großen Phones/iPad
// skaliert es mit. Min 200, Max 320.
const screenshotHeight = Math.min(320, Math.max(200, screenH * 0.32));
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t(ctaKey)}
onPrimary={onActivate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t(titleKey)}
</Text>
{/* Screenshot — sauber ohne Overlay. Dynamisch dimensioniert. */}
<View
style={{
marginTop: 8,
alignSelf: 'center',
height: screenshotHeight,
aspectRatio: 0.9,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Image
source={screenshot}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
</View>
{/* Animierter Pointer UNTER dem Screenshot — Dimensions-agnostic. */}
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} />
<Text
style={{
marginTop: 10,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 17,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 8,
}}
>
{t('onboarding.protection.tap_marker_hint')}
</Text>
{children}
</OnboardingShell>
);
}