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:
parent
335945fe2c
commit
6870f71265
@ -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 "Cooldown starten" nutzt dann 40 Sekunden statt 24h.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugStub({
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user