diff --git a/apps/rebreak-native/app/debug.tsx b/apps/rebreak-native/app/debug.tsx index ff25b2d..f4bd264 100644 --- a/apps/rebreak-native/app/debug.tsx +++ b/apps/rebreak-native/app/debug.tsx @@ -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} + + { + getCooldownTestMode().then(setEnabled); + }, []); + + async function toggle(value: boolean) { + setEnabled(value); + await setCooldownTestMode(value); + } + + return ( + + + + + + + + Test-Cooldown (40 Sek) + + + + + Nur auf staging — der nächste "Cooldown starten" nutzt dann 40 Sekunden statt 24h. + + + + ); +} + function DebugStub({ title, subtitle, diff --git a/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx b/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx index 8b462c2..371a5c6 100644 --- a/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx +++ b/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx @@ -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} > - {/* Header */} + {/* Header — paddingTop berücksichtigt Notch/Statusbar (pageSheet auf iOS gibt + insets korrekt weiter; auf Android sichert es den Statusbar-Bereich ab). */} - + {t('common.back')} - + {t('blocker.deactivation_heading')} - + {t('blocker.deactivation_title')} diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx index 3965a28..7a10ae0 100644 --- a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx +++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx @@ -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 > - @@ -202,15 +204,15 @@ export function ProtectionDetailsSheet({ {t('blocker.details_title')} - + {t('blocker.details_done')} - + {loadingStats && !stats ? ( diff --git a/apps/rebreak-native/hooks/useProtectionState.ts b/apps/rebreak-native/hooks/useProtectionState.ts index 23be816..305310e 100644 --- a/apps/rebreak-native/hooks/useProtectionState.ts +++ b/apps/rebreak-native/hooks/useProtectionState.ts @@ -46,11 +46,28 @@ export function useProtectionState(): UseProtectionStateReturn { const pollTimer = useRef | null>(null); const tickTimer = useRef | null>(null); + const prevCooldownActiveRef = useRef(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]); diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index 8c0284f..9895b82 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -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 { + await AsyncStorage.setItem(DEV_COOLDOWN_TESTMODE_KEY, on ? "1" : "0"); +} + +export async function getCooldownTestMode(): Promise { + 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 = { 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 }; },