From aa609de46fa3014ae9cba5e73d1aa5952c1b9a95 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 8 May 2026 20:37:50 +0200 Subject: [PATCH] feat(ui): Settings + Demographics native UIMenu + clean Wheel backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/rebreak-native/app/settings.tsx | 307 ++++++++++++------ .../components/WheelPickerModal.tsx | 3 +- .../profile/DemographicsAccordion.tsx | 52 ++- 3 files changed, 260 insertions(+), 102 deletions(-) diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index ccb8e2e..7e60d5b 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -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( - title: string, - options: PickerOption[], - 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(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(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(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( - 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) => ( - ({ - opacity: row.soon ? 0.5 : pressed ? 0.7 : 1, - })} - > - + {section.rows.map((row, i) => { + // Visual content of the row (icon + label + sublabel) + const rowLeft = ( + <> ) : null} - {row.soon ? ( - + ); + + 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 ( + + {rowLeft} + + row.menu!.onSelect(event) + } + shouldOpenOnLongPress={false} > - {t('settings.soon_badge')} - - ) : row.value ? ( - - {row.value} - - ) : ( - - )} - - - ))} + ({ opacity: pressed ? 0.6 : 1 })} + > + + {row.value ? ( + + {row.value} + + ) : null} + + + + + + ); + } + + // Standard-Row: ganze Pressable als Tap-Target + return ( + ({ + opacity: row.soon ? 0.5 : pressed ? 0.7 : 1, + })} + > + + {rowLeft} + {row.soon ? ( + + {t('settings.soon_badge')} + + ) : row.value ? ( + + {row.value} + + ) : ( + + )} + + + ); + })} ))} @@ -404,6 +461,74 @@ export default function SettingsScreen() { {Platform.OS} + + + + + {t('settings.lyra_voice')} + + + {t('settings.lyra_voice_desc')} + + {voiceOptions.map((opt, idx) => { + const isSelected = opt.value === selectedVoice; + return ( + { + setSelectedVoice(opt.value); + voiceSheetRef.current?.dismiss(); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + {opt.label} + + {isSelected ? ( + + ) : null} + + + ); + })} + + ); } diff --git a/apps/rebreak-native/components/WheelPickerModal.tsx b/apps/rebreak-native/components/WheelPickerModal.tsx index e365b23..2ec6668 100644 --- a/apps/rebreak-native/components/WheelPickerModal.tsx +++ b/apps/rebreak-native/components/WheelPickerModal.tsx @@ -64,7 +64,8 @@ export function WheelPickerModal({ 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', }} > diff --git a/apps/rebreak-native/components/profile/DemographicsAccordion.tsx b/apps/rebreak-native/components/profile/DemographicsAccordion.tsx index dbcea8c..ed59eab 100644 --- a/apps/rebreak-native/components/profile/DemographicsAccordion.tsx +++ b/apps/rebreak-native/components/profile/DemographicsAccordion.tsx @@ -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} > - - 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. */} + ({ + id: opt.value, + title: opt.label, + state: opt.value === local.gender ? 'on' : 'off', + }))} + onPressAction={({ nativeEvent: { event } }) => + flushSave({ ...local, gender: event }) } - /> + shouldOpenOnLongPress={false} + > + ({ opacity: pressed ? 0.6 : 1 })} + > + + + + {lookupLabel(GENDER_OPTIONS, local.gender) ?? 'auswählen'} + + + + + +