## 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>
266 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|