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,
|
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',
|
? [
|
||||||
title: t('settings.section_lyra'),
|
{
|
||||||
rows: [
|
key: 'lyra',
|
||||||
{
|
title: t('settings.section_lyra'),
|
||||||
icon: 'mic-outline',
|
rows: [
|
||||||
label: t('settings.lyra_voice'),
|
{
|
||||||
sublabel:
|
icon: 'mic-outline' as const,
|
||||||
plan === 'legend'
|
label: t('settings.lyra_voice'),
|
||||||
? t('settings.lyra_voice_desc')
|
sublabel: t('settings.lyra_voice_desc'),
|
||||||
: t('settings.lyra_voice_only_legend'),
|
value: voiceLabel,
|
||||||
value: plan === 'legend' ? selectedVoiceName : undefined,
|
menu: {
|
||||||
// Voice picker is wired but changes are local-only until
|
title: t('settings.lyra_voice'),
|
||||||
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
|
actions: [
|
||||||
onPress:
|
{
|
||||||
plan === 'legend' ? () => voiceSheetRef.current?.present() : undefined,
|
id: 'null',
|
||||||
soon: plan !== 'legend',
|
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',
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user