feat(blocker): __DEV__ test-cooldown toggle (40s) + auto-disable on elapse + safe-area fixes for deactivation sheets

- protection.ts: setCooldownTestMode/getCooldownTestMode (AsyncStorage 'dev:cooldown-testmode');
  requestDeactivation sends testMode:true when on (__DEV__ only)
- debug.tsx: CooldownTestModeToggle (Switch) — '40s instead of 24h, staging only'
- useProtectionState.ts: wire applyCooldownDisableIfElapsed() — fires on cooldown
  active→false transition (guarded so no extra fetch per poll) + on AppState 'active';
  protection actually turns off when the (test-)cooldown elapses (the 'Step 5b' auto-disable)
- DeactivationExplainerSheet.tsx: useSafeAreaInsets — header paddingTop insets.top+14,
  ScrollView paddingBottom max(insets.bottom,12)+24; back btn Pressable→TouchableOpacity
- ProtectionDetailsSheet.tsx: ScrollView paddingBottom max(insets.bottom,16)+24 (was 40);
  backdrop + 'Fertig' Pressable→TouchableOpacity

tsc clean. (Note: 'sheet doesn't scroll' — the bottom content was being clipped under the
home indicator; the paddingBottom fix should resolve it. Broader UI polish deferred to a
separate session — Task #10.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 16:40:58 +02:00
parent 335945fe2c
commit 6870f71265
5 changed files with 136 additions and 23 deletions

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native';
import { View, Text, ScrollView, Switch, TouchableOpacity, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@ -7,6 +7,7 @@ import { useColors } from '../lib/theme';
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
import { getCooldownTestMode, setCooldownTestMode } from '../lib/protection';
export default function DebugScreen() {
const router = useRouter();
@ -95,6 +96,8 @@ export default function DebugScreen() {
/>
) : null}
<CooldownTestModeToggle />
<DebugStub
title="LLM-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
@ -247,6 +250,73 @@ function PlanOverrideToggle({
);
}
function CooldownTestModeToggle() {
const colors = useColors();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
getCooldownTestMode().then(setEnabled);
}, []);
async function toggle(value: boolean) {
setEnabled(value);
await setCooldownTestMode(value);
}
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="timer-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Test-Cooldown (40 Sek)
</Text>
<Switch
value={enabled}
onValueChange={toggle}
trackColor={{ false: colors.border, true: '#16a34a' }}
thumbColor="#ffffff"
/>
</View>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
Nur auf staging der nächste &quot;Cooldown starten&quot; nutzt dann 40 Sekunden statt 24h.
</Text>
</View>
</View>
);
}
function DebugStub({
title,
subtitle,

View File

@ -1,4 +1,5 @@
import { Modal, View, Text, Pressable, TouchableOpacity, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native';
import { Modal, View, Text, TouchableOpacity, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -28,6 +29,7 @@ export function DeactivationExplainerSheet({
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const [submitting, setSubmitting] = useState(false);
function showFinalConfirm() {
@ -77,31 +79,37 @@ export function DeactivationExplainerSheet({
onRequestClose={onClose}
>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
{/* Header */}
{/* Header paddingTop berücksichtigt Notch/Statusbar (pageSheet auf iOS gibt
insets korrekt weiter; auf Android sichert es den Statusbar-Bereich ab). */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 14,
paddingTop: insets.top + 14,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<Pressable onPress={onClose} hitSlop={10}>
<TouchableOpacity
onPress={onClose}
hitSlop={10}
activeOpacity={0.6}
style={{ minWidth: 50 }}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.back')}
</Text>
</Pressable>
</TouchableOpacity>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.deactivation_heading')}
</Text>
<View style={{ width: 50 }} />
</View>
<ScrollView contentContainerStyle={{ padding: 20, gap: 18 }}>
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 12) + 24, gap: 18 }}>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
{t('blocker.deactivation_title')}
</Text>

View File

@ -3,7 +3,6 @@ import {
Modal,
View,
Text,
Pressable,
TouchableOpacity,
ScrollView,
Dimensions,
@ -12,6 +11,7 @@ import {
ActivityIndicator,
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Svg, { Path, Circle } from 'react-native-svg';
@ -58,6 +58,7 @@ export function ProtectionDetailsSheet({
}: Props) {
const { t, i18n } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
@ -148,8 +149,9 @@ export function ProtectionDetailsSheet({
onRequestClose={handleClose}
statusBarTranslucent
>
<Pressable
<TouchableOpacity
onPress={handleClose}
activeOpacity={1}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }}
/>
@ -202,15 +204,15 @@ export function ProtectionDetailsSheet({
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.details_title')}
</Text>
<Pressable onPress={handleClose} hitSlop={10} style={{ width: 50, alignItems: 'flex-end' }}>
<TouchableOpacity onPress={handleClose} hitSlop={10} activeOpacity={0.6} style={{ width: 50, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.details_done')}
</Text>
</Pressable>
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 40, gap: 18 }}
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 24, gap: 18 }}
showsVerticalScrollIndicator
>
{loadingStats && !stats ? (

View File

@ -46,11 +46,28 @@ export function useProtectionState(): UseProtectionStateReturn {
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const prevCooldownActiveRef = useRef<boolean | null>(null);
const fetchState = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const next = await protection.getCombinedState();
const prevActive = prevCooldownActiveRef.current;
prevCooldownActiveRef.current = next.cooldown.active;
// Cooldown ist gerade von active → inactive gekippt: Auto-Disable prüfen.
if (prevActive === true && !next.cooldown.active) {
const didDisable = await protection.applyCooldownDisableIfElapsed();
if (didDisable) {
// Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State.
const afterDisable = await protection.getCombinedState();
setState(afterDisable);
setTickSeconds(afterDisable.cooldown.remainingSeconds);
setError(null);
return;
}
}
setState(next);
setTickSeconds(next.cooldown.remainingSeconds);
setError(null);
@ -93,16 +110,15 @@ export function useProtectionState(): UseProtectionStateReturn {
};
}, [state?.cooldown.active]);
// AppState-Listener: Refresh wenn App aus Background zurückkommt.
// KEIN auto-disable hier — Backend's canDisableProtection-Flag ist auch
// in initial-state true, würde sonst den Filter killen ohne dass User
// jemals einen Cooldown gestartet hat. Auto-Disable nur über expliziten
// UI-Pfad nach Cooldown-Ablauf (kommt in Step 5b).
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed ist.
// Guard in applyCooldownDisableIfElapsed: cooldownEndsAt muss gesetzt sein
// (= es lief je ein Cooldown) und remainingSeconds <= 0. Verhindert
// False-Positives wenn canDisableProtection im Initial-State true ist.
useEffect(() => {
const sub = AppState.addEventListener('change', (status: AppStateStatus) => {
if (status === 'active') {
fetchState(false);
}
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
if (status !== 'active') return;
await protection.applyCooldownDisableIfElapsed();
await fetchState(false);
});
return () => sub.remove();
}, [fetchState]);

View File

@ -9,6 +9,7 @@
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
*/
import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import RebreakProtection from "../modules/rebreak-protection";
import type {
ActivateResult,
@ -69,6 +70,19 @@ type BackendProtectionState = {
plan: "free" | "pro" | "legend";
};
// ─── Dev Helpers ───────────────────────────────────────────────────────────
const DEV_COOLDOWN_TESTMODE_KEY = "dev:cooldown-testmode";
export async function setCooldownTestMode(on: boolean): Promise<void> {
await AsyncStorage.setItem(DEV_COOLDOWN_TESTMODE_KEY, on ? "1" : "0");
}
export async function getCooldownTestMode(): Promise<boolean> {
const val = await AsyncStorage.getItem(DEV_COOLDOWN_TESTMODE_KEY);
return val === "1";
}
// ─── Public API ────────────────────────────────────────────────────────────
export const protection = {
@ -142,15 +156,18 @@ export const protection = {
// ─── Backend-Cooldown ────────────────────────────────────────────────────
/** Startet 24h Cooldown. Schutz BLEIBT aktiv, kann erst nach Ablauf disabled werden. */
/** Startet 24h Cooldown (oder 40s bei aktivem __DEV__-testMode). Schutz BLEIBT aktiv. */
async requestDeactivation(
reason?: string,
): Promise<{ cooldownEndsAt: string }> {
const testMode = __DEV__ ? await getCooldownTestMode() : false;
const body: Record<string, unknown> = { reason };
if (testMode) body.testMode = true;
const res = await apiFetch<{
cooldownEndsAt: string;
token: string;
remainingSeconds: number;
}>("/api/cooldown/request", { method: "POST", body: { reason } });
}>("/api/cooldown/request", { method: "POST", body });
return { cooldownEndsAt: res.cooldownEndsAt };
},