chahinebrini 1596a4ea7a feat(protection,onboarding): anti-auto-reactivation + protection pre-explainer + custom sheets
## 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>
2026-05-17 19:05:37 +02:00

135 lines
3.9 KiB
TypeScript

import { useState } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../lib/theme';
import { FormSheet } from './FormSheet';
/**
* Erinnerungs-Sheet wenn der Schutz auf dem Device aus ist (z.B. User hat
* in iOS-Settings das Filter-Profil manuell entfernt) und das Backend ihn
* trotzdem als aktiv erwartet (= bypassed/recovering).
*
* Ersetzt das native Alert.alert weil:
* - iOS Alert kann den Reactivate-Button nicht visuell höher gewichten
* (OK + Reactivate sehen gleich aus, oder OK ist sogar fetter wenn als
* cancel-style)
* - Wir wollen einen klaren blauen Primary "Schutz einschalten" + dezenten
* ghost-Link "Später" — kein "Two-Equal-Buttons"-Look
*
* Schließbar via swipe-down / Backdrop-Tap / "Später"-Link.
*/
export function ProtectionOffSheet({
visible,
onClose,
onReactivate,
}: {
visible: boolean;
onClose: () => void;
/** Wird gerufen wenn User "Schutz wieder einschalten" tappt — soll
* handleActivateUrlFilter o.ä. callen. */
onReactivate: () => Promise<void> | void;
}) {
const { t } = useTranslation();
const colors = useColors();
const [busy, setBusy] = useState(false);
async function handleReactivate() {
if (busy) return;
setBusy(true);
try {
await onReactivate();
onClose();
} finally {
setBusy(false);
}
}
return (
<FormSheet
visible={visible}
onClose={onClose}
title={t('blocker.protection_off_title')}
initialHeightPct={0.5}
minHeightPct={0.3}
>
<View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16 }}>
{/* Hero-Icon */}
<View style={{ alignItems: 'center', marginTop: 8, marginBottom: 16 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: 'rgba(245,158,11,0.12)',
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.30)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="shield-outline" size={32} color={colors.warning} />
</View>
</View>
{/* Body-Text */}
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 14,
lineHeight: 21,
color: colors.text,
textAlign: 'center',
marginBottom: 22,
}}
>
{t('blocker.protection_off_message')}
</Text>
{/* Primary CTA — blau, prominent */}
<TouchableOpacity
onPress={handleReactivate}
disabled={busy}
activeOpacity={0.85}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
marginBottom: 10,
opacity: busy ? 0.6 : 1,
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 16,
color: '#ffffff',
letterSpacing: 0.2,
}}
>
{busy ? t('common.loading') : t('blocker.reactivate_btn')}
</Text>
</TouchableOpacity>
{/* Secondary CTA — Ghost-Link, klein */}
<TouchableOpacity
onPress={onClose}
activeOpacity={0.6}
style={{ paddingVertical: 12, alignItems: 'center' }}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 14,
color: colors.textMuted,
}}
>
{t('blocker.protection_off_later')}
</Text>
</TouchableOpacity>
</View>
</FormSheet>
);
}