feat(native): lyra voice picker UI + me-hydration

- 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-14 22:15:49 +02:00
parent 76f8595a4f
commit d9bb7ef91a
4 changed files with 95 additions and 106 deletions

View File

@ -9,7 +9,7 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { useRef, useState } from 'react'; 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';
@ -182,19 +182,43 @@ export default function SettingsScreen() {
const { plan } = useUserPlan(); const { plan } = useUserPlan();
const colors = useColors(); const colors = useColors();
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later) type LyraVoiceId = null | 'iFSsEDGbm0FiEd2IVH4w' | 'Gt7OshJCH7MuzX96wFHi';
// 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');
const { me } = useMe(); const { me } = useMe();
// TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet) const hydratedVoice =
const voiceSheetRef = useRef<TrueSheet>(null); me?.lyraVoiceId === 'iFSsEDGbm0FiEd2IVH4w' || me?.lyraVoiceId === 'Gt7OshJCH7MuzX96wFHi'
? (me.lyraVoiceId as LyraVoiceId)
: null;
const [selectedVoice, setSelectedVoice] = useState<LyraVoiceId>(hydratedVoice);
const [voiceSaving, setVoiceSaving] = useState(false);
useEffect(() => {
setSelectedVoice(hydratedVoice);
}, [hydratedVoice]);
const subscriptionSheetRef = useRef<TrueSheet>(null); const subscriptionSheetRef = useRef<TrueSheet>(null);
const planSheetRef = useRef<TrueSheet>(null); const planSheetRef = useRef<TrueSheet>(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) { async function handleToggleAppLock(next: boolean) {
if (next) { if (next) {
// Erst verifizieren, dass Face ID / Touch ID / Passcode klappt — sonst nicht aktivieren. // 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') }, { value: 'en', label: t('settings.language_en') },
]; ];
const voiceOptions: PickerOption<string>[] = [ const voiceLabel =
{ value: 'EXAVITQu4vr4xnSDxMaL', label: t('settings.lyra_voice_sarah') }, selectedVoice === 'iFSsEDGbm0FiEd2IVH4w'
{ value: 'ThT5KcBeYPX3keUQqHPh', label: t('settings.lyra_voice_aria') }, ? t('settings.lyra_voice_1')
{ value: 'XB0fDUnXU5powFXDhCwa', label: t('settings.lyra_voice_charlotte') }, : selectedVoice === 'Gt7OshJCH7MuzX96wFHi'
{ value: 'Xb7hH8MSUJpSbSDYk0k2', label: t('settings.lyra_voice_alice') }, ? t('settings.lyra_voice_2')
{ value: 'pqHfZKP75CvOlQylNhV4', label: t('settings.lyra_voice_bill') }, : t('settings.lyra_voice_default');
];
const selectedVoiceName =
voiceOptions.find((v) => v.value === selectedVoice)?.label ?? t('settings.lyra_voice_sarah');
const sections: Section[] = [ const sections: Section[] = [
// Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt // Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt
@ -352,26 +372,46 @@ export default function SettingsScreen() {
}, },
], ],
}, },
...(plan === 'legend'
? [
{ {
key: 'lyra', key: 'lyra',
title: t('settings.section_lyra'), title: t('settings.section_lyra'),
rows: [ rows: [
{ {
icon: 'mic-outline', icon: 'mic-outline' as const,
label: t('settings.lyra_voice'), label: t('settings.lyra_voice'),
sublabel: sublabel: t('settings.lyra_voice_desc'),
plan === 'legend' value: voiceLabel,
? t('settings.lyra_voice_desc') menu: {
: t('settings.lyra_voice_only_legend'), title: t('settings.lyra_voice'),
value: plan === 'legend' ? selectedVoiceName : undefined, actions: [
// Voice picker is wired but changes are local-only until {
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent. id: 'null',
onPress: title: t('settings.lyra_voice_default'),
plan === 'legend' ? () => voiceSheetRef.current?.present() : undefined, state: selectedVoice === null ? 'on' : ('off' as const),
soon: plan !== 'legend', },
{
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', key: 'account',
title: t('settings.danger_section'), title: t('settings.danger_section'),
@ -417,6 +457,12 @@ export default function SettingsScreen() {
value: me?.plan ?? '…', value: me?.plan ?? '…',
onPress: () => planSheetRef.current?.present(), 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() {
</TrueSheet> </TrueSheet>
)} )}
<TrueSheet
ref={voiceSheetRef}
detents={['auto', 1]}
cornerRadius={20}
grabber
backgroundColor={colors.surface}
>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 24, backgroundColor: colors.surface }}>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
marginBottom: 8,
}}
>
{t('settings.lyra_voice')}
</Text>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginBottom: 20,
lineHeight: 18,
}}
>
{t('settings.lyra_voice_desc')}
</Text>
{voiceOptions.map((opt, idx) => {
const isSelected = opt.value === selectedVoice;
return (
<TouchableOpacity
key={opt.value}
onPress={() => {
setSelectedVoice(opt.value);
voiceSheetRef.current?.dismiss();
}}
activeOpacity={0.6}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: idx < voiceOptions.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
>
<Text
style={{
fontSize: 16,
color: colors.text,
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
}}
>
{opt.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={20} color={colors.brandOrange} />
) : null}
</View>
</TouchableOpacity>
);
})}
</View>
</TrueSheet>
</View> </View>
); );
} }

View File

@ -25,6 +25,7 @@ export type Me = {
avatar: string | null; avatar: string | null;
plan: Plan; plan: Plan;
streak: number; streak: number;
lyraVoiceId: string | null;
created_at?: string; created_at?: string;
}; };

View File

@ -546,6 +546,9 @@
"language_picker_title": "Sprache wählen", "language_picker_title": "Sprache wählen",
"language_de": "Deutsch", "language_de": "Deutsch",
"language_en": "English", "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_picker_title": "Lyra-Stimme wählen",
"lyra_voice_sarah": "Sarah (warm)", "lyra_voice_sarah": "Sarah (warm)",
"lyra_voice_aria": "Aria (ruhig)", "lyra_voice_aria": "Aria (ruhig)",
@ -559,6 +562,8 @@
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
"debug_plan": "Plan überschreiben (DEV)", "debug_plan": "Plan überschreiben (DEV)",
"debug_plan_desc": "POST /api/dev/set-plan — nur staging", "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_page_title": "Registrierte Geräte",
"devices_slots": "Geräte-Slots", "devices_slots": "Geräte-Slots",
"devices_slots_desc": "Dein %{plan}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.", "devices_slots_desc": "Dein %{plan}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.",

View File

@ -546,6 +546,9 @@
"language_picker_title": "Choose language", "language_picker_title": "Choose language",
"language_de": "Deutsch", "language_de": "Deutsch",
"language_en": "English", "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_picker_title": "Choose Lyra voice",
"lyra_voice_sarah": "Sarah (warm)", "lyra_voice_sarah": "Sarah (warm)",
"lyra_voice_aria": "Aria (calm)", "lyra_voice_aria": "Aria (calm)",
@ -559,6 +562,8 @@
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
"debug_plan": "Override plan (DEV)", "debug_plan": "Override plan (DEV)",
"debug_plan_desc": "POST /api/dev/set-plan — staging only", "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_page_title": "Registered devices",
"devices_slots": "Device slots", "devices_slots": "Device slots",
"devices_slots_desc": "Your %{plan} plan allows this many simultaneous devices.", "devices_slots_desc": "Your %{plan} plan allows this many simultaneous devices.",