debug.tsx war hart __DEV__-gated → der Protection-Log-Viewer (v0.3.3) wäre im TestFlight-Build unerreichbar gewesen. eas.json production-Profil setzt jetzt EXPO_PUBLIC_ENABLE_DEBUG=1. debug.tsx + HeaderDropdownMenu prüfen `__DEV__ || EXPO_PUBLIC_ENABLE_DEBUG`. Für den echten App-Store-Release einfach das Flag aus eas.json nehmen.
1161 lines
34 KiB
TypeScript
1161 lines
34 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
ScrollView,
|
|
Switch,
|
|
TouchableOpacity,
|
|
Alert,
|
|
Clipboard,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
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, protection } from '../lib/protection';
|
|
import { useRealtimeDebugStore, type LogEntry } from '../stores/realtimeDebug';
|
|
import { supabase } from '../lib/supabase';
|
|
|
|
export default function DebugScreen() {
|
|
const router = useRouter();
|
|
const colors = useColors();
|
|
const { me } = useMe();
|
|
|
|
// Debug-Page ist in __DEV__ immer da, plus in TestFlight/production-Builds
|
|
// wenn EXPO_PUBLIC_ENABLE_DEBUG gesetzt ist (eas.json production-Profil) —
|
|
// damit Tester den Protection-Log-Viewer ohne Mac/Console.app nutzen können.
|
|
// Für den echten App-Store-Release das Flag aus eas.json nehmen → wieder dicht.
|
|
const debugAccessible =
|
|
__DEV__ || process.env.EXPO_PUBLIC_ENABLE_DEBUG === '1';
|
|
|
|
useEffect(() => {
|
|
if (!debugAccessible) {
|
|
router.replace('/');
|
|
}
|
|
}, [router, debugAccessible]);
|
|
|
|
if (!debugAccessible) {
|
|
return <View style={{ flex: 1, backgroundColor: colors.bg }} />;
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 12,
|
|
paddingTop: 4,
|
|
paddingBottom: 12,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
|
}}
|
|
>
|
|
<TouchableOpacity
|
|
onPress={() => router.back()}
|
|
hitSlop={8}
|
|
activeOpacity={0.6}
|
|
style={{ width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }}
|
|
>
|
|
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
|
Debug
|
|
</Text>
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: '#fef3c7',
|
|
borderRadius: 14,
|
|
padding: 14,
|
|
marginBottom: 20,
|
|
borderWidth: 1,
|
|
borderColor: '#fde68a',
|
|
flexDirection: 'row',
|
|
gap: 10,
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<Ionicons name="warning-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
|
|
Dev only
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#92400e',
|
|
marginTop: 4,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /.
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{me ? (
|
|
<PlanOverrideToggle
|
|
colors={colors}
|
|
userId={me.id}
|
|
currentPlan={me.plan}
|
|
/>
|
|
) : null}
|
|
|
|
{me ? (
|
|
<OnboardingResetToggle
|
|
colors={colors}
|
|
currentStep={me.onboardingStep}
|
|
/>
|
|
) : null}
|
|
|
|
<CooldownTestModeToggle />
|
|
|
|
{Platform.OS === 'android' ? <AndroidA11yResetToggle colors={colors} /> : null}
|
|
|
|
<RealtimeStatusCard />
|
|
<RealtimeLogCard />
|
|
{Platform.OS === 'ios' ? <ProtectionLogCard /> : null}
|
|
|
|
<DebugStub
|
|
title="LLM-Provider Toggle"
|
|
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
|
icon="bulb-outline"
|
|
/>
|
|
<DebugStub
|
|
title="TTS-Provider Toggle"
|
|
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
|
icon="volume-high-outline"
|
|
/>
|
|
<DebugStub
|
|
title="Bench-Output"
|
|
subtitle="TTS/LLM-Latenz-Logs anzeigen (TODO)"
|
|
icon="speedometer-outline"
|
|
/>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// ─── Realtime Status Card ──────────────────────────────────────────────────
|
|
|
|
function RealtimeStatusCard() {
|
|
const colors = useColors();
|
|
const connectionState = useRealtimeDebugStore((s) => s.connectionState);
|
|
const reconnectCount = useRealtimeDebugStore((s) => s.reconnectCount);
|
|
const lastEventAt = useRealtimeDebugStore((s) => s.lastEventAt);
|
|
const tokenExpiresAt = useRealtimeDebugStore((s) => s.tokenExpiresAt);
|
|
const refreshSnapshot = useRealtimeDebugStore((s) => s.refreshSnapshot);
|
|
|
|
const [tick, setTick] = useState(0);
|
|
const [channels, setChannels] = useState<{ topic: string; state: string }[]>([]);
|
|
|
|
// Re-render once per second to keep relative timestamps live
|
|
useEffect(() => {
|
|
const t = setInterval(() => setTick((n) => n + 1), 1000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
// Refresh snapshot + channel list on each tick
|
|
useEffect(() => {
|
|
refreshSnapshot();
|
|
try {
|
|
const rt = supabase.realtime as any;
|
|
const raw: { topic?: string; state?: string }[] = rt.channels ?? [];
|
|
setChannels(
|
|
raw.map((ch) => ({
|
|
topic: ch.topic ?? '?',
|
|
state: ch.state ?? '?',
|
|
})),
|
|
);
|
|
} catch {
|
|
setChannels([]);
|
|
}
|
|
}, [tick, refreshSnapshot]);
|
|
|
|
const stateColor = stateAccent(connectionState, colors);
|
|
|
|
const tokenSecsLeft =
|
|
tokenExpiresAt != null ? Math.round(tokenExpiresAt - Date.now() / 1000) : null;
|
|
|
|
const lastEventLabel = lastEventAt
|
|
? relativeSeconds(Date.now() - lastEventAt)
|
|
: 'never';
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
padding: 14,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name="wifi-outline" size={18} color={colors.textMuted} />
|
|
</View>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
|
|
Realtime Status
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => refreshSnapshot()}
|
|
hitSlop={8}
|
|
activeOpacity={0.6}
|
|
>
|
|
<Ionicons name="refresh-outline" size={18} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<StatusRow
|
|
label="Connection"
|
|
value={connectionState}
|
|
valueColor={stateColor}
|
|
colors={colors}
|
|
/>
|
|
<StatusRow
|
|
label="Reconnects (since launch)"
|
|
value={String(reconnectCount)}
|
|
colors={colors}
|
|
/>
|
|
<StatusRow
|
|
label="Last event"
|
|
value={lastEventLabel}
|
|
colors={colors}
|
|
/>
|
|
<StatusRow
|
|
label="Token expires in"
|
|
value={
|
|
tokenSecsLeft == null
|
|
? 'n/a'
|
|
: tokenSecsLeft < 0
|
|
? 'EXPIRED'
|
|
: `${tokenSecsLeft}s`
|
|
}
|
|
valueColor={
|
|
tokenSecsLeft != null && tokenSecsLeft < 120 ? colors.error : undefined
|
|
}
|
|
colors={colors}
|
|
/>
|
|
|
|
{channels.length > 0 ? (
|
|
<View style={{ marginTop: 12 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.8,
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
Active channels ({channels.length})
|
|
</Text>
|
|
{channels.map((ch) => (
|
|
<View
|
|
key={ch.topic}
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 5,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_400Regular',
|
|
flex: 1,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{ch.topic}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: stateAccent(ch.state, colors),
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
marginLeft: 8,
|
|
}}
|
|
>
|
|
{ch.state}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View style={{ marginTop: 8 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
Keine aktiven Channels
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function StatusRow({
|
|
label,
|
|
value,
|
|
valueColor,
|
|
colors,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
valueColor?: string;
|
|
colors: import('../lib/theme').ColorScheme;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingVertical: 6,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
}}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: valueColor ?? colors.text,
|
|
fontFamily: 'Nunito_700Bold',
|
|
}}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Realtime Log Card ─────────────────────────────────────────────────────
|
|
|
|
function RealtimeLogCard() {
|
|
const colors = useColors();
|
|
const log = useRealtimeDebugStore((s) => s.log);
|
|
const clearLog = useRealtimeDebugStore((s) => s.clearLog);
|
|
|
|
function copyLog() {
|
|
const text = log
|
|
.map((e) => `[${new Date(e.ts).toISOString()}] ${e.text}`)
|
|
.join('\n');
|
|
Clipboard.setString(text);
|
|
Alert.alert('Kopiert', `${log.length} Log-Einträge in Zwischenablage.`);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
padding: 14,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name="list-outline" size={18} color={colors.textMuted} />
|
|
</View>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
|
|
Realtime Log
|
|
</Text>
|
|
<TouchableOpacity onPress={copyLog} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
|
|
<Ionicons name="copy-outline" size={17} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={clearLog} hitSlop={8} activeOpacity={0.6}>
|
|
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{log.length === 0 ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontStyle: 'italic',
|
|
paddingVertical: 8,
|
|
}}
|
|
>
|
|
Noch keine Events — warte auf Subscription-Aktivität.
|
|
</Text>
|
|
) : (
|
|
<View
|
|
style={{
|
|
maxHeight: 260,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
{log.map((entry) => (
|
|
<LogLine key={entry.id} entry={entry} colors={colors} />
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 8,
|
|
opacity: 0.6,
|
|
}}
|
|
>
|
|
max 100 Einträge, neueste oben
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Protection Log Card (iOS native NEFilter/FamilyControls flow) ──────────
|
|
|
|
function ProtectionLogCard() {
|
|
const colors = useColors();
|
|
const [logs, setLogs] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
async function loadLogs() {
|
|
setLoading(true);
|
|
try {
|
|
const next = await protection.getProtectionLogs();
|
|
// neueste oben
|
|
setLogs([...next].reverse());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadLogs();
|
|
}, []);
|
|
|
|
function copyLogs() {
|
|
Clipboard.setString(logs.join('\n'));
|
|
Alert.alert('Kopiert', `${logs.length} Protection-Log-Zeilen in Zwischenablage.`);
|
|
}
|
|
|
|
async function clearLogs() {
|
|
await protection.clearProtectionLogs();
|
|
setLogs([]);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
padding: 14,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name="shield-outline" size={18} color={colors.textMuted} />
|
|
</View>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
|
|
Protection Log (nativ)
|
|
</Text>
|
|
<TouchableOpacity onPress={loadLogs} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
|
|
<Ionicons name="refresh-outline" size={17} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={copyLogs} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
|
|
<Ionicons name="copy-outline" size={17} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={clearLogs} hitSlop={8} activeOpacity={0.6}>
|
|
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{logs.length === 0 ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontStyle: 'italic',
|
|
paddingVertical: 8,
|
|
}}
|
|
>
|
|
{loading
|
|
? 'Lade...'
|
|
: 'Keine Logs — aktiviere den URL-Filter, dann auf Refresh tippen.'}
|
|
</Text>
|
|
) : (
|
|
<View style={{ maxHeight: 320, overflow: 'hidden' }}>
|
|
<ScrollView style={{ flex: 1 }} nestedScrollEnabled showsVerticalScrollIndicator>
|
|
{logs.map((line, i) => (
|
|
<Text
|
|
key={i}
|
|
style={{
|
|
fontSize: 10,
|
|
lineHeight: 15,
|
|
color: line.includes('❌') || line.includes('failed')
|
|
? '#dc2626'
|
|
: line.includes('✅')
|
|
? '#16a34a'
|
|
: colors.textMuted,
|
|
fontFamily: 'Menlo',
|
|
marginBottom: 3,
|
|
}}
|
|
>
|
|
{line}
|
|
</Text>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 8,
|
|
opacity: 0.6,
|
|
}}
|
|
>
|
|
SharedLogStore — max 200 Einträge, neueste oben
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function LogLine({
|
|
entry,
|
|
colors,
|
|
}: {
|
|
entry: LogEntry;
|
|
colors: import('../lib/theme').ColorScheme;
|
|
}) {
|
|
const time = new Date(entry.ts);
|
|
const hms = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
|
const isError =
|
|
entry.text.includes('error') ||
|
|
entry.text.includes('close') ||
|
|
entry.text.includes('CLOSED');
|
|
const isOpen = entry.text.includes('open') || entry.text.includes('joined');
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
gap: 6,
|
|
paddingVertical: 3,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(0,0,0,0.03)',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
width: 58,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{hms}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
color: isError ? colors.error : isOpen ? colors.success : colors.text,
|
|
fontFamily: 'Nunito_400Regular',
|
|
flex: 1,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
{entry.text}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
function pad(n: number) {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
function relativeSeconds(diffMs: number): string {
|
|
const s = Math.round(diffMs / 1000);
|
|
if (s < 60) return `vor ${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `vor ${m}m`;
|
|
return `vor ${Math.floor(m / 60)}h`;
|
|
}
|
|
|
|
function stateAccent(state: string, colors: import('../lib/theme').ColorScheme): string {
|
|
const upper = state.toUpperCase();
|
|
if (upper === 'OPEN' || upper === 'JOINED' || upper === 'SUBSCRIBED') return colors.success;
|
|
if (upper === 'CLOSED' || upper === 'CHANNEL_ERROR' || upper === 'TIMED_OUT')
|
|
return colors.error;
|
|
if (upper === 'CONNECTING' || upper === 'JOINING') return colors.warning;
|
|
return colors.textMuted;
|
|
}
|
|
|
|
// ─── Plan Override ─────────────────────────────────────────────────────────
|
|
|
|
const PLANS: Plan[] = ['free', 'pro', 'legend'];
|
|
|
|
const PLAN_COLOR: Record<Plan, string> = {
|
|
free: '#737373',
|
|
pro: '#007AFF',
|
|
legend: '#f59e0b',
|
|
};
|
|
|
|
function PlanOverrideToggle({
|
|
colors,
|
|
userId,
|
|
currentPlan,
|
|
}: {
|
|
colors: import('../lib/theme').ColorScheme;
|
|
userId: string;
|
|
currentPlan: Plan;
|
|
}) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [sheetTarget, setSheetTarget] = useState<Plan | null>(null);
|
|
|
|
async function applyPlanSwitch(plan: Plan) {
|
|
setLoading(true);
|
|
try {
|
|
await apiFetch('/api/dev/set-plan', {
|
|
method: 'POST',
|
|
body: { plan },
|
|
});
|
|
invalidateMe();
|
|
} catch (e: unknown) {
|
|
Alert.alert('Fehler', e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function switchPlan(plan: Plan) {
|
|
if (plan === currentPlan) return;
|
|
setSheetTarget(plan);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
padding: 14,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name="star-outline" size={18} color={colors.textMuted} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
|
Plan-Override (DEV)
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 3,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
POST /api/dev/set-plan — nur staging
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
{PLANS.map((plan) => {
|
|
const isActive = plan === currentPlan;
|
|
const accent = PLAN_COLOR[plan];
|
|
return (
|
|
<TouchableOpacity
|
|
key={plan}
|
|
onPress={() => switchPlan(plan)}
|
|
disabled={loading || isActive}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
alignItems: 'center',
|
|
backgroundColor: isActive ? accent : colors.surfaceElevated,
|
|
opacity: loading ? 0.5 : 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: isActive ? '#ffffff' : colors.textMuted,
|
|
textTransform: 'capitalize',
|
|
}}
|
|
>
|
|
{plan}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{sheetTarget ? (
|
|
<PlanChangeSheet
|
|
visible={sheetTarget !== null}
|
|
targetPlan={sheetTarget}
|
|
onConfirm={() => {
|
|
const t = sheetTarget;
|
|
setSheetTarget(null);
|
|
applyPlanSwitch(t);
|
|
}}
|
|
onClose={() => setSheetTarget(null)}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Onboarding Reset ──────────────────────────────────────────────────────
|
|
|
|
const ONBOARDING_STEPS = ['welcome', 'nickname', 'block', 'done'] as const;
|
|
type OnboardingStepValue = (typeof ONBOARDING_STEPS)[number];
|
|
|
|
function OnboardingResetToggle({
|
|
colors,
|
|
currentStep,
|
|
}: {
|
|
colors: import('../lib/theme').ColorScheme;
|
|
currentStep: OnboardingStepValue;
|
|
}) {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
async function setStep(step: OnboardingStepValue) {
|
|
if (loading || step === currentStep) return;
|
|
setLoading(true);
|
|
try {
|
|
await apiFetch('/api/profile/me/onboarding-step', {
|
|
method: 'PATCH',
|
|
body: { step },
|
|
});
|
|
invalidateMe();
|
|
if (step === 'welcome') {
|
|
// /onboarding/welcome existiert nicht mehr (Duo-Rewrite → /onboarding/index.tsx).
|
|
// Korrekter Pfad ist /onboarding (Expo Router löst index.tsx auto auf).
|
|
router.replace('/onboarding');
|
|
} else if (step === 'nickname') {
|
|
// Legacy-Stage — Duo-Flow navigiert intern; /onboarding triggert via
|
|
// slideFromStep den Resume zur Nickname-Slide.
|
|
router.replace('/onboarding');
|
|
} else if (step === 'block') {
|
|
router.replace('/(app)/blocker');
|
|
} else if (step === 'done') {
|
|
router.replace('/(app)');
|
|
}
|
|
} catch (e: unknown) {
|
|
Alert.alert('Fehler', e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
padding: 14,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name="rocket-outline" size={18} color={colors.textMuted} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
|
Onboarding-Step
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 3,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
PATCH /api/profile/me/onboarding-step — aktuell: {currentStep}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={{ flexDirection: 'row', gap: 6 }}>
|
|
{ONBOARDING_STEPS.map((step) => {
|
|
const isActive = step === currentStep;
|
|
return (
|
|
<TouchableOpacity
|
|
key={step}
|
|
onPress={() => setStep(step)}
|
|
disabled={loading || isActive}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
alignItems: 'center',
|
|
backgroundColor: isActive ? colors.brandOrange : colors.surfaceElevated,
|
|
opacity: loading ? 0.5 : 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: isActive ? '#ffffff' : colors.textMuted,
|
|
textTransform: 'capitalize',
|
|
}}
|
|
>
|
|
{step}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Android: A11y-Settings Quick-Open ──────────────────────────────────────
|
|
|
|
/**
|
|
* Android-only Dev-Helper: öffnet Settings → Bedienungshilfen damit User den
|
|
* ReBreak-Service manuell off-toggeln kann. Wird gebraucht für Screenshot-
|
|
* Capture vom a11y-Settings-Page in 4 Sprachen — nach Cooldown disarmt
|
|
* forceDisable() den tamper-lock, aber das System-Switch in den Einstellungen
|
|
* bleibt programmatisch un-modifizierbar (Android-OS-Restriction).
|
|
*/
|
|
function AndroidA11yResetToggle({
|
|
colors,
|
|
}: {
|
|
colors: import('../lib/theme').ColorScheme;
|
|
}) {
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function forceResetAndOpen() {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
// Step 1: native forceDisable — stoppt VPN, disarmt tamper-lock, setzt
|
|
// filter_enabled=false → a11y-service wird passiv → blockt nichts mehr.
|
|
await protection.forceDisable();
|
|
|
|
// Step 2: backend protection-state auf "explizit disabled" markieren →
|
|
// enforceProtection-Loop feuert KEINE Auto-Reactivation in den nächsten
|
|
// Pollings. Sonst wäre der a11y-Service-Off-Toggle zwecklos weil die
|
|
// App den Filter sofort wieder hochfährt.
|
|
await apiFetch('/api/protection/dev-force-disabled', { method: 'POST' });
|
|
invalidateMe();
|
|
|
|
// Step 3: Android-Settings → Bedienungshilfen öffnen — User toggelt
|
|
// ReBreak-Service manuell off (Android-OS-Restriction: programmatisch
|
|
// nicht setzbar). Danach Screenshot-Capture frischer Trigger möglich.
|
|
await protection.openSystemSettings('accessibility');
|
|
} catch (e: unknown) {
|
|
Alert.alert('Fehler', e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
padding: 14,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name="accessibility-outline" size={18} color={colors.textMuted} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
|
Force Reset (Android)
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 3,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
VPN stoppen + tamper-lock disarmen + Backend als disabled markieren
|
|
+ Settings öffnen — Anti-Auto-Reactivation für Screenshot-Capture.
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
onPress={forceResetAndOpen}
|
|
disabled={busy}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
backgroundColor: colors.brandOrange,
|
|
borderRadius: 10,
|
|
paddingVertical: 10,
|
|
alignItems: 'center',
|
|
opacity: busy ? 0.5 : 1,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#ffffff',
|
|
}}
|
|
>
|
|
{busy ? 'Reset läuft...' : 'Force Reset + Settings öffnen'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Cooldown Test Mode ────────────────────────────────────────────────────
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─── Debug Stub ────────────────────────────────────────────────────────────
|
|
|
|
function DebugStub({
|
|
title,
|
|
subtitle,
|
|
icon,
|
|
}: {
|
|
title: string;
|
|
subtitle: string;
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
}) {
|
|
const colors = useColors();
|
|
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',
|
|
opacity: 0.6,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name={icon} size={18} color={colors.textMuted} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>{title}</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 3,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{subtitle}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|