feat(settings): Theme + Language + Lyra-Voice Picker; Dropdown-width 280→170pt

Settings-Page (alles auf einen Rutsch):
- Theme-Picker (System/Hell/Dunkel) — neuer useThemeStore (Zustand) mit
  AsyncStorage @rebreak/theme persist; init-call in app/_layout.tsx
  parallel zu auth-init
- Language-Picker (DE/EN) — neuer useLanguageStore mit i18n.changeLanguage
  + AsyncStorage @rebreak/language persist; init beim App-Start
- Lyra-Voice-Picker (Legend-only) — UI live, lokal selectable. BACKEND-GAP:
  PATCH /api/profile/me/lyra-voice fehlt (demographics.patch akzeptiert
  Voice-ID nicht via zod). UI bleibt funktional, Persist erst wenn
  Endpoint da ist.
- Logout neutral (kein style:'destructive' am Alert-Button)
- Coming-Soon-Banner entfernt (war veraltet, Settings ist live)

Dropdown:
- HeaderDropdownMenu minWidth 280→170, Item-Labels numberOfLines={1}
  ellipsize bei langen Strings

Locales:
- 11 neue Keys fuer Theme/Lang/Voice-Picker (DE+EN gepflegt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-07 21:22:32 +02:00
parent cddc4d0f26
commit 9ccd0bd334
7 changed files with 339 additions and 52 deletions

View File

@ -14,6 +14,8 @@ import {
Nunito_800ExtraBold,
} from '@expo-google-fonts/nunito';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useLanguageStore } from '../stores/language';
import { BrandSplash } from '../components/BrandSplash';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
@ -40,6 +42,8 @@ const queryClient = new QueryClient({
function RootLayoutInner() {
const { loading, init } = useAuthStore();
const initTheme = useThemeStore((s) => s.init);
const initLanguage = useLanguageStore((s) => s.init);
const [fontsLoaded] = useFonts({
Nunito_400Regular,
Nunito_600SemiBold,
@ -49,6 +53,8 @@ function RootLayoutInner() {
useEffect(() => {
init();
initTheme();
initLanguage();
}, []);
useEffect(() => {

View File

@ -1,12 +1,139 @@
import { Alert, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import {
Alert,
Animated,
Modal,
Platform,
Pressable,
ScrollView,
Text,
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 { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme';
import { useAuthStore } from '../stores/auth';
import { useThemeStore, type ThemeMode } from '../stores/theme';
import { useLanguageStore, type AppLanguage } from '../stores/language';
import { useUserPlan } from '../hooks/useUserPlan';
import { AppHeader } from '../components/AppHeader';
// ─── Picker Sheet ──────────────────────────────────────────────────────────
type PickerOption<T extends string> = { value: T; label: string };
function PickerSheet<T extends string>({
visible,
title,
options,
selected,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: PickerOption<T>[];
selected: T;
onSelect: (v: T) => void;
onClose: () => void;
}) {
const translateY = useRef(new Animated.Value(300)).current;
useEffect(() => {
if (visible) {
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
damping: 22,
stiffness: 280,
}).start();
} else {
Animated.timing(translateY, {
toValue: 300,
duration: 180,
useNativeDriver: true,
}).start();
}
}, [visible]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.3)', justifyContent: 'flex-end' }}
>
<Animated.View
onStartShouldSetResponder={() => true}
style={{
backgroundColor: '#fff',
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
paddingBottom: 34,
transform: [{ translateY }],
}}
>
<View
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#e5e5e5',
alignSelf: 'center',
marginTop: 10,
marginBottom: 14,
}}
/>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center',
marginBottom: 12,
}}
>
{title}
</Text>
{options.map((opt) => (
<Pressable
key={opt.value}
onPress={() => {
onSelect(opt.value);
onClose();
}}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 14,
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
})}
>
<Text
style={{
fontSize: 15,
fontFamily: opt.value === selected ? 'Nunito_700Bold' : 'Nunito_400Regular',
color: opt.value === selected ? colors.brandOrange : '#0a0a0a',
}}
>
{opt.label}
</Text>
{opt.value === selected && (
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
)}
</Pressable>
))}
</Animated.View>
</Pressable>
</Modal>
);
}
// ─── Settings Screen ───────────────────────────────────────────────────────
type SectionRow = {
icon: React.ComponentProps<typeof Ionicons>['name'];
iconColor: string;
@ -14,6 +141,7 @@ type SectionRow = {
sublabel: string;
soon?: boolean;
destructive?: boolean;
value?: string;
onPress?: () => void;
};
@ -28,25 +156,63 @@ export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { signOut } = useAuthStore();
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
const { language, setLanguage } = useLanguageStore();
const { plan } = useUserPlan();
const [themePickerOpen, setThemePickerOpen] = useState(false);
const [langPickerOpen, setLangPickerOpen] = useState(false);
const [voicePickerOpen, setVoicePickerOpen] = useState(false);
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
// A dedicated PATCH /api/profile/me/lyra-voice endpoint is needed from backend-agent.
// For now: picker is wired to local state only, changes are NOT persisted.
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
async function handleSignOut() {
Alert.alert(
t('auth.signOut'),
'',
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('auth.signOut'),
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/');
},
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') },
];
const voiceOptions: PickerOption<string>[] = [
{ value: 'EXAVITQu4vr4xnSDxMaL', label: t('settings.lyra_voice_sarah') },
{ value: 'ThT5KcBeYPX3keUQqHPh', label: t('settings.lyra_voice_aria') },
{ value: 'XB0fDUnXU5powFXDhCwa', label: t('settings.lyra_voice_charlotte') },
{ value: 'Xb7hH8MSUJpSbSDYk0k2', label: t('settings.lyra_voice_alice') },
{ value: 'pqHfZKP75CvOlQylNhV4', label: t('settings.lyra_voice_bill') },
];
const selectedVoiceName =
voiceOptions.find((v) => v.value === selectedVoice)?.label ?? t('settings.lyra_voice_sarah');
const sections: Section[] = [
{
key: 'profile',
@ -77,14 +243,16 @@ export default function SettingsScreen() {
iconColor: '#a78bfa',
label: t('settings.theme'),
sublabel: t('settings.theme_desc'),
soon: true,
value: themeLabel,
onPress: () => setThemePickerOpen(true),
},
{
icon: 'language-outline',
iconColor: '#a78bfa',
label: t('settings.language'),
sublabel: t('settings.language_desc'),
soon: true,
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
onPress: () => setLangPickerOpen(true),
},
],
},
@ -136,8 +304,15 @@ export default function SettingsScreen() {
icon: 'mic-outline',
iconColor: '#ec4899',
label: t('settings.lyra_voice'),
sublabel: t('settings.lyra_voice_desc'),
soon: true,
sublabel:
plan === 'legend'
? t('settings.lyra_voice_desc')
: t('settings.lyra_voice_only_legend'),
value: plan === 'legend' ? selectedVoiceName : undefined,
// 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' ? () => setVoicePickerOpen(true) : undefined,
soon: plan !== 'legend',
},
],
},
@ -200,38 +375,6 @@ export default function SettingsScreen() {
}}
showsVerticalScrollIndicator={false}
>
<View
style={{
backgroundColor: '#fef3c7',
borderRadius: 14,
padding: 14,
marginBottom: 20,
borderWidth: 1,
borderColor: '#fde68a',
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="construct-outline" size={18} color="#b45309" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
{t('settings.coming_soon_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
marginTop: 4,
lineHeight: 17,
}}
>
{t('settings.coming_soon_desc')}
</Text>
</View>
</View>
{sections.map((section) => (
<View key={section.key} style={{ marginBottom: 24 }}>
<Text
@ -326,6 +469,19 @@ export default function SettingsScreen() {
>
{t('settings.soon_badge')}
</Text>
) : row.value ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
flexShrink: 0,
marginLeft: 4,
}}
numberOfLines={1}
>
{row.value}
</Text>
) : (
<Ionicons
name="chevron-forward"
@ -365,6 +521,33 @@ export default function SettingsScreen() {
{Platform.OS}
</Text>
</ScrollView>
<PickerSheet
visible={themePickerOpen}
title={t('settings.theme_picker_title')}
options={themeOptions}
selected={themeMode}
onSelect={setThemeMode}
onClose={() => setThemePickerOpen(false)}
/>
<PickerSheet
visible={langPickerOpen}
title={t('settings.language_picker_title')}
options={langOptions}
selected={language}
onSelect={setLanguage}
onClose={() => setLangPickerOpen(false)}
/>
<PickerSheet
visible={voicePickerOpen}
title={t('settings.lyra_voice_picker_title')}
options={voiceOptions}
selected={selectedVoice}
onSelect={setSelectedVoice}
onClose={() => setVoicePickerOpen(false)}
/>
</View>
);
}

View File

@ -100,7 +100,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
minWidth: 280,
minWidth: 170,
overflow: 'hidden',
}}
>
@ -190,6 +190,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
style={{ marginRight: 14 }}
/>
<Text
numberOfLines={1}
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
@ -227,6 +228,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
style={{ marginRight: 14 }}
/>
<Text
numberOfLines={1}
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',

View File

@ -449,6 +449,20 @@
"section_lyra": "Lyra (Legend)",
"lyra_voice": "Lyra-Stimme",
"lyra_voice_desc": "Voice-Picker — verfügbar im Legend-Plan",
"lyra_voice_only_legend": "Nur im Legend-Plan verfügbar",
"theme_picker_title": "Theme wählen",
"theme_system": "System",
"theme_light": "Hell",
"theme_dark": "Dunkel",
"language_picker_title": "Sprache wählen",
"language_de": "Deutsch",
"language_en": "English",
"lyra_voice_picker_title": "Lyra-Stimme wählen",
"lyra_voice_sarah": "Sarah (warm)",
"lyra_voice_aria": "Aria (ruhig)",
"lyra_voice_charlotte": "Charlotte (klar)",
"lyra_voice_alice": "Alice (nüchtern)",
"lyra_voice_bill": "Bill (tief)",
"section_debug": "Debug",
"debug_llm": "LLM-Provider",
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",

View File

@ -449,6 +449,20 @@
"section_lyra": "Lyra (Legend)",
"lyra_voice": "Lyra voice",
"lyra_voice_desc": "Voice picker — Legend-plan exclusive",
"lyra_voice_only_legend": "Legend plan only",
"theme_picker_title": "Choose theme",
"theme_system": "System",
"theme_light": "Light",
"theme_dark": "Dark",
"language_picker_title": "Choose language",
"language_de": "Deutsch",
"language_en": "English",
"lyra_voice_picker_title": "Choose Lyra voice",
"lyra_voice_sarah": "Sarah (warm)",
"lyra_voice_aria": "Aria (calm)",
"lyra_voice_charlotte": "Charlotte (clear)",
"lyra_voice_alice": "Alice (neutral)",
"lyra_voice_bill": "Bill (deep)",
"section_debug": "Debug",
"debug_llm": "LLM provider",
"debug_llm_desc": "Model & prompt tuning (DEV)",

View File

@ -0,0 +1,30 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import i18n from '../lib/i18n';
export type AppLanguage = 'de' | 'en';
const STORAGE_KEY = '@rebreak/language';
type LanguageState = {
language: AppLanguage;
setLanguage: (lang: AppLanguage) => Promise<void>;
init: () => Promise<void>;
};
export const useLanguageStore = create<LanguageState>((set) => ({
language: (i18n.language === 'de' ? 'de' : 'en') as AppLanguage,
init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
const lang: AppLanguage = stored === 'de' || stored === 'en' ? stored : 'en';
await i18n.changeLanguage(lang);
set({ language: lang });
},
setLanguage: async (lang) => {
await AsyncStorage.setItem(STORAGE_KEY, lang);
await i18n.changeLanguage(lang);
set({ language: lang });
},
}));

View File

@ -0,0 +1,38 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Appearance } from 'react-native';
export type ThemeMode = 'system' | 'light' | 'dark';
const STORAGE_KEY = '@rebreak/theme';
function resolveColorScheme(mode: ThemeMode): 'light' | 'dark' {
if (mode === 'system') {
return Appearance.getColorScheme() === 'dark' ? 'dark' : 'light';
}
return mode;
}
type ThemeState = {
mode: ThemeMode;
colorScheme: 'light' | 'dark';
setMode: (mode: ThemeMode) => Promise<void>;
init: () => Promise<void>;
};
export const useThemeStore = create<ThemeState>((set) => ({
mode: 'system',
colorScheme: resolveColorScheme('system'),
init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
const mode: ThemeMode =
stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
set({ mode, colorScheme: resolveColorScheme(mode) });
},
setMode: async (mode) => {
await AsyncStorage.setItem(STORAGE_KEY, mode);
set({ mode, colorScheme: resolveColorScheme(mode) });
},
}));