From d9bb7ef91a88c197f89364b2e47e55639607494f Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 14 May 2026 22:15:49 +0200 Subject: [PATCH] feat(native): lyra voice picker UI + me-hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.tsx: neuer Abschnitt 'Lyra (Legend)' — nur sichtbar wenn plan==='legend', UIMenu mit 3 Optionen (Standard / Stimme 1 / Stimme 2), chevron-forward Anchor. Optimistic Update via PATCH /api/profile/me/lyra-voice, Revert bei Error. - useMe.ts: lyraVoiceId im Me-Type — Hydration aus /api/auth/me beim App-Start. - de.json + en.json: settings.lyra_voice + lyra_voice_default/_1/_2. Co-Authored-By: Claude Opus 4.7 --- apps/rebreak-native/app/settings.tsx | 190 ++++++++++++--------------- apps/rebreak-native/hooks/useMe.ts | 1 + apps/rebreak-native/locales/de.json | 5 + apps/rebreak-native/locales/en.json | 5 + 4 files changed, 95 insertions(+), 106 deletions(-) diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index a66a341..9a157ec 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -9,7 +9,7 @@ import { TouchableOpacity, View, } from 'react-native'; -import { useRef, useState } from 'react'; +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'; @@ -182,19 +182,43 @@ export default function SettingsScreen() { const { plan } = useUserPlan(); const colors = useColors(); - // 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'); + type LyraVoiceId = null | 'iFSsEDGbm0FiEd2IVH4w' | 'Gt7OshJCH7MuzX96wFHi'; const { me } = useMe(); - // TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet) - const voiceSheetRef = useRef(null); + const hydratedVoice = + me?.lyraVoiceId === 'iFSsEDGbm0FiEd2IVH4w' || me?.lyraVoiceId === 'Gt7OshJCH7MuzX96wFHi' + ? (me.lyraVoiceId as LyraVoiceId) + : null; + const [selectedVoice, setSelectedVoice] = useState(hydratedVoice); + const [voiceSaving, setVoiceSaving] = useState(false); + + useEffect(() => { + setSelectedVoice(hydratedVoice); + }, [hydratedVoice]); + const subscriptionSheetRef = useRef(null); const planSheetRef = useRef(null); + async function handleVoiceSelect(voiceId: LyraVoiceId) { + if (voiceSaving || voiceId === selectedVoice) return; + const prev = selectedVoice; + setSelectedVoice(voiceId); + setVoiceSaving(true); + try { + await apiFetch('/api/profile/me/lyra-voice', { + method: 'PATCH', + body: { lyraVoiceId: voiceId }, + }); + invalidateMe(); + } catch (e: unknown) { + setSelectedVoice(prev); + Alert.alert(t('common.error'), e instanceof Error ? e.message : String(e)); + } finally { + setVoiceSaving(false); + } + } + async function handleToggleAppLock(next: boolean) { if (next) { // Erst verifizieren, dass Face ID / Touch ID / Passcode klappt — sonst nicht aktivieren. @@ -239,16 +263,12 @@ export default function SettingsScreen() { { 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 voiceLabel = + selectedVoice === 'iFSsEDGbm0FiEd2IVH4w' + ? t('settings.lyra_voice_1') + : selectedVoice === 'Gt7OshJCH7MuzX96wFHi' + ? t('settings.lyra_voice_2') + : t('settings.lyra_voice_default'); const sections: Section[] = [ // Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt @@ -352,26 +372,46 @@ export default function SettingsScreen() { }, ], }, - { - key: 'lyra', - title: t('settings.section_lyra'), - rows: [ - { - icon: 'mic-outline', - label: t('settings.lyra_voice'), - 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' ? () => voiceSheetRef.current?.present() : undefined, - soon: plan !== 'legend', - }, - ], - }, + ...(plan === 'legend' + ? [ + { + key: 'lyra', + title: t('settings.section_lyra'), + rows: [ + { + icon: 'mic-outline' as const, + label: t('settings.lyra_voice'), + sublabel: t('settings.lyra_voice_desc'), + value: voiceLabel, + menu: { + title: t('settings.lyra_voice'), + actions: [ + { + id: 'null', + title: t('settings.lyra_voice_default'), + state: selectedVoice === null ? 'on' : ('off' as const), + }, + { + id: 'iFSsEDGbm0FiEd2IVH4w', + title: t('settings.lyra_voice_1'), + state: selectedVoice === 'iFSsEDGbm0FiEd2IVH4w' ? 'on' : ('off' as const), + }, + { + id: 'Gt7OshJCH7MuzX96wFHi', + title: t('settings.lyra_voice_2'), + state: selectedVoice === 'Gt7OshJCH7MuzX96wFHi' ? 'on' : ('off' as const), + }, + ], + onSelect: (id: string) => + handleVoiceSelect( + id === 'null' ? null : (id as 'iFSsEDGbm0FiEd2IVH4w' | 'Gt7OshJCH7MuzX96wFHi'), + ), + }, + }, + ], + } satisfies Section, + ] + : []), { key: 'account', title: t('settings.danger_section'), @@ -417,6 +457,12 @@ export default function SettingsScreen() { value: me?.plan ?? '…', onPress: () => planSheetRef.current?.present(), }, + { + icon: 'pulse-outline', + label: t('settings.debug_realtime'), + sublabel: t('settings.debug_realtime_desc'), + onPress: () => router.push('/debug'), + }, ], }); } @@ -685,74 +731,6 @@ export default function SettingsScreen() { )} - - - - {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(); - }} - activeOpacity={0.6} - > - - - {opt.label} - - {isSelected ? ( - - ) : null} - - - ); - })} - - ); } diff --git a/apps/rebreak-native/hooks/useMe.ts b/apps/rebreak-native/hooks/useMe.ts index c67d29c..aeaec53 100644 --- a/apps/rebreak-native/hooks/useMe.ts +++ b/apps/rebreak-native/hooks/useMe.ts @@ -25,6 +25,7 @@ export type Me = { avatar: string | null; plan: Plan; streak: number; + lyraVoiceId: string | null; created_at?: string; }; diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index c48b42d..cc8c5c2 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -546,6 +546,9 @@ "language_picker_title": "Sprache wählen", "language_de": "Deutsch", "language_en": "English", + "lyra_voice_default": "Standard", + "lyra_voice_1": "Stimme 1", + "lyra_voice_2": "Stimme 2", "lyra_voice_picker_title": "Lyra-Stimme wählen", "lyra_voice_sarah": "Sarah (warm)", "lyra_voice_aria": "Aria (ruhig)", @@ -559,6 +562,8 @@ "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", "debug_plan": "Plan überschreiben (DEV)", "debug_plan_desc": "POST /api/dev/set-plan — nur staging", + "debug_realtime": "Realtime-Verbindung (DEV)", + "debug_realtime_desc": "Connection-State, Channels, Event-Log", "devices_page_title": "Registrierte Geräte", "devices_slots": "Geräte-Slots", "devices_slots_desc": "Dein %{plan}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 495d9e8..0cc24f8 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -546,6 +546,9 @@ "language_picker_title": "Choose language", "language_de": "Deutsch", "language_en": "English", + "lyra_voice_default": "Default", + "lyra_voice_1": "Voice 1", + "lyra_voice_2": "Voice 2", "lyra_voice_picker_title": "Choose Lyra voice", "lyra_voice_sarah": "Sarah (warm)", "lyra_voice_aria": "Aria (calm)", @@ -559,6 +562,8 @@ "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", "debug_plan": "Override plan (DEV)", "debug_plan_desc": "POST /api/dev/set-plan — staging only", + "debug_realtime": "Realtime connection (DEV)", + "debug_realtime_desc": "Connection state, channels, event log", "devices_page_title": "Registered devices", "devices_slots": "Device slots", "devices_slots_desc": "Your %{plan} plan allows this many simultaneous devices.",