diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 6978f2c..67c83ed 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -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(() => { diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 68b43c7..3b0fb14 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -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 = { value: T; label: string }; + +function PickerSheet({ + visible, + title, + options, + selected, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + options: PickerOption[]; + 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 ( + + + true} + style={{ + backgroundColor: '#fff', + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + paddingBottom: 34, + transform: [{ translateY }], + }} + > + + + {title} + + {options.map((opt) => ( + { + onSelect(opt.value); + onClose(); + }} + style={({ pressed }) => ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 14, + backgroundColor: pressed ? '#f5f5f5' : 'transparent', + })} + > + + {opt.label} + + {opt.value === selected && ( + + )} + + ))} + + + + ); +} + +// ─── Settings Screen ─────────────────────────────────────────────────────── + type SectionRow = { icon: React.ComponentProps['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[] = [ + { 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[] = [ + { value: 'de', label: t('settings.language_de') }, + { value: 'en', label: t('settings.language_en') }, + ]; + + const voiceOptions: PickerOption[] = [ + { 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} > - - - - - {t('settings.coming_soon_title')} - - - {t('settings.coming_soon_desc')} - - - - {sections.map((section) => ( {t('settings.soon_badge')} + ) : row.value ? ( + + {row.value} + ) : ( + + setThemePickerOpen(false)} + /> + + setLangPickerOpen(false)} + /> + + setVoicePickerOpen(false)} + /> ); } diff --git a/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx b/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx index 4a7adab..9510c95 100644 --- a/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx +++ b/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx @@ -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 }} /> Promise; + init: () => Promise; +}; + +export const useLanguageStore = create((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 }); + }, +})); diff --git a/apps/rebreak-native/stores/theme.ts b/apps/rebreak-native/stores/theme.ts new file mode 100644 index 0000000..99a8e94 --- /dev/null +++ b/apps/rebreak-native/stores/theme.ts @@ -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; + init: () => Promise; +}; + +export const useThemeStore = create((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) }); + }, +}));