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:
chahinebrini 2026-05-08 20:37:50 +02:00
parent c24ab64c9d
commit aa609de46f
3 changed files with 260 additions and 102 deletions

View File

@ -6,11 +6,12 @@ import {
Text,
View,
} from 'react-native';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
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 { colors } from '../lib/theme';
import { useAuthStore } from '../stores/auth';
@ -31,6 +32,12 @@ type SectionRow = {
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;
};
};
type Section = {
@ -47,7 +54,6 @@ export default function SettingsScreen() {
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
const { language, setLanguage } = useLanguageStore();
const { plan } = useUserPlan();
const { showActionSheetWithOptions } = useNativeActionSheet();
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
// 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.
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
function pickFromOptions<T extends string>(
title: string,
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);
},
);
}
// TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet)
const voiceSheetRef = useRef<TrueSheet>(null);
async function handleSignOut() {
Alert.alert(t('auth.signOut'), '', [
@ -128,20 +118,32 @@ export default function SettingsScreen() {
label: t('settings.theme'),
sublabel: t('settings.theme_desc'),
value: themeLabel,
onPress: () =>
pickFromOptions<ThemeMode>(t('settings.theme'), themeOptions, (v) =>
setThemeMode(v),
),
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: 'language-outline',
label: t('settings.language'),
sublabel: t('settings.language_desc'),
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
onPress: () =>
pickFromOptions<AppLanguage>(t('settings.language'), langOptions, (v) =>
setLanguage(v),
),
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),
},
},
],
},
@ -196,14 +198,7 @@ export default function SettingsScreen() {
// Voice picker is wired but changes are local-only until
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
onPress:
plan === 'legend'
? () =>
pickFromOptions<string>(
t('settings.lyra_voice'),
voiceOptions,
(v) => setSelectedVoice(v),
)
: undefined,
plan === 'legend' ? () => voiceSheetRef.current?.present() : undefined,
soon: plan !== 'legend',
},
],
@ -290,27 +285,10 @@ export default function SettingsScreen() {
elevation: 1,
}}
>
{section.rows.map((row, i) => (
<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={{
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)',
}}
>
{section.rows.map((row, i) => {
// Visual content of the row (icon + label + sublabel)
const rowLeft = (
<>
<Ionicons
name={row.icon}
size={18}
@ -341,6 +319,84 @@ export default function SettingsScreen() {
</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: '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}
>
<Pressable
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
}}
>
{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>
</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={{
@ -374,7 +430,8 @@ export default function SettingsScreen() {
)}
</View>
</Pressable>
))}
);
})}
</View>
</View>
))}
@ -404,6 +461,74 @@ export default function SettingsScreen() {
{Platform.OS}
</Text>
</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 File

@ -64,7 +64,8 @@ export function WheelPickerModal<T extends string | number>({
onPress={onClose}
style={{
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',
}}
>

View File

@ -9,6 +9,7 @@ import {
UIManager,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { MenuView } from '@react-native-menu/menu';
import { getCitiesForBundesland } from '../../lib/germanCities';
import { WheelPickerModal } from '../WheelPickerModal';
import { colors } from '../../lib/theme';
@ -388,17 +389,48 @@ export function DemographicsAccordion({
why="Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel."
filled={!!local.gender}
>
<SelectButton
value={lookupLabel(GENDER_OPTIONS, local.gender)}
onPress={() =>
setWheelConfig({
title: 'Geschlecht',
options: GENDER_OPTIONS,
value: local.gender,
onSelect: (v) => flushSave({ ...local, gender: v as string }),
})
{/* ≤3 Optionen → UIMenu (anchored Pull-Down). Apple HIG-konform. */}
<MenuView
title="Geschlecht"
actions={GENDER_OPTIONS.map((opt) => ({
id: opt.value,
title: opt.label,
state: opt.value === local.gender ? 'on' : 'off',
}))}
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