- AppDelegate: NSLog for didUpdate token, didInvalidate, didReceiveIncomingPush - backend/push: log [push-token] register, [call-ring] receiver token-counts + expo-push-fanout for android-fallback - app/call.tsx: 250ms grace window before closeScreen on initial idle (fixes 'foreground call flashes briefly then disappears' race when dm.tsx startCall set() hasn't propagated through useCallStore selector yet)
928 lines
31 KiB
TypeScript
928 lines
31 KiB
TypeScript
import {
|
|
Alert,
|
|
Linking,
|
|
Platform,
|
|
ScrollView,
|
|
Switch,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { MenuView, type MenuAction } from '@react-native-menu/menu';
|
|
import { TrueSheet } from '@lodev09/react-native-true-sheet';
|
|
import { useTranslation } from 'react-i18next';
|
|
import Constants from 'expo-constants';
|
|
import { LanguageIcon } from '../components/icons/LanguageIcon';
|
|
import { useColors } from '../lib/theme';
|
|
import { Button } from '../components/Button';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useAppLockStore } from '../stores/appLock';
|
|
import { useThemeStore, type ThemeMode } from '../stores/theme';
|
|
import { useLanguageStore, type AppLanguage } from '../stores/language';
|
|
import { useUserPlan } from '../hooks/useUserPlan';
|
|
import { useMe, invalidateMe } from '../hooks/useMe';
|
|
import { apiFetch } from '../lib/api';
|
|
import { AppHeader } from '../components/AppHeader';
|
|
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
|
|
import { MagicSheet } from '../components/devices/MagicSheet';
|
|
|
|
// ─── Subscription Sheet ────────────────────────────────────────────────────
|
|
|
|
type SubscriptionSheetProps = {
|
|
plan: 'free' | 'pro' | 'legend';
|
|
colors: import('../lib/theme').ColorScheme;
|
|
t: (key: string) => string;
|
|
};
|
|
|
|
const PLAN_ACCENT: Record<string, string> = {
|
|
free: '#737373',
|
|
pro: '#007AFF',
|
|
legend: '#f59e0b',
|
|
};
|
|
|
|
function SubscriptionSheet({ plan, colors, t }: SubscriptionSheetProps) {
|
|
const accentColor = PLAN_ACCENT[plan] ?? '#737373';
|
|
const planLabel =
|
|
plan === 'legend'
|
|
? t('settings.subscription_plan_legend')
|
|
: plan === 'pro'
|
|
? t('settings.subscription_plan_pro')
|
|
: t('settings.subscription_plan_free');
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 20,
|
|
paddingTop: 8,
|
|
paddingBottom: 32,
|
|
backgroundColor: colors.surface,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
|
<Ionicons name="star-outline" size={22} color={accentColor} />
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_700Bold',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{t('settings.subscription_sheet_title')}
|
|
</Text>
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
borderRadius: 20,
|
|
backgroundColor: accentColor + '22',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: accentColor,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
{planLabel}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
lineHeight: 20,
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
{t('settings.subscription_sheet_body')}
|
|
</Text>
|
|
|
|
{/* TODO: für iOS-Submission ggf. zu nicht-tippbarem Text degradieren
|
|
(Apple Guideline 3.1.1: externe Abo-Links können Review-Ablehnung triggern,
|
|
wenn sie als Kauf-Umgehung gewertet werden. Standalone-URL ohne Preis-Info
|
|
sollte ok sein, ist aber ungeprüft — bei Submission erneut prüfen.) */}
|
|
<Button
|
|
title={t('settings.subscription_sheet_cta')}
|
|
onPress={() => Linking.openURL('https://rebreak.org/account')}
|
|
size="lg"
|
|
style={{ backgroundColor: accentColor }}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Settings Screen ───────────────────────────────────────────────────────
|
|
|
|
type PickerOption<T extends string> = { value: T; label: string };
|
|
|
|
type SectionRow = {
|
|
/** Ionicons-name ODER eigenes SVG-Component (für custom icons wie LanguageIcon) */
|
|
icon: React.ComponentProps<typeof Ionicons>['name'] | React.ComponentType<{ size?: number; color?: string }>;
|
|
label: string;
|
|
sublabel: string;
|
|
soon?: boolean;
|
|
destructive?: boolean;
|
|
value?: string;
|
|
onPress?: () => void;
|
|
/** Wenn gesetzt, wrappt UIMenu (anchored Pull-Down) statt onPress-trigger */
|
|
menu?: {
|
|
title: string;
|
|
actions: MenuAction[];
|
|
onSelect: (id: string) => void;
|
|
};
|
|
/** Wenn gesetzt, rendert ein native UISwitch am End-Anchor statt Chevron/Value */
|
|
toggle?: {
|
|
value: boolean;
|
|
onValueChange: (next: boolean) => void;
|
|
disabled?: boolean;
|
|
};
|
|
};
|
|
|
|
type Section = {
|
|
key: string;
|
|
title: string;
|
|
rows: SectionRow[];
|
|
};
|
|
|
|
export default function SettingsScreen() {
|
|
const router = useRouter();
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useTranslation();
|
|
const { signOut } = useAuthStore();
|
|
const appLockEnabled = useAppLockStore((s) => s.enabled);
|
|
const appLockAvailable = useAppLockStore((s) => s.available);
|
|
const setAppLockEnabled = useAppLockStore((s) => s.setEnabled);
|
|
const appLockAuthenticate = useAppLockStore((s) => s.authenticate);
|
|
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
|
const { language, setLanguage } = useLanguageStore();
|
|
const { plan } = useUserPlan();
|
|
const colors = useColors();
|
|
|
|
type LyraVoiceId = null | 'iFSsEDGbm0FiEd2IVH4w' | 'Gt7OshJCH7MuzX96wFHi';
|
|
|
|
const { me } = useMe();
|
|
|
|
const pushEnabled = useNotificationPrefsStore((s) => s.pushEnabled);
|
|
const streakReminderEnabled = useNotificationPrefsStore((s) => s.streakReminderEnabled);
|
|
const streakReminderTime = useNotificationPrefsStore((s) => s.streakReminderTime);
|
|
const callsInRecents = useNotificationPrefsStore((s) => s.callsInRecents);
|
|
const setPushEnabled = useNotificationPrefsStore((s) => s.setPushEnabled);
|
|
const setStreakReminderEnabled = useNotificationPrefsStore((s) => s.setStreakReminderEnabled);
|
|
const setStreakReminderTime = useNotificationPrefsStore((s) => s.setStreakReminderTime);
|
|
const setCallsInRecents = useNotificationPrefsStore((s) => s.setCallsInRecents);
|
|
const initNotifPrefs = useNotificationPrefsStore((s) => s.init);
|
|
|
|
const onToggleCallsInRecents = async (value: boolean) => {
|
|
await setCallsInRecents(value);
|
|
Alert.alert(
|
|
t('settings.calls_in_recents'),
|
|
t('settings.calls_in_recents_restart'),
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
initNotifPrefs();
|
|
}, []);
|
|
|
|
const hydratedVoice =
|
|
me?.lyraVoiceId === 'iFSsEDGbm0FiEd2IVH4w' || me?.lyraVoiceId === 'Gt7OshJCH7MuzX96wFHi'
|
|
? (me.lyraVoiceId as LyraVoiceId)
|
|
: null;
|
|
const [selectedVoice, setSelectedVoice] = useState<LyraVoiceId>(hydratedVoice);
|
|
const [voiceSaving, setVoiceSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setSelectedVoice(hydratedVoice);
|
|
}, [hydratedVoice]);
|
|
|
|
const subscriptionSheetRef = useRef<TrueSheet>(null);
|
|
const [magicSheetVisible, setMagicSheetVisible] = useState(false);
|
|
|
|
async function handleVoiceSelect(voiceId: LyraVoiceId) {
|
|
if (voiceSaving || voiceId === selectedVoice) return;
|
|
const prev = selectedVoice;
|
|
setSelectedVoice(voiceId);
|
|
setVoiceSaving(true);
|
|
try {
|
|
await apiFetch('/api/profile/me/lyra-voice', {
|
|
method: 'PATCH',
|
|
body: { lyraVoiceId: voiceId },
|
|
});
|
|
invalidateMe();
|
|
} catch (e: unknown) {
|
|
setSelectedVoice(prev);
|
|
Alert.alert(t('common.error'), e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setVoiceSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleToggleAppLock(next: boolean) {
|
|
if (next) {
|
|
// Erst verifizieren, dass Face ID / Touch ID / Passcode klappt — sonst nicht aktivieren.
|
|
// (Switch ist controlled über appLockEnabled → springt von selbst zurück wenn wir nicht persistieren.)
|
|
const ok = await appLockAuthenticate(t('applock.prompt'));
|
|
if (!ok) return;
|
|
await setAppLockEnabled(true);
|
|
} else {
|
|
await setAppLockEnabled(false);
|
|
}
|
|
}
|
|
|
|
async function handleSignOut() {
|
|
Alert.alert(t('auth.signOut'), '', [
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('auth.signOut'),
|
|
style: 'cancel',
|
|
onPress: async () => {
|
|
await signOut();
|
|
router.replace('/');
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
const themeOptions: PickerOption<ThemeMode>[] = [
|
|
{ value: 'system', label: t('settings.theme_system') },
|
|
{ value: 'light', label: t('settings.theme_light') },
|
|
{ value: 'dark', label: t('settings.theme_dark') },
|
|
];
|
|
|
|
const themeLabel =
|
|
themeMode === 'system'
|
|
? t('settings.theme_system')
|
|
: themeMode === 'light'
|
|
? t('settings.theme_light')
|
|
: t('settings.theme_dark');
|
|
|
|
const langOptions: PickerOption<AppLanguage>[] = [
|
|
{ value: 'de', label: t('settings.language_de') },
|
|
{ value: 'en', label: t('settings.language_en') },
|
|
{ value: 'fr', label: t('settings.language_fr') },
|
|
{ value: 'ar', label: t('settings.language_ar') },
|
|
];
|
|
|
|
const voiceLabel =
|
|
selectedVoice === 'iFSsEDGbm0FiEd2IVH4w'
|
|
? t('settings.lyra_voice_1')
|
|
: selectedVoice === 'Gt7OshJCH7MuzX96wFHi'
|
|
? t('settings.lyra_voice_2')
|
|
: t('settings.lyra_voice_default');
|
|
|
|
const [streakTimePickerVisible, setStreakTimePickerVisible] = useState(false);
|
|
|
|
const sections: Section[] = [
|
|
// Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt
|
|
{
|
|
key: 'security',
|
|
title: t('settings.section_security'),
|
|
rows: [
|
|
{
|
|
icon: 'lock-closed-outline',
|
|
label: t('settings.app_lock'),
|
|
sublabel: !appLockAvailable
|
|
? t('settings.app_lock_unavailable')
|
|
: Platform.OS === 'ios'
|
|
? t('settings.app_lock_desc')
|
|
: t('settings.app_lock_desc_android'),
|
|
toggle: {
|
|
value: appLockEnabled,
|
|
onValueChange: handleToggleAppLock,
|
|
disabled: !appLockAvailable,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'theme',
|
|
title: t('settings.section_theme'),
|
|
rows: [
|
|
{
|
|
icon: 'color-palette-outline',
|
|
label: t('settings.theme'),
|
|
sublabel: t('settings.theme_desc'),
|
|
value: themeLabel,
|
|
menu: {
|
|
title: t('settings.theme'),
|
|
// Bewusst KEINE `image`-Props (SF-Symbols) — sonst rendert UIMenu mit
|
|
// Icon-Slot reserviert und das Menu wird breiter/höher als bei Sprache.
|
|
actions: themeOptions.map((opt) => ({
|
|
id: opt.value,
|
|
title: opt.label,
|
|
state: opt.value === themeMode ? 'on' : 'off',
|
|
})),
|
|
onSelect: (id) => setThemeMode(id as ThemeMode),
|
|
},
|
|
},
|
|
{
|
|
icon: LanguageIcon,
|
|
label: t('settings.language'),
|
|
sublabel: t('settings.language_desc'),
|
|
value:
|
|
language === 'de'
|
|
? t('settings.language_de')
|
|
: language === 'fr'
|
|
? t('settings.language_fr')
|
|
: language === 'ar'
|
|
? t('settings.language_ar')
|
|
: t('settings.language_en'),
|
|
menu: {
|
|
title: t('settings.language'),
|
|
actions: langOptions.map((opt) => ({
|
|
id: opt.value,
|
|
title: opt.label,
|
|
state: opt.value === language ? 'on' : 'off',
|
|
})),
|
|
onSelect: (id) => setLanguage(id as AppLanguage),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'notifications',
|
|
title: t('settings.section_notifications'),
|
|
rows: [
|
|
{
|
|
icon: 'notifications-outline',
|
|
label: t('settings.notifications_push'),
|
|
sublabel: t('settings.notifications_push_sublabel'),
|
|
toggle: {
|
|
value: pushEnabled,
|
|
onValueChange: setPushEnabled,
|
|
},
|
|
},
|
|
...(pushEnabled
|
|
? [
|
|
{
|
|
icon: 'flame-outline' as const,
|
|
label: t('settings.notifications_streak'),
|
|
sublabel: t('settings.notifications_streak_desc'),
|
|
toggle: {
|
|
value: streakReminderEnabled,
|
|
onValueChange: setStreakReminderEnabled,
|
|
},
|
|
},
|
|
...(streakReminderEnabled
|
|
? [
|
|
{
|
|
icon: 'time-outline' as const,
|
|
label: t('settings.notifications_streak_time'),
|
|
sublabel: t('settings.notifications_streak_time_desc'),
|
|
value: `${String(streakReminderTime.hour).padStart(2, '0')}:${String(streakReminderTime.minute).padStart(2, '0')}`,
|
|
onPress: () => setStreakTimePickerVisible(true),
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
: []),
|
|
...(Platform.OS === 'ios'
|
|
? [
|
|
{
|
|
icon: 'call-outline' as const,
|
|
label: t('settings.calls_in_recents'),
|
|
sublabel: t('settings.calls_in_recents_desc'),
|
|
toggle: {
|
|
value: callsInRecents,
|
|
onValueChange: onToggleCallsInRecents,
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
{
|
|
key: 'devices',
|
|
title: t('settings.section_devices'),
|
|
rows: [
|
|
{
|
|
icon: 'phone-portrait-outline',
|
|
label: t('settings.devices'),
|
|
sublabel: t('settings.devices_desc'),
|
|
onPress: () => router.push('/devices'),
|
|
},
|
|
{
|
|
icon: 'sparkles-outline',
|
|
label: t('settings.rebreak_magic'),
|
|
sublabel: t('settings.rebreak_magic_desc'),
|
|
onPress: () => setMagicSheetVisible(true),
|
|
},
|
|
{
|
|
icon: 'star-outline',
|
|
label: t('settings.subscription'),
|
|
sublabel: t('settings.subscription_desc'),
|
|
value:
|
|
plan === 'legend'
|
|
? t('settings.subscription_plan_legend')
|
|
: plan === 'pro'
|
|
? t('settings.subscription_plan_pro')
|
|
: t('settings.subscription_plan_free'),
|
|
onPress: () => subscriptionSheetRef.current?.present(),
|
|
},
|
|
],
|
|
},
|
|
...(plan === 'legend'
|
|
? [
|
|
{
|
|
key: 'lyra',
|
|
title: t('settings.section_lyra'),
|
|
rows: [
|
|
{
|
|
icon: 'mic-outline' as const,
|
|
label: t('settings.lyra_voice'),
|
|
sublabel: t('settings.lyra_voice_desc'),
|
|
value: voiceLabel,
|
|
menu: {
|
|
title: t('settings.lyra_voice'),
|
|
actions: [
|
|
{
|
|
id: 'null',
|
|
title: t('settings.lyra_voice_default'),
|
|
state: selectedVoice === null ? 'on' : ('off' as const),
|
|
},
|
|
{
|
|
id: 'iFSsEDGbm0FiEd2IVH4w',
|
|
title: t('settings.lyra_voice_1'),
|
|
state: selectedVoice === 'iFSsEDGbm0FiEd2IVH4w' ? 'on' : ('off' as const),
|
|
},
|
|
{
|
|
id: 'Gt7OshJCH7MuzX96wFHi',
|
|
title: t('settings.lyra_voice_2'),
|
|
state: selectedVoice === 'Gt7OshJCH7MuzX96wFHi' ? 'on' : ('off' as const),
|
|
},
|
|
],
|
|
onSelect: (id: string) =>
|
|
handleVoiceSelect(
|
|
id === 'null' ? null : (id as 'iFSsEDGbm0FiEd2IVH4w' | 'Gt7OshJCH7MuzX96wFHi'),
|
|
),
|
|
},
|
|
},
|
|
],
|
|
} satisfies Section,
|
|
]
|
|
: []),
|
|
{
|
|
key: 'help',
|
|
title: t('settings.section_help'),
|
|
rows: [
|
|
{
|
|
icon: 'help-circle-outline',
|
|
label: t('settings.help_faq'),
|
|
sublabel: t('settings.help_faq_desc'),
|
|
onPress: () => router.push('/help/faq'),
|
|
},
|
|
{
|
|
icon: 'mail-outline',
|
|
label: t('settings.help_contact'),
|
|
sublabel: t('settings.help_contact_desc'),
|
|
onPress: () => router.push('/help/contact'),
|
|
},
|
|
{
|
|
icon: 'information-circle-outline',
|
|
label: t('settings.help_about'),
|
|
sublabel: t('settings.help_about_desc'),
|
|
onPress: () => router.push('/help/about'),
|
|
},
|
|
{
|
|
icon: 'heart-outline',
|
|
label: t('settings.help_crisis'),
|
|
sublabel: t('settings.help_crisis_desc'),
|
|
onPress: () => router.push('/help/crisis'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'account',
|
|
title: t('settings.danger_section'),
|
|
rows: [
|
|
{
|
|
icon: 'log-out-outline',
|
|
label: t('settings.sign_out'),
|
|
sublabel: '',
|
|
onPress: handleSignOut,
|
|
},
|
|
{
|
|
icon: 'trash-outline',
|
|
label: t('settings.delete_account'),
|
|
sublabel: t('settings.delete_desc'),
|
|
destructive: true,
|
|
soon: true,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
|
|
<AppHeader showBack title={t('settings.title')} />
|
|
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 16,
|
|
paddingTop: 16,
|
|
paddingBottom: insets.bottom + 40,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{sections.map((section) => (
|
|
<View key={section.key} style={{ marginBottom: 24 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1,
|
|
marginBottom: 8,
|
|
marginLeft: 4,
|
|
}}
|
|
>
|
|
{section.title}
|
|
</Text>
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.card,
|
|
borderRadius: 14,
|
|
overflow: 'hidden',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.04,
|
|
shadowRadius: 3,
|
|
elevation: 1,
|
|
}}
|
|
>
|
|
{section.rows.map((row, i) => {
|
|
// Visual content of the row (icon + label + sublabel)
|
|
const iconColor = row.destructive ? colors.error : colors.textMuted;
|
|
const IconComponent = typeof row.icon === 'string' ? null : row.icon;
|
|
const rowLeft = (
|
|
<>
|
|
{IconComponent ? (
|
|
<IconComponent size={18} color={iconColor} />
|
|
) : (
|
|
<Ionicons
|
|
name={row.icon as React.ComponentProps<typeof Ionicons>['name']}
|
|
size={18}
|
|
color={iconColor}
|
|
/>
|
|
)}
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: 15,
|
|
color: row.destructive ? colors.error : colors.text,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
}}
|
|
>
|
|
{row.label}
|
|
</Text>
|
|
{row.sublabel ? (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
{row.sublabel}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
const containerStyle = {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
gap: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
minHeight: 56,
|
|
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
|
|
borderBottomColor: colors.border,
|
|
opacity: row.soon ? 0.5 : 1,
|
|
};
|
|
|
|
// Row mit Toggle: native UISwitch am End-Anchor, Label-Bereich nicht tappable
|
|
if (row.toggle) {
|
|
return (
|
|
<View key={row.label} style={containerStyle}>
|
|
{rowLeft}
|
|
<Switch
|
|
value={row.toggle.value}
|
|
onValueChange={row.toggle.onValueChange}
|
|
disabled={row.toggle.disabled}
|
|
trackColor={{ false: colors.border, true: '#6366f1' }}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Row mit Menu: Label-Bereich nicht tappable, MenuView nur am End-Anchor
|
|
if (row.menu) {
|
|
return (
|
|
<View key={row.label} style={containerStyle}>
|
|
{rowLeft}
|
|
<MenuView
|
|
title={row.menu.title}
|
|
actions={row.menu.actions}
|
|
onPressAction={({ nativeEvent: { event } }) =>
|
|
row.menu!.onSelect(event)
|
|
}
|
|
shouldOpenOnLongPress={false}
|
|
>
|
|
<TouchableOpacity
|
|
hitSlop={8}
|
|
activeOpacity={0.6}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 6,
|
|
borderRadius: 8,
|
|
backgroundColor: colors.surfaceElevated,
|
|
}}
|
|
>
|
|
{row.value ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{row.value}
|
|
</Text>
|
|
) : null}
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={14}
|
|
color={colors.textMuted}
|
|
/>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</MenuView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={row.label}
|
|
onPress={row.soon ? undefined : row.onPress}
|
|
disabled={row.soon}
|
|
activeOpacity={0.7}
|
|
style={{ opacity: row.soon ? 0.5 : 1 }}
|
|
>
|
|
<View style={containerStyle}>
|
|
{rowLeft}
|
|
{row.soon ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
{t('settings.soon_badge')}
|
|
</Text>
|
|
) : row.value ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginLeft: 4,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{row.value}
|
|
</Text>
|
|
) : (
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={16}
|
|
color={colors.border}
|
|
/>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
))}
|
|
|
|
{/* ─── Version Badge ── sichtbar für Tester bei Bug-Reports ──── */}
|
|
<Text
|
|
style={{
|
|
textAlign: 'center',
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
marginTop: 6,
|
|
opacity: 0.75,
|
|
}}
|
|
>
|
|
{'v' +
|
|
(Constants.expoConfig?.version ?? '?') +
|
|
' (' +
|
|
(Platform.OS === 'ios'
|
|
? (Constants.expoConfig?.ios?.buildNumber ?? '?')
|
|
: String(Constants.expoConfig?.android?.versionCode ?? '?')) +
|
|
')'}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
textAlign: 'center',
|
|
fontSize: 10,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 2,
|
|
opacity: 0.45,
|
|
}}
|
|
>
|
|
{Platform.OS}
|
|
</Text>
|
|
</ScrollView>
|
|
|
|
<TrueSheet
|
|
ref={subscriptionSheetRef}
|
|
detents={['auto']}
|
|
cornerRadius={20}
|
|
grabber
|
|
backgroundColor={colors.surface}
|
|
>
|
|
<SubscriptionSheet plan={plan} colors={colors} t={t} />
|
|
</TrueSheet>
|
|
|
|
<MagicSheet
|
|
visible={magicSheetVisible}
|
|
onClose={() => setMagicSheetVisible(false)}
|
|
colors={colors}
|
|
/>
|
|
|
|
{streakTimePickerVisible ? (
|
|
<StreakTimePickerSheet
|
|
hour={streakReminderTime.hour}
|
|
minute={streakReminderTime.minute}
|
|
colors={colors}
|
|
t={t}
|
|
onConfirm={(hour, minute) => {
|
|
setStreakReminderTime(hour, minute);
|
|
setStreakTimePickerVisible(false);
|
|
}}
|
|
onClose={() => setStreakTimePickerVisible(false)}
|
|
/>
|
|
) : null}
|
|
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── Streak Time Picker Sheet ─────────────────────────────────────────────
|
|
|
|
function StreakTimePickerSheet({
|
|
hour,
|
|
minute,
|
|
colors,
|
|
t,
|
|
onConfirm,
|
|
onClose,
|
|
}: {
|
|
hour: number;
|
|
minute: number;
|
|
colors: import('../lib/theme').ColorScheme;
|
|
t: (key: string) => string;
|
|
onConfirm: (hour: number, minute: number) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [localHour, setLocalHour] = useState(hour);
|
|
const [localMinute, setLocalMinute] = useState(minute);
|
|
|
|
const hourOptions = Array.from({ length: 24 }, (_, i) => i);
|
|
const minuteOptions = [0, 15, 30, 45];
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
backgroundColor: colors.surface,
|
|
borderTopLeftRadius: 20,
|
|
borderTopRightRadius: 20,
|
|
paddingHorizontal: 20,
|
|
paddingTop: 16,
|
|
paddingBottom: 40,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: -4 },
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 12,
|
|
elevation: 8,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text, marginBottom: 4 }}>
|
|
{t('settings.notifications_streak_time_picker_title')}
|
|
</Text>
|
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginBottom: 20 }}>
|
|
{t('settings.notifications_streak_time_picker_desc')}
|
|
</Text>
|
|
|
|
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 8 }}>
|
|
{t('settings.notifications_hour')}
|
|
</Text>
|
|
<ScrollView style={{ maxHeight: 160 }} showsVerticalScrollIndicator={false}>
|
|
{hourOptions.map((h) => (
|
|
<TouchableOpacity
|
|
key={h}
|
|
onPress={() => setLocalHour(h)}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
paddingVertical: 10,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 8,
|
|
marginBottom: 2,
|
|
backgroundColor: h === localHour ? '#6366f1' + '22' : 'transparent',
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 16, fontFamily: h === localHour ? 'Nunito_700Bold' : 'Nunito_400Regular', color: h === localHour ? '#6366f1' : colors.text, textAlign: 'center' }}>
|
|
{String(h).padStart(2, '0')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 8 }}>
|
|
{t('settings.notifications_minute')}
|
|
</Text>
|
|
{minuteOptions.map((m) => (
|
|
<TouchableOpacity
|
|
key={m}
|
|
onPress={() => setLocalMinute(m)}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
paddingVertical: 10,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 8,
|
|
marginBottom: 2,
|
|
backgroundColor: m === localMinute ? '#6366f1' + '22' : 'transparent',
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 16, fontFamily: m === localMinute ? 'Nunito_700Bold' : 'Nunito_400Regular', color: m === localMinute ? '#6366f1' : colors.text, textAlign: 'center' }}>
|
|
{String(m).padStart(2, '0')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
<View style={{ flexDirection: 'row', gap: 12 }}>
|
|
<TouchableOpacity
|
|
onPress={onClose}
|
|
activeOpacity={0.7}
|
|
style={{ flex: 1, paddingVertical: 14, borderRadius: 12, backgroundColor: colors.surfaceElevated, alignItems: 'center' }}
|
|
>
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
|
{t('common.cancel')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => onConfirm(localHour, localMinute)}
|
|
activeOpacity={0.7}
|
|
style={{ flex: 2, paddingVertical: 14, borderRadius: 12, backgroundColor: '#6366f1', alignItems: 'center' }}
|
|
>
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#ffffff' }}>
|
|
{t('common.confirm')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|