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 { 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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -7,6 +7,7 @@ import { useColors } from '../lib/theme';
|
|||||||
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
|
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
|
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
|
||||||
|
import { getCooldownTestMode, setCooldownTestMode } from '../lib/protection';
|
||||||
|
|
||||||
export default function DebugScreen() {
|
export default function DebugScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -95,6 +96,8 @@ export default function DebugScreen() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<CooldownTestModeToggle />
|
||||||
|
|
||||||
<DebugStub
|
<DebugStub
|
||||||
title="LLM-Provider Toggle"
|
title="LLM-Provider Toggle"
|
||||||
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
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({
|
function DebugStub({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -28,6 +29,7 @@ export function DeactivationExplainerSheet({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
function showFinalConfirm() {
|
function showFinalConfirm() {
|
||||||
@ -77,31 +79,37 @@ export function DeactivationExplainerSheet({
|
|||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
<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
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 14,
|
paddingTop: insets.top + 14,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
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 }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.back')}
|
{t('common.back')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.deactivation_heading')}
|
{t('blocker.deactivation_heading')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ width: 50 }} />
|
<View style={{ width: 50 }} />
|
||||||
</View>
|
</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 }}>
|
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
|
||||||
{t('blocker.deactivation_title')}
|
{t('blocker.deactivation_title')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@ -12,6 +11,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Easing,
|
Easing,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
@ -58,6 +58,7 @@ export function ProtectionDetailsSheet({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
|
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
|
||||||
|
|
||||||
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
|
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
|
||||||
@ -148,8 +149,9 @@ export function ProtectionDetailsSheet({
|
|||||||
onRequestClose={handleClose}
|
onRequestClose={handleClose}
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
<Pressable
|
<TouchableOpacity
|
||||||
onPress={handleClose}
|
onPress={handleClose}
|
||||||
|
activeOpacity={1}
|
||||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }}
|
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 }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.details_title')}
|
{t('blocker.details_title')}
|
||||||
</Text>
|
</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 }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('blocker.details_done')}
|
{t('blocker.details_done')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 40, gap: 18 }}
|
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 24, gap: 18 }}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
>
|
>
|
||||||
{loadingStats && !stats ? (
|
{loadingStats && !stats ? (
|
||||||
|
|||||||
@ -46,11 +46,28 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
|
|
||||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const tickTimer = 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) => {
|
const fetchState = useCallback(async (showLoading = false) => {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const next = await protection.getCombinedState();
|
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);
|
setState(next);
|
||||||
setTickSeconds(next.cooldown.remainingSeconds);
|
setTickSeconds(next.cooldown.remainingSeconds);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -93,16 +110,15 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
};
|
};
|
||||||
}, [state?.cooldown.active]);
|
}, [state?.cooldown.active]);
|
||||||
|
|
||||||
// AppState-Listener: Refresh wenn App aus Background zurückkommt.
|
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed ist.
|
||||||
// KEIN auto-disable hier — Backend's canDisableProtection-Flag ist auch
|
// Guard in applyCooldownDisableIfElapsed: cooldownEndsAt muss gesetzt sein
|
||||||
// in initial-state true, würde sonst den Filter killen ohne dass User
|
// (= es lief je ein Cooldown) und remainingSeconds <= 0. Verhindert
|
||||||
// jemals einen Cooldown gestartet hat. Auto-Disable nur über expliziten
|
// False-Positives wenn canDisableProtection im Initial-State true ist.
|
||||||
// UI-Pfad nach Cooldown-Ablauf (kommt in Step 5b).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sub = AppState.addEventListener('change', (status: AppStateStatus) => {
|
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
|
||||||
if (status === 'active') {
|
if (status !== 'active') return;
|
||||||
fetchState(false);
|
await protection.applyCooldownDisableIfElapsed();
|
||||||
}
|
await fetchState(false);
|
||||||
});
|
});
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
|
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
|
||||||
*/
|
*/
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import RebreakProtection from "../modules/rebreak-protection";
|
import RebreakProtection from "../modules/rebreak-protection";
|
||||||
import type {
|
import type {
|
||||||
ActivateResult,
|
ActivateResult,
|
||||||
@ -69,6 +70,19 @@ type BackendProtectionState = {
|
|||||||
plan: "free" | "pro" | "legend";
|
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 ────────────────────────────────────────────────────────────
|
// ─── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const protection = {
|
export const protection = {
|
||||||
@ -142,15 +156,18 @@ export const protection = {
|
|||||||
|
|
||||||
// ─── Backend-Cooldown ────────────────────────────────────────────────────
|
// ─── 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(
|
async requestDeactivation(
|
||||||
reason?: string,
|
reason?: string,
|
||||||
): Promise<{ cooldownEndsAt: 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<{
|
const res = await apiFetch<{
|
||||||
cooldownEndsAt: string;
|
cooldownEndsAt: string;
|
||||||
token: string;
|
token: string;
|
||||||
remainingSeconds: number;
|
remainingSeconds: number;
|
||||||
}>("/api/cooldown/request", { method: "POST", body: { reason } });
|
}>("/api/cooldown/request", { method: "POST", body });
|
||||||
return { cooldownEndsAt: res.cooldownEndsAt };
|
return { cooldownEndsAt: res.cooldownEndsAt };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user