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,
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<TrueSheet>(null);
const hydratedVoice =
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 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) {
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<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 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() {
</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 File

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

View File

@ -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.",

View File

@ -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.",