- 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>
241 lines
7.3 KiB
TypeScript
241 lines
7.3 KiB
TypeScript
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';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
type Props = {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
/** User triggert "Atmen" → Deflector zur Urge-Page (Click-2 ende, kein Cooldown). */
|
|
onBreathe: () => void;
|
|
/** Click-3 Bestätigung — final destruktive Aktion via ActionSheet. */
|
|
onStartCooldown: (reason: string) => Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Click 2 of 3 (Cooldown-Friction).
|
|
*
|
|
* Erklärt was Cooldown bedeutet, bietet [Atmen] als primary Deflector an,
|
|
* und ein kleines [Cooldown trotzdem starten] das einen nativen ActionSheet
|
|
* zur finalen Bestätigung öffnet (Click 3).
|
|
*/
|
|
export function DeactivationExplainerSheet({
|
|
visible,
|
|
onClose,
|
|
onBreathe,
|
|
onStartCooldown,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
function showFinalConfirm() {
|
|
if (Platform.OS === 'ios') {
|
|
ActionSheetIOS.showActionSheetWithOptions(
|
|
{
|
|
title: t('blocker.deactivation_actionsheet_title'),
|
|
message: t('blocker.deactivation_actionsheet_message'),
|
|
options: [t('common.cancel'), t('blocker.deactivation_start_cta')],
|
|
destructiveButtonIndex: 1,
|
|
cancelButtonIndex: 0,
|
|
},
|
|
async (idx) => {
|
|
if (idx === 1) await runCooldown();
|
|
},
|
|
);
|
|
} else {
|
|
// Android Fallback
|
|
Alert.alert(
|
|
t('blocker.deactivation_actionsheet_title'),
|
|
t('blocker.deactivation_actionsheet_message'),
|
|
[
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{ text: t('blocker.deactivation_start_cta'), style: 'destructive', onPress: runCooldown },
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runCooldown() {
|
|
setSubmitting(true);
|
|
try {
|
|
await onStartCooldown('user_requested_deactivation');
|
|
onClose();
|
|
} catch (e: any) {
|
|
Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_failed_msg'));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
animationType="slide"
|
|
presentationStyle="pageSheet"
|
|
onRequestClose={onClose}
|
|
>
|
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
|
{/* 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: insets.top + 14,
|
|
paddingBottom: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
}}
|
|
>
|
|
<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>
|
|
</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, paddingBottom: Math.max(insets.bottom, 12) + 24, gap: 18 }}>
|
|
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
|
|
{t('blocker.deactivation_title')}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
lineHeight: 22,
|
|
}}
|
|
>
|
|
{t('blocker.deactivation_intro')}
|
|
</Text>
|
|
|
|
{/* Was passiert */}
|
|
<View style={{ gap: 12 }}>
|
|
<BulletRow
|
|
icon="time-outline"
|
|
title={t('blocker.deactivation_bullet1_title')}
|
|
text={t('blocker.deactivation_bullet1_text')}
|
|
/>
|
|
<BulletRow
|
|
icon="refresh-outline"
|
|
title={t('blocker.deactivation_bullet2_title')}
|
|
text={t('blocker.deactivation_bullet2_text')}
|
|
/>
|
|
<BulletRow
|
|
icon="leaf-outline"
|
|
title={t('blocker.deactivation_bullet3_title')}
|
|
text={t('blocker.deactivation_bullet3_text')}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ height: 12 }} />
|
|
|
|
{/* Primary Deflector */}
|
|
<TouchableOpacity
|
|
onPress={onBreathe}
|
|
activeOpacity={0.85}
|
|
>
|
|
<View style={{
|
|
backgroundColor: '#16a34a',
|
|
borderRadius: 14,
|
|
paddingVertical: 16,
|
|
paddingHorizontal: 16,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 10,
|
|
}}>
|
|
<Ionicons name="leaf" size={18} color="#fff" />
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
|
{t('blocker.deactivation_breathe_cta')}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
{/* Destructive secondary */}
|
|
<TouchableOpacity
|
|
onPress={showFinalConfirm}
|
|
disabled={submitting}
|
|
hitSlop={8}
|
|
activeOpacity={0.5}
|
|
style={{
|
|
opacity: submitting ? 0.5 : 1,
|
|
alignSelf: 'center',
|
|
paddingVertical: 12,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#dc2626',
|
|
}}
|
|
>
|
|
{submitting ? t('blocker.deactivation_starting') : t('blocker.deactivation_start_anyway')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function BulletRow({
|
|
icon,
|
|
title,
|
|
text,
|
|
}: {
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
title: string;
|
|
text: string;
|
|
}) {
|
|
const colors = useColors();
|
|
return (
|
|
<View style={{ flexDirection: 'row', gap: 12 }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Ionicons name={icon} size={18} color={colors.textMuted} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
{title}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
marginTop: 2,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{text}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|