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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user