## 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>
135 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|