## Backend: Anti-Auto-Reactivation nach Cooldown
Bug: nach Cooldown-Ablauf wurde der URL-Filter automatisch wieder
reaktiviert (enforceProtection-Loop fängt 'recoveringFromBypass'-Phase ab).
Damit war der Cooldown-Schritt entwertet — User konnte nicht wirklich
abschalten, weil die App den Schutz sofort wieder hochfuhr.
Fix: Profile.protectionDisabledAt (DateTime nullable). Wird in
/api/cooldown/status auf cooldown-auto-resolve gesetzt. /api/protection/state
gibt dann protectionShouldBeActive=false zurück → Frontend macht KEINE
Auto-Reactivation. User muss explizit re-aktivieren (CTA in der App).
- Migration 20260517_protection_disabled_at
- Schema: Profile.protectionDisabledAt
- /api/cooldown/status: setzt das Feld auf expired+resolve
- /api/protection/state: includes profile.protectionDisabledAt in shouldBeActive-Berechnung
- /api/protection/mark-active (POST, NEU): clears das Feld, vom Frontend
auto-aufgerufen nach erfolgreichem activateUrlFilter
Bypass-Recovery durch externe iOS-Settings-Disable (nicht cooldown-bezogen)
funktioniert weiter — protectionDisabledAt ist dann null, alte Logik greift.
## Frontend: ProtectionOffSheet (Custom-Sheet statt Alert.alert)
Bisheriges native Alert mit OK+Reactivate-Buttons hat keine visuelle
Hierarchy (iOS macht beide gleich). Ersetzt mit FormSheet:
- Großer blauer Primary "Schutz wieder einschalten"
- Ghost-Link "Später"
- Swipe-down / Backdrop-Tap zum Schließen
## Frontend: ProtectionSlide mit Pre-Explainer (Screenshot + Pulse-Marker)
User-Request: vor dem iOS-Permission-Dialog ein Erklärungs-Screen zeigen
damit der User weiß wo er tappen muss (Apple's "Don't Allow" ist groß+
blau = Trap, "Allow" ist der unscheinbare Button unten).
- components/onboarding/ScreenshotPointer.tsx — Reanimated pulsing red
ring, positionierbar via {xPercent, yPercent}
- lib/onboardingAssets.ts — locale-aware require()-Map für Screenshot-
Assets mit de-Fallback
- assets/onboarding/de/ — 4 iOS-Screenshots vom User (url_filter +
screen_time permission dialogs + 2 confirm screens)
- ProtectionSlide refactored: internal phase state preexplain_url →
preexplain_lock → done. Jede Phase zeigt Screenshot + Pulse-Marker auf
korrekten Button + Lyra-Bubble + activate-CTA.
## Locale-Keys
- onboarding.lyra.protection_url.body, onboarding.lyra.protection_lock.body
- onboarding.protection.url_title, .lock_title, .tap_marker_hint
- onboarding.protection.applock_failed_*, applock_skip
- blocker.protection_off_later, reactivate_btn (refined)
## Bugfix: de.json JSON-syntax
Smart-quote-typo: schließendes "" nach „Erlauben" und „Fortfahren" war
ein plain ASCII " (U+0022) statt U+201D, was den JSON-String früh
terminiert hat. Metro+Hermes warfen "unrecognized Unicode —".
Fix: escapte \" verwendet — JSON-safe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
255 lines
8.1 KiB
TypeScript
255 lines
8.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { Alert, Image, Platform, Text, 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, getPointerPosition } 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 lang = i18n.language || 'de';
|
|
const screenshot = getPermissionScreenshot(dialog, lang);
|
|
const pointer = getPointerPosition(dialog);
|
|
|
|
return (
|
|
<OnboardingShell
|
|
current={current}
|
|
total={total}
|
|
cta={
|
|
<CTABar
|
|
primaryLabel={t(ctaKey)}
|
|
onPrimary={onActivate}
|
|
primaryLoading={activating}
|
|
/>
|
|
}
|
|
>
|
|
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
|
|
|
|
<Text
|
|
style={{
|
|
marginTop: 22,
|
|
fontFamily: 'Nunito_700Bold',
|
|
fontSize: 14,
|
|
letterSpacing: 0.5,
|
|
color: colors.textMuted,
|
|
textTransform: 'uppercase',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{t(titleKey)}
|
|
</Text>
|
|
|
|
{/* Screenshot mit Pulse-Pointer auf den korrekten Button */}
|
|
<View
|
|
style={{
|
|
marginTop: 12,
|
|
alignSelf: 'center',
|
|
width: '92%',
|
|
aspectRatio: 0.9, // Screenshots sind etwa quadratisch / leicht hochkant
|
|
borderRadius: 18,
|
|
overflow: 'hidden',
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
}}
|
|
>
|
|
<Image
|
|
source={screenshot}
|
|
style={{ width: '100%', height: '100%' }}
|
|
resizeMode="contain"
|
|
/>
|
|
<ScreenshotPointer xPercent={pointer.xPercent} yPercent={pointer.yPercent} />
|
|
</View>
|
|
|
|
<Text
|
|
style={{
|
|
marginTop: 16,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontSize: 13,
|
|
lineHeight: 19,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
paddingHorizontal: 8,
|
|
}}
|
|
>
|
|
{t('onboarding.protection.tap_marker_hint')}
|
|
</Text>
|
|
{children}
|
|
</OnboardingShell>
|
|
);
|
|
}
|