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,
|
Nunito_800ExtraBold,
|
||||||
} from '@expo-google-fonts/nunito';
|
} from '@expo-google-fonts/nunito';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useThemeStore } from '../stores/theme';
|
||||||
|
import { useLanguageStore } from '../stores/language';
|
||||||
import { BrandSplash } from '../components/BrandSplash';
|
import { BrandSplash } from '../components/BrandSplash';
|
||||||
import '../lib/i18n'; // i18next-Init via Side-Effect
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
||||||
import '../global.css';
|
import '../global.css';
|
||||||
@ -40,6 +42,8 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
function RootLayoutInner() {
|
function RootLayoutInner() {
|
||||||
const { loading, init } = useAuthStore();
|
const { loading, init } = useAuthStore();
|
||||||
|
const initTheme = useThemeStore((s) => s.init);
|
||||||
|
const initLanguage = useLanguageStore((s) => s.init);
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Nunito_400Regular,
|
Nunito_400Regular,
|
||||||
Nunito_600SemiBold,
|
Nunito_600SemiBold,
|
||||||
@ -49,6 +53,8 @@ function RootLayoutInner() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init();
|
init();
|
||||||
|
initTheme();
|
||||||
|
initLanguage();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { colors } from '../lib/theme';
|
import { colors } from '../lib/theme';
|
||||||
import { useAuthStore } from '../stores/auth';
|
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';
|
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 = {
|
type SectionRow = {
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
@ -14,6 +141,7 @@ type SectionRow = {
|
|||||||
sublabel: string;
|
sublabel: string;
|
||||||
soon?: boolean;
|
soon?: boolean;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
|
value?: string;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -28,25 +156,63 @@ export default function SettingsScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signOut } = useAuthStore();
|
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() {
|
async function handleSignOut() {
|
||||||
Alert.alert(
|
Alert.alert(t('auth.signOut'), '', [
|
||||||
t('auth.signOut'),
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
'',
|
{
|
||||||
[
|
text: t('auth.signOut'),
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
style: 'cancel',
|
||||||
{
|
onPress: async () => {
|
||||||
text: t('auth.signOut'),
|
await signOut();
|
||||||
style: 'destructive',
|
router.replace('/');
|
||||||
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[] = [
|
const sections: Section[] = [
|
||||||
{
|
{
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
@ -77,14 +243,16 @@ export default function SettingsScreen() {
|
|||||||
iconColor: '#a78bfa',
|
iconColor: '#a78bfa',
|
||||||
label: t('settings.theme'),
|
label: t('settings.theme'),
|
||||||
sublabel: t('settings.theme_desc'),
|
sublabel: t('settings.theme_desc'),
|
||||||
soon: true,
|
value: themeLabel,
|
||||||
|
onPress: () => setThemePickerOpen(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'language-outline',
|
icon: 'language-outline',
|
||||||
iconColor: '#a78bfa',
|
iconColor: '#a78bfa',
|
||||||
label: t('settings.language'),
|
label: t('settings.language'),
|
||||||
sublabel: t('settings.language_desc'),
|
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',
|
icon: 'mic-outline',
|
||||||
iconColor: '#ec4899',
|
iconColor: '#ec4899',
|
||||||
label: t('settings.lyra_voice'),
|
label: t('settings.lyra_voice'),
|
||||||
sublabel: t('settings.lyra_voice_desc'),
|
sublabel:
|
||||||
soon: true,
|
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}
|
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) => (
|
{sections.map((section) => (
|
||||||
<View key={section.key} style={{ marginBottom: 24 }}>
|
<View key={section.key} style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
@ -326,6 +469,19 @@ export default function SettingsScreen() {
|
|||||||
>
|
>
|
||||||
{t('settings.soon_badge')}
|
{t('settings.soon_badge')}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : row.value ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{row.value}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-forward"
|
name="chevron-forward"
|
||||||
@ -365,6 +521,33 @@ export default function SettingsScreen() {
|
|||||||
{Platform.OS}
|
{Platform.OS}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
shadowOpacity: 0.18,
|
shadowOpacity: 0.18,
|
||||||
shadowRadius: 20,
|
shadowRadius: 20,
|
||||||
elevation: 12,
|
elevation: 12,
|
||||||
minWidth: 280,
|
minWidth: 170,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -190,6 +190,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
style={{ marginRight: 14 }}
|
style={{ marginRight: 14 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
@ -227,6 +228,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
style={{ marginRight: 14 }}
|
style={{ marginRight: 14 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
|||||||
@ -449,6 +449,20 @@
|
|||||||
"section_lyra": "Lyra (Legend)",
|
"section_lyra": "Lyra (Legend)",
|
||||||
"lyra_voice": "Lyra-Stimme",
|
"lyra_voice": "Lyra-Stimme",
|
||||||
"lyra_voice_desc": "Voice-Picker — verfügbar im Legend-Plan",
|
"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",
|
"section_debug": "Debug",
|
||||||
"debug_llm": "LLM-Provider",
|
"debug_llm": "LLM-Provider",
|
||||||
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
|
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
|
||||||
|
|||||||
@ -449,6 +449,20 @@
|
|||||||
"section_lyra": "Lyra (Legend)",
|
"section_lyra": "Lyra (Legend)",
|
||||||
"lyra_voice": "Lyra voice",
|
"lyra_voice": "Lyra voice",
|
||||||
"lyra_voice_desc": "Voice picker — Legend-plan exclusive",
|
"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",
|
"section_debug": "Debug",
|
||||||
"debug_llm": "LLM provider",
|
"debug_llm": "LLM provider",
|
||||||
"debug_llm_desc": "Model & prompt tuning (DEV)",
|
"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