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:
parent
76f8595a4f
commit
d9bb7ef91a
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ export type Me = {
|
||||
avatar: string | null;
|
||||
plan: Plan;
|
||||
streak: number;
|
||||
lyraVoiceId: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user