feat(ui): Settings + Demographics native UIMenu + clean Wheel backdrop
Settings: - Theme/Sprache UIMenu: chevron-forward anchor (statt chevron-down — chevron-down reserviert für Collapsibles, siehe feedback_chevron_icon_convention memory) - Theme menu: SF-Symbol images entfernt → Theme/Sprache haben gleiches Padding Demographics (Profile): - Geschlecht (3 options) → UIMenu (anchored Pull-Down) statt Wheel - ≤3 → UIMenu, >3 → Wheel (Apple HIG-konform) WheelPickerModal: - Backdrop transparent (kein darkening overlay) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c24ab64c9d
commit
aa609de46f
@ -6,11 +6,12 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } 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';
|
||||||
import { useNativeActionSheet } from '../lib/useNativeActionSheet';
|
import { MenuView, type MenuAction } from '@react-native-menu/menu';
|
||||||
|
import { TrueSheet } from '@lodev09/react-native-true-sheet';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { colors } from '../lib/theme';
|
import { colors } from '../lib/theme';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
@ -31,6 +32,12 @@ type SectionRow = {
|
|||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
value?: string;
|
value?: string;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
/** Wenn gesetzt, wrappt UIMenu (anchored Pull-Down) statt onPress-trigger */
|
||||||
|
menu?: {
|
||||||
|
title: string;
|
||||||
|
actions: MenuAction[];
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = {
|
type Section = {
|
||||||
@ -47,7 +54,6 @@ export default function SettingsScreen() {
|
|||||||
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
||||||
const { language, setLanguage } = useLanguageStore();
|
const { language, setLanguage } = useLanguageStore();
|
||||||
const { plan } = useUserPlan();
|
const { plan } = useUserPlan();
|
||||||
const { showActionSheetWithOptions } = useNativeActionSheet();
|
|
||||||
|
|
||||||
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
|
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
|
||||||
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
|
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
|
||||||
@ -55,24 +61,8 @@ export default function SettingsScreen() {
|
|||||||
// For now: picker is wired to local state only, changes are NOT persisted.
|
// For now: picker is wired to local state only, changes are NOT persisted.
|
||||||
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
|
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
|
||||||
|
|
||||||
function pickFromOptions<T extends string>(
|
// TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet)
|
||||||
title: string,
|
const voiceSheetRef = useRef<TrueSheet>(null);
|
||||||
options: PickerOption<T>[],
|
|
||||||
onPick: (value: T) => void,
|
|
||||||
) {
|
|
||||||
const labels = options.map((o) => o.label);
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
options: [...labels, t('common.cancel')],
|
|
||||||
cancelButtonIndex: labels.length,
|
|
||||||
},
|
|
||||||
(idx) => {
|
|
||||||
if (idx === undefined || idx === labels.length) return;
|
|
||||||
onPick(options[idx].value);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
Alert.alert(t('auth.signOut'), '', [
|
Alert.alert(t('auth.signOut'), '', [
|
||||||
@ -128,20 +118,32 @@ export default function SettingsScreen() {
|
|||||||
label: t('settings.theme'),
|
label: t('settings.theme'),
|
||||||
sublabel: t('settings.theme_desc'),
|
sublabel: t('settings.theme_desc'),
|
||||||
value: themeLabel,
|
value: themeLabel,
|
||||||
onPress: () =>
|
menu: {
|
||||||
pickFromOptions<ThemeMode>(t('settings.theme'), themeOptions, (v) =>
|
title: t('settings.theme'),
|
||||||
setThemeMode(v),
|
// 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: 'language-outline',
|
icon: 'language-outline',
|
||||||
label: t('settings.language'),
|
label: t('settings.language'),
|
||||||
sublabel: t('settings.language_desc'),
|
sublabel: t('settings.language_desc'),
|
||||||
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
|
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
|
||||||
onPress: () =>
|
menu: {
|
||||||
pickFromOptions<AppLanguage>(t('settings.language'), langOptions, (v) =>
|
title: t('settings.language'),
|
||||||
setLanguage(v),
|
actions: langOptions.map((opt) => ({
|
||||||
),
|
id: opt.value,
|
||||||
|
title: opt.label,
|
||||||
|
state: opt.value === language ? 'on' : 'off',
|
||||||
|
})),
|
||||||
|
onSelect: (id) => setLanguage(id as AppLanguage),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -196,14 +198,7 @@ export default function SettingsScreen() {
|
|||||||
// Voice picker is wired but changes are local-only until
|
// Voice picker is wired but changes are local-only until
|
||||||
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
|
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
|
||||||
onPress:
|
onPress:
|
||||||
plan === 'legend'
|
plan === 'legend' ? () => voiceSheetRef.current?.present() : undefined,
|
||||||
? () =>
|
|
||||||
pickFromOptions<string>(
|
|
||||||
t('settings.lyra_voice'),
|
|
||||||
voiceOptions,
|
|
||||||
(v) => setSelectedVoice(v),
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
soon: plan !== 'legend',
|
soon: plan !== 'legend',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -290,27 +285,10 @@ export default function SettingsScreen() {
|
|||||||
elevation: 1,
|
elevation: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{section.rows.map((row, i) => (
|
{section.rows.map((row, i) => {
|
||||||
<Pressable
|
// Visual content of the row (icon + label + sublabel)
|
||||||
key={row.label}
|
const rowLeft = (
|
||||||
onPress={row.soon ? undefined : row.onPress}
|
<>
|
||||||
disabled={row.soon}
|
|
||||||
style={({ pressed }) => ({
|
|
||||||
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 12,
|
|
||||||
minHeight: 56,
|
|
||||||
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
|
|
||||||
borderBottomColor: 'rgba(0,0,0,0.04)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={row.icon}
|
name={row.icon}
|
||||||
size={18}
|
size={18}
|
||||||
@ -341,40 +319,119 @@ export default function SettingsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{row.soon ? (
|
</>
|
||||||
<Text
|
);
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
const containerStyle = {
|
||||||
color: '#a3a3a3',
|
flexDirection: 'row' as const,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
alignItems: 'center' as const,
|
||||||
textTransform: 'uppercase',
|
gap: 12,
|
||||||
letterSpacing: 0.5,
|
paddingHorizontal: 14,
|
||||||
}}
|
paddingVertical: 12,
|
||||||
|
minHeight: 56,
|
||||||
|
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.04)',
|
||||||
|
opacity: row.soon ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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}
|
||||||
>
|
>
|
||||||
{t('settings.soon_badge')}
|
<Pressable
|
||||||
</Text>
|
hitSlop={8}
|
||||||
) : row.value ? (
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
<Text
|
>
|
||||||
style={{
|
<View
|
||||||
fontSize: 13,
|
style={{
|
||||||
color: colors.textMuted,
|
flexDirection: 'row',
|
||||||
fontFamily: 'Nunito_400Regular',
|
alignItems: 'center',
|
||||||
marginLeft: 4,
|
gap: 4,
|
||||||
}}
|
paddingHorizontal: 8,
|
||||||
numberOfLines={1}
|
paddingVertical: 6,
|
||||||
>
|
borderRadius: 8,
|
||||||
{row.value}
|
}}
|
||||||
</Text>
|
>
|
||||||
) : (
|
{row.value ? (
|
||||||
<Ionicons
|
<Text
|
||||||
name="chevron-forward"
|
style={{
|
||||||
size={16}
|
fontSize: 13,
|
||||||
color="#d4d4d8"
|
color: colors.textMuted,
|
||||||
/>
|
fontFamily: 'Nunito_400Regular',
|
||||||
)}
|
}}
|
||||||
</View>
|
numberOfLines={1}
|
||||||
</Pressable>
|
>
|
||||||
))}
|
{row.value}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</MenuView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard-Row: ganze Pressable als Tap-Target
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={row.label}
|
||||||
|
onPress={row.soon ? undefined : row.onPress}
|
||||||
|
disabled={row.soon}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View style={containerStyle}>
|
||||||
|
{rowLeft}
|
||||||
|
{row.soon ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#a3a3a3',
|
||||||
|
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="#d4d4d8"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@ -404,6 +461,74 @@ export default function SettingsScreen() {
|
|||||||
{Platform.OS}
|
{Platform.OS}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<TrueSheet
|
||||||
|
ref={voiceSheetRef}
|
||||||
|
detents={['auto', 1]}
|
||||||
|
cornerRadius={20}
|
||||||
|
grabber
|
||||||
|
>
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.lyra_voice')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.lyra_voice_desc')}
|
||||||
|
</Text>
|
||||||
|
{voiceOptions.map((opt, idx) => {
|
||||||
|
const isSelected = opt.value === selectedVoice;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={opt.value}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedVoice(opt.value);
|
||||||
|
voiceSheetRef.current?.dismiss();
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderBottomWidth: idx < voiceOptions.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? (
|
||||||
|
<Ionicons name="checkmark" size={20} color={colors.brandOrange} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</TrueSheet>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,8 @@ export function WheelPickerModal<T extends string | number>({
|
|||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
// Kein darkening (User-Regel) — backdrop nur als Tap-to-close-Layer
|
||||||
|
backgroundColor: 'transparent',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
UIManager,
|
UIManager,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { MenuView } from '@react-native-menu/menu';
|
||||||
import { getCitiesForBundesland } from '../../lib/germanCities';
|
import { getCitiesForBundesland } from '../../lib/germanCities';
|
||||||
import { WheelPickerModal } from '../WheelPickerModal';
|
import { WheelPickerModal } from '../WheelPickerModal';
|
||||||
import { colors } from '../../lib/theme';
|
import { colors } from '../../lib/theme';
|
||||||
@ -388,17 +389,48 @@ export function DemographicsAccordion({
|
|||||||
why="Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel."
|
why="Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel."
|
||||||
filled={!!local.gender}
|
filled={!!local.gender}
|
||||||
>
|
>
|
||||||
<SelectButton
|
{/* ≤3 Optionen → UIMenu (anchored Pull-Down). Apple HIG-konform. */}
|
||||||
value={lookupLabel(GENDER_OPTIONS, local.gender)}
|
<MenuView
|
||||||
onPress={() =>
|
title="Geschlecht"
|
||||||
setWheelConfig({
|
actions={GENDER_OPTIONS.map((opt) => ({
|
||||||
title: 'Geschlecht',
|
id: opt.value,
|
||||||
options: GENDER_OPTIONS,
|
title: opt.label,
|
||||||
value: local.gender,
|
state: opt.value === local.gender ? 'on' : 'off',
|
||||||
onSelect: (v) => flushSave({ ...local, gender: v as string }),
|
}))}
|
||||||
})
|
onPressAction={({ nativeEvent: { event } }) =>
|
||||||
|
flushSave({ ...local, gender: event })
|
||||||
}
|
}
|
||||||
/>
|
shouldOpenOnLongPress={false}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
backgroundColor: '#f4f4f5',
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e4e4e7',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: local.gender ? colors.text : colors.textMuted,
|
||||||
|
fontFamily: local.gender ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lookupLabel(GENDER_OPTIONS, local.gender) ?? 'auswählen'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</MenuView>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
|
||||||
<FieldRow
|
<FieldRow
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user