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:
parent
cddc4d0f26
commit
9ccd0bd334
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
30
apps/rebreak-native/stores/language.ts
Normal file
30
apps/rebreak-native/stores/language.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
38
apps/rebreak-native/stores/theme.ts
Normal file
38
apps/rebreak-native/stores/theme.ts
Normal 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) });
|
||||
},
|
||||
}));
|
||||
Loading…
x
Reference in New Issue
Block a user