## 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>
98 lines
2.6 KiB
TypeScript
98 lines
2.6 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Animated, Easing, View } from 'react-native';
|
|
|
|
/**
|
|
* Animierter Pulse-Marker für Screenshot-Overlays im Onboarding-Pre-Explainer.
|
|
*
|
|
* Positioniert via Prozent-Koordinaten relativ zum Container — so unabhängig
|
|
* von Screen-Größe + iPad-Skalierung. Pulsiert (Outer-Ring) + arrow pointer
|
|
* darunter zeigt klar wo der User tappen muss.
|
|
*
|
|
* <View style={{ aspectRatio: 9/16, ... }}>
|
|
* <Image source={...} />
|
|
* <ScreenshotPointer xPercent={50} yPercent={87} />
|
|
* </View>
|
|
*
|
|
* `xPercent=50, yPercent=87` ist die typische Position für "Erlauben"/
|
|
* "Fortfahren" am Bottom des iOS-Permission-Dialogs.
|
|
*/
|
|
export function ScreenshotPointer({
|
|
xPercent,
|
|
yPercent,
|
|
color = '#dc2626',
|
|
}: {
|
|
/** Horizontale Position als 0..100 vom Container-Width */
|
|
xPercent: number;
|
|
/** Vertikale Position als 0..100 vom Container-Height */
|
|
yPercent: number;
|
|
/** Pulse-Color. Default rot — fällt auf, signalisiert "wichtig". */
|
|
color?: string;
|
|
}) {
|
|
const pulse = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
const loop = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulse, {
|
|
toValue: 1,
|
|
duration: 1100,
|
|
useNativeDriver: true,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
Animated.timing(pulse, {
|
|
toValue: 0,
|
|
duration: 1100,
|
|
useNativeDriver: true,
|
|
easing: Easing.in(Easing.cubic),
|
|
}),
|
|
]),
|
|
);
|
|
loop.start();
|
|
return () => loop.stop();
|
|
}, [pulse]);
|
|
|
|
const scale = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] });
|
|
const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [0.95, 0.2] });
|
|
|
|
return (
|
|
<View
|
|
pointerEvents="none"
|
|
style={{
|
|
position: 'absolute',
|
|
top: `${yPercent}%`,
|
|
left: `${xPercent}%`,
|
|
// Marker center'n auf der Position
|
|
transform: [{ translateX: -28 }, { translateY: -28 }],
|
|
width: 56,
|
|
height: 56,
|
|
}}
|
|
>
|
|
{/* Outer pulse-ring */}
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
borderWidth: 3,
|
|
borderColor: color,
|
|
opacity,
|
|
transform: [{ scale }],
|
|
}}
|
|
/>
|
|
{/* Inner solid ring */}
|
|
<View
|
|
style={{
|
|
position: 'absolute',
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
borderWidth: 3,
|
|
borderColor: color,
|
|
backgroundColor: 'transparent',
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|