feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset, safer uploaded-avatar path validation, preset_avatars + HTTP tests. - Expo: personal-info (library + presets), profile tab avatar, resolveApiMediaUrl, auth hooks cache sync, Web multipart helper, partial-save messaging + profile i18n. - Includes existing edits to conversation screen and voice use-player. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
PlusCircle,
|
||||
Square,
|
||||
Type,
|
||||
Volume2,
|
||||
X,
|
||||
@@ -95,6 +94,16 @@ function isFirstAssistantTextPart(listKey: string, messageId: string): boolean {
|
||||
return listKey === messageId || listKey === `${messageId}_part_0`;
|
||||
}
|
||||
|
||||
/** PlaybackItem.messageRef.listKey 可与 `item.id` 或 `${id}_seg_/part_` 后缀对齐 */
|
||||
function playbackMessageRefMatchesMessage(
|
||||
playbackListKey: string | undefined,
|
||||
messageItemId: string,
|
||||
): boolean {
|
||||
if (!playbackListKey?.length) return false;
|
||||
if (playbackListKey === messageItemId) return true;
|
||||
return playbackListKey.startsWith(`${messageItemId}_`);
|
||||
}
|
||||
|
||||
/** 展平消息列表:assistant 消息按 [SPLIT] 边界拆成多条,每条一个 listKey */
|
||||
function flattenMessagesForList(
|
||||
messages: MessageItem[],
|
||||
@@ -139,8 +148,10 @@ function MessageBubble({
|
||||
currentPlaybackUri,
|
||||
currentPlaybackItem,
|
||||
playbackIsPlaying,
|
||||
playbackIsPaused,
|
||||
onPlayVoiceExclusive,
|
||||
onPausePlayback,
|
||||
onPauseAssistantTts,
|
||||
onResumeAssistantTts,
|
||||
onInterruptAssistantTts,
|
||||
onReplayAssistantTts,
|
||||
bubbleTextStyle,
|
||||
@@ -155,8 +166,10 @@ function MessageBubble({
|
||||
currentPlaybackUri: string | null;
|
||||
currentPlaybackItem: PlaybackItem | null;
|
||||
playbackIsPlaying: boolean;
|
||||
playbackIsPaused: boolean;
|
||||
onPlayVoiceExclusive: (uri: string) => void;
|
||||
onPausePlayback: () => void;
|
||||
onPauseAssistantTts: () => void;
|
||||
onResumeAssistantTts: () => void;
|
||||
onInterruptAssistantTts: () => void;
|
||||
onReplayAssistantTts: (messageId: string, urls: string[]) => void;
|
||||
bubbleTextStyle?: TextStyle;
|
||||
@@ -177,14 +190,40 @@ function MessageBubble({
|
||||
const isAssistantTextFirstPart =
|
||||
!isUser && !isVoice && isFirstAssistantTextPart(listKey, item.id);
|
||||
|
||||
const isThisBubbleTtsTarget =
|
||||
const playbackKind = currentPlaybackItem?.kind;
|
||||
const playbackRefListKey = currentPlaybackItem?.messageRef?.listKey;
|
||||
const matchesThisMessageForTts =
|
||||
!isUser &&
|
||||
!isVoice &&
|
||||
playbackIsPlaying &&
|
||||
currentPlaybackItem?.kind !== 'voice' &&
|
||||
currentPlaybackItem?.messageRef?.listKey === item.id;
|
||||
playbackKind !== 'voice' &&
|
||||
playbackMessageRefMatchesMessage(playbackRefListKey, item.id);
|
||||
|
||||
const isAssistantTtsHighlight = isThisBubbleTtsTarget;
|
||||
const playbackEngaged = playbackIsPlaying || playbackIsPaused;
|
||||
const isThisBubbleActiveTts =
|
||||
matchesThisMessageForTts && playbackEngaged;
|
||||
|
||||
const isThisBubbleTtsPlaying =
|
||||
isThisBubbleActiveTts && playbackIsPlaying;
|
||||
const isThisBubbleTtsPaused = isThisBubbleActiveTts && playbackIsPaused;
|
||||
|
||||
const isAssistantTtsHighlight = isThisBubbleActiveTts;
|
||||
|
||||
const isThisVoiceTrack =
|
||||
!!item.audioUri &&
|
||||
currentPlaybackUri === item.audioUri &&
|
||||
currentPlaybackItem?.kind === 'voice';
|
||||
|
||||
const readAloudAccessibilityLabel = isThisBubbleTtsPlaying
|
||||
? t('readAloudPause')
|
||||
: isThisBubbleTtsPaused
|
||||
? t('readAloudResume')
|
||||
: t('readAloudAgain');
|
||||
|
||||
const ReadAloudIconComponent = isThisBubbleTtsPlaying
|
||||
? Pause
|
||||
: isThisBubbleTtsPaused
|
||||
? Play
|
||||
: Volume2;
|
||||
|
||||
const assistantTextBubbleBody = (
|
||||
<View
|
||||
@@ -200,28 +239,28 @@ function MessageBubble({
|
||||
textStyle={bubbleTextStyle}
|
||||
/>
|
||||
{isAssistantTextFirstPart &&
|
||||
(ttsUrls.length > 0 || isThisBubbleTtsTarget) ? (
|
||||
(ttsUrls.length > 0 || isThisBubbleActiveTts) ? (
|
||||
<View style={styles.readAloudRow}>
|
||||
<Pressable
|
||||
disabled={isThisBubbleTtsTarget}
|
||||
onPress={() => {
|
||||
onReplayAssistantTts(item.id, ttsUrls);
|
||||
if (isThisBubbleTtsPlaying) {
|
||||
onPauseAssistantTts();
|
||||
} else if (isThisBubbleTtsPaused) {
|
||||
onResumeAssistantTts();
|
||||
} else {
|
||||
onReplayAssistantTts(item.id, ttsUrls);
|
||||
}
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.readAloudButton,
|
||||
{ width: readAloudButtonSize, height: readAloudButtonSize },
|
||||
!isThisBubbleTtsTarget && pressed ? { opacity: 0.85 } : null,
|
||||
pressed ? { opacity: 0.85 } : null,
|
||||
]}
|
||||
accessibilityElementsHidden={isThisBubbleTtsTarget}
|
||||
importantForAccessibility={
|
||||
isThisBubbleTtsTarget ? 'no-hide-descendants' : 'auto'
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('readAloudAgain')}
|
||||
accessibilityState={{ disabled: isThisBubbleTtsTarget }}
|
||||
accessibilityLabel={readAloudAccessibilityLabel}
|
||||
>
|
||||
<Icon
|
||||
as={isThisBubbleTtsTarget ? Square : Volume2}
|
||||
as={ReadAloudIconComponent}
|
||||
size={readAloudIconSize}
|
||||
color={CHAT_COLORS.primary}
|
||||
/>
|
||||
@@ -259,19 +298,19 @@ function MessageBubble({
|
||||
durationSeconds={item.durationSeconds ?? 0}
|
||||
audioUri={item.audioUri}
|
||||
isUser={isUser}
|
||||
isPlaying={
|
||||
!!item.audioUri &&
|
||||
playbackIsPlaying &&
|
||||
currentPlaybackUri === item.audioUri
|
||||
}
|
||||
isPlaying={playbackIsPlaying && isThisVoiceTrack}
|
||||
durationTextStyle={voiceDurationTextStyle}
|
||||
onPlayPress={() => {
|
||||
if (!item.audioUri) return;
|
||||
if (playbackIsPlaying && currentPlaybackUri === item.audioUri) {
|
||||
onPausePlayback();
|
||||
} else {
|
||||
onPlayVoiceExclusive(item.audioUri);
|
||||
if (playbackIsPlaying && isThisVoiceTrack) {
|
||||
onPauseAssistantTts();
|
||||
return;
|
||||
}
|
||||
if (playbackIsPaused && isThisVoiceTrack) {
|
||||
onResumeAssistantTts();
|
||||
return;
|
||||
}
|
||||
onPlayVoiceExclusive(item.audioUri);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -286,7 +325,7 @@ function MessageBubble({
|
||||
) : (
|
||||
<View style={styles.assistantBubbleWrap}>
|
||||
{assistantTextBubbleBody}
|
||||
{isThisBubbleTtsTarget ? (
|
||||
{isThisBubbleActiveTts ? (
|
||||
<Pressable
|
||||
onPress={onInterruptAssistantTts}
|
||||
style={({ pressed }) => [
|
||||
@@ -1053,6 +1092,8 @@ export default function ConversationScreen() {
|
||||
status: playerStatus,
|
||||
currentSource,
|
||||
currentPlaybackItem,
|
||||
pausePlayback,
|
||||
resumePlayback,
|
||||
} = usePlayer();
|
||||
|
||||
const handleTtsPlaybackResume = useCallback(() => {
|
||||
@@ -1145,9 +1186,12 @@ export default function ConversationScreen() {
|
||||
[enqueueExclusive],
|
||||
);
|
||||
|
||||
const handlePausePlayback = useCallback(() => {
|
||||
void stop();
|
||||
}, [stop]);
|
||||
pausePlayback();
|
||||
}, [pausePlayback]);
|
||||
|
||||
const handleResumeAssistantPlayback = useCallback(() => {
|
||||
void resumePlayback();
|
||||
}, [resumePlayback]);
|
||||
|
||||
const handleReplayAssistantTts = useCallback(
|
||||
(messageId: string, urls: string[]) => {
|
||||
@@ -1468,8 +1512,10 @@ export default function ConversationScreen() {
|
||||
currentPlaybackUri={currentSource}
|
||||
currentPlaybackItem={currentPlaybackItem}
|
||||
playbackIsPlaying={playerStatus === 'playing'}
|
||||
playbackIsPaused={playerStatus === 'paused'}
|
||||
onPauseAssistantTts={handlePauseAssistantPlayback}
|
||||
onResumeAssistantTts={handleResumeAssistantPlayback}
|
||||
onPlayVoiceExclusive={handlePlayVoiceExclusive}
|
||||
onPausePlayback={handlePausePlayback}
|
||||
onInterruptAssistantTts={handleInterruptAssistantTts}
|
||||
onReplayAssistantTts={handleReplayAssistantTts}
|
||||
bubbleTextStyle={chatBubbleTextStyle}
|
||||
@@ -1498,7 +1544,8 @@ export default function ConversationScreen() {
|
||||
agentName={t('agentName')}
|
||||
streamingTtsActive={
|
||||
!!streamingMessage &&
|
||||
playerStatus === 'playing' &&
|
||||
(playerStatus === 'playing' ||
|
||||
playerStatus === 'paused') &&
|
||||
currentPlaybackItem?.kind === 'tts_auto'
|
||||
}
|
||||
onStreamingPress={handleInterruptAssistantTts}
|
||||
|
||||
@@ -1,24 +1,65 @@
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { resolveApiMediaUrl } from '@/core/api/media-url';
|
||||
import { ApiError } from '@/core/api/types';
|
||||
import { buildAvatarUploadFormData } from '@/features/auth/avatar-upload-form-data';
|
||||
import {
|
||||
useAvatarPresets,
|
||||
useSetAvatarPreset,
|
||||
useUpdateNickname,
|
||||
useUploadAvatar,
|
||||
} from '@/features/auth/hooks';
|
||||
import { useProfile, useUpdateProfile } from '@/features/profile/hooks';
|
||||
|
||||
export default function PersonalInfoScreen() {
|
||||
const { data: profile } = useProfile();
|
||||
const update = useUpdateProfile();
|
||||
const PRESET_GRID_H_PADDING = 16 * 2;
|
||||
const TILE_GAP = 12;
|
||||
const COLS = 4;
|
||||
|
||||
function computePresetTileSize(): number {
|
||||
const w = Dimensions.get('window').width;
|
||||
return (w - PRESET_GRID_H_PADDING - TILE_GAP * (COLS - 1)) / COLS;
|
||||
}
|
||||
|
||||
type AvatarModalStep = 'menu' | 'presets';
|
||||
|
||||
export default function PersonalInfoScreen() {
|
||||
const { t } = useTranslation('profile');
|
||||
const { data: profile, isLoading: profileLoading } = useProfile();
|
||||
const update = useUpdateProfile();
|
||||
const updateNicknameMut = useUpdateNickname();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const setPreset = useSetAvatarPreset();
|
||||
const { data: presets, isLoading: presetsLoading } = useAvatarPresets();
|
||||
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [birthYear, setBirthYear] = useState('');
|
||||
const [birthPlace, setBirthPlace] = useState('');
|
||||
const [grewUpPlace, setGrewUpPlace] = useState('');
|
||||
const [occupation, setOccupation] = useState('');
|
||||
|
||||
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||
const [avatarStep, setAvatarStep] = useState<AvatarModalStep>('menu');
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setNickname(profile.nickname ?? '');
|
||||
setBirthYear(profile.birth_year?.toString() ?? '');
|
||||
setBirthPlace(profile.birth_place ?? '');
|
||||
setGrewUpPlace(profile.grew_up_place ?? '');
|
||||
@@ -26,53 +67,275 @@ export default function PersonalInfoScreen() {
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const handleSave = () => {
|
||||
update.mutate({
|
||||
birth_year: birthYear ? Number(birthYear) : null,
|
||||
birth_place: birthPlace || null,
|
||||
grew_up_place: grewUpPlace || null,
|
||||
occupation: occupation || null,
|
||||
});
|
||||
const closeAvatarModal = () => {
|
||||
setAvatarModalOpen(false);
|
||||
setAvatarStep('menu');
|
||||
};
|
||||
|
||||
const pickFromLibrary = async () => {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert('', t('personalInfo.libraryPermissionDenied'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (result.canceled) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
|
||||
try {
|
||||
const form = await buildAvatarUploadFormData(asset);
|
||||
await uploadAvatar.mutateAsync(form);
|
||||
closeAvatarModal();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
Alert.alert(t('personalInfo.avatarUploadFailed'), msg);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreset = async (presetId: string) => {
|
||||
try {
|
||||
await setPreset.mutateAsync(presetId);
|
||||
closeAvatarModal();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
Alert.alert(t('personalInfo.avatarPresetFailed'), msg);
|
||||
}
|
||||
};
|
||||
|
||||
const avatarBusy = uploadAvatar.isPending || setPreset.isPending;
|
||||
const avatarUri = resolveApiMediaUrl(profile?.avatar_url ?? null);
|
||||
const tileSize = computePresetTileSize();
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = nickname.trim();
|
||||
if (!trimmed) {
|
||||
Alert.alert('', t('personalInfo.nicknameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
let nicknameCommitted = false;
|
||||
try {
|
||||
if (profile && trimmed !== profile.nickname) {
|
||||
await updateNicknameMut.mutateAsync({ nickname: trimmed });
|
||||
nicknameCommitted = true;
|
||||
}
|
||||
await update.mutateAsync({
|
||||
birth_year: birthYear ? Number(birthYear) : null,
|
||||
birth_place: birthPlace || null,
|
||||
grew_up_place: grewUpPlace || null,
|
||||
occupation: occupation || null,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
if (nicknameCommitted) {
|
||||
Alert.alert(t('personalInfo.savePartialTitle'), `${t('personalInfo.savePartialBody')}\n\n${msg}`);
|
||||
} else {
|
||||
Alert.alert(t('personalInfo.saveFailed'), msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saving = update.isPending || updateNicknameMut.isPending;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<ScreenHeader title="个人信息" />
|
||||
<SafeAreaView className="flex-1 gap-4 px-4 pt-4">
|
||||
<ScreenHeader title={t('personalInfo.title')} />
|
||||
<SafeAreaView className="flex-1 gap-4 px-4 pt-4" edges={['bottom']}>
|
||||
<View className="items-center gap-2">
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('personalInfo.changeAvatar')}
|
||||
disabled={avatarBusy || profileLoading}
|
||||
onPress={() => {
|
||||
setAvatarStep('menu');
|
||||
setAvatarModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<View className="relative h-28 w-28 overflow-hidden rounded-full bg-muted">
|
||||
{avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: avatarUri }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
contentFit="cover"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text variant="large" className="text-muted-foreground">
|
||||
{nickname.trim().slice(0, 1).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{avatarBusy ? (
|
||||
<View className="absolute inset-0 items-center justify-center bg-background/60">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{t('personalInfo.changeAvatar')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-3">
|
||||
<Text variant="small" className="text-muted-foreground">
|
||||
{t('personalInfo.nickname')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="出生年份"
|
||||
placeholder={t('personalInfo.nicknamePlaceholder')}
|
||||
value={nickname}
|
||||
onChangeText={setNickname}
|
||||
autoCapitalize="none"
|
||||
maxLength={50}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('personalInfo.birthYearPlaceholder')}
|
||||
value={birthYear}
|
||||
onChangeText={setBirthYear}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
<Input
|
||||
placeholder="出生地"
|
||||
placeholder={t('personalInfo.birthPlacePlaceholder')}
|
||||
value={birthPlace}
|
||||
onChangeText={setBirthPlace}
|
||||
/>
|
||||
<Input
|
||||
placeholder="成长地"
|
||||
placeholder={t('personalInfo.grewUpPlaceholder')}
|
||||
value={grewUpPlace}
|
||||
onChangeText={setGrewUpPlace}
|
||||
/>
|
||||
<Input
|
||||
placeholder="职业"
|
||||
placeholder={t('personalInfo.occupationPlaceholder')}
|
||||
value={occupation}
|
||||
onChangeText={setOccupation}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{update.error && (
|
||||
{(update.error ?? updateNicknameMut.error) != null ? (
|
||||
<Text className="text-sm text-destructive">
|
||||
{update.error.message}
|
||||
{(updateNicknameMut.error ?? update.error)?.message}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Button onPress={handleSave} disabled={update.isPending}>
|
||||
<Text>{update.isPending ? '保存中...' : '保存'}</Text>
|
||||
<Button onPress={() => void handleSave()} disabled={saving}>
|
||||
<Text>{saving ? t('personalInfo.saving') : t('personalInfo.save')}</Text>
|
||||
</Button>
|
||||
</SafeAreaView>
|
||||
|
||||
<Modal
|
||||
visible={avatarModalOpen}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={closeAvatarModal}
|
||||
>
|
||||
<SafeAreaView className="flex-1 bg-background">
|
||||
<View className="flex-row items-center justify-between border-b border-border px-3 py-2">
|
||||
{avatarStep === 'presets' ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={12}
|
||||
onPress={() => setAvatarStep('menu')}
|
||||
>
|
||||
<Text className="text-primary">{t('personalInfo.back')}</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View className="w-14" />
|
||||
)}
|
||||
<Text variant="large">
|
||||
{avatarStep === 'presets'
|
||||
? t('personalInfo.presetPickTitle')
|
||||
: t('personalInfo.changeAvatar')}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={12}
|
||||
onPress={closeAvatarModal}
|
||||
>
|
||||
<Text className="text-primary">{t('personalInfo.cancel')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{avatarStep === 'menu' ? (
|
||||
<View className="gap-3 px-4 pt-6">
|
||||
<Button variant="outline" onPress={() => void pickFromLibrary()}>
|
||||
<Text>{t('personalInfo.chooseFromLibrary')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => setAvatarStep('presets')}
|
||||
>
|
||||
<Text>{t('personalInfo.choosePreset')}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-1 px-4 pt-4">
|
||||
{presetsLoading ? (
|
||||
<ActivityIndicator style={{ marginTop: 32 }} />
|
||||
) : (
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: TILE_GAP,
|
||||
paddingBottom: 24,
|
||||
}}
|
||||
>
|
||||
{(presets ?? []).map((item) => {
|
||||
const uri = resolveApiMediaUrl(item.url);
|
||||
return (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`preset-${item.id}`}
|
||||
disabled={avatarBusy}
|
||||
className="overflow-hidden rounded-xl bg-muted"
|
||||
onPress={() => void applyPreset(item.id)}
|
||||
style={{
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
}}
|
||||
>
|
||||
{uri ? (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { Pressable, ScrollView, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { resolveApiMediaUrl } from '@/core/api/media-url';
|
||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
import { useSession, useLogout } from '@/features/auth/hooks';
|
||||
import { useCurrentPlan } from '@/features/profile/hooks';
|
||||
@@ -181,6 +183,8 @@ export default function ProfileScreen() {
|
||||
themeOptions.find((o) => o.value === themeName)?.label ??
|
||||
tApp('theme.default');
|
||||
|
||||
const avatarUri = resolveApiMediaUrl(user?.avatar_url ?? null);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
@@ -191,10 +195,19 @@ export default function ProfileScreen() {
|
||||
<View className="items-center gap-4 py-8">
|
||||
<View className="relative">
|
||||
<View
|
||||
className="h-24 w-24 items-center justify-center rounded-full bg-muted"
|
||||
className="h-24 w-24 items-center justify-center overflow-hidden rounded-full bg-muted"
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
>
|
||||
<Icon as={User} className="text-muted-foreground" size={40} />
|
||||
{avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: avatarUri }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
contentFit="cover"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<Icon as={User} className="text-muted-foreground" size={40} />
|
||||
)}
|
||||
</View>
|
||||
<Pressable
|
||||
className="absolute bottom-0 right-0 h-9 w-9 items-center justify-center rounded-full border-2 border-background bg-primary"
|
||||
|
||||
14
app-expo/src/core/api/media-url.ts
Normal file
14
app-expo/src/core/api/media-url.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { config } from '@/core/config';
|
||||
|
||||
/** 将 API 返回的相对路径(如 `/api/auth/avatars/x.jpg`)转为可请求的绝对 URL。 */
|
||||
export function resolveApiMediaUrl(
|
||||
pathOrUrl: string | null | undefined,
|
||||
): string | null {
|
||||
if (pathOrUrl == null || pathOrUrl === '') return null;
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
||||
if (pathOrUrl.startsWith('/')) {
|
||||
const base = config.apiBaseUrl.replace(/\/$/, '');
|
||||
return `${base}${pathOrUrl}`;
|
||||
}
|
||||
return pathOrUrl;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { api } from '@/core/api/client';
|
||||
|
||||
import type {
|
||||
AvatarPresetItem,
|
||||
ChangePasswordRequest,
|
||||
ChangePhoneRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
SetAvatarPresetRequest,
|
||||
SmsLoginRequest,
|
||||
SmsRegisterRequest,
|
||||
SmsRequest,
|
||||
@@ -90,4 +92,14 @@ export const authApi = {
|
||||
uploadAvatar(file: FormData) {
|
||||
return api.post<UserInfo>(`${AUTH}/me/avatar`, { body: file });
|
||||
},
|
||||
|
||||
fetchAvatarPresets() {
|
||||
return api.get<AvatarPresetItem[]>(`${AUTH}/avatar-presets`, {
|
||||
skipAuth: true,
|
||||
});
|
||||
},
|
||||
|
||||
setAvatarPreset(body: SetAvatarPresetRequest) {
|
||||
return api.put<UserInfo>(`${AUTH}/me/avatar/preset`, { body });
|
||||
},
|
||||
} as const;
|
||||
|
||||
80
app-expo/src/features/auth/avatar-upload-form-data.ts
Normal file
80
app-expo/src/features/auth/avatar-upload-form-data.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type * as ImagePicker from 'expo-image-picker';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type AvatarMime = 'image/jpeg' | 'image/png' | 'image/webp';
|
||||
|
||||
function inferMimeFromUri(uri: string): AvatarMime {
|
||||
const u = uri.toLowerCase();
|
||||
if (u.endsWith('.png')) return 'image/png';
|
||||
if (u.endsWith('.webp')) return 'image/webp';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function coerceMime(value: string | null | undefined, uri: string): AvatarMime {
|
||||
if (
|
||||
value === 'image/jpeg' ||
|
||||
value === 'image/png' ||
|
||||
value === 'image/webp'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return inferMimeFromUri(uri);
|
||||
}
|
||||
|
||||
function mimeToFilename(mime: AvatarMime): string {
|
||||
switch (mime) {
|
||||
case 'image/png':
|
||||
return 'avatar.png';
|
||||
case 'image/webp':
|
||||
return 'avatar.webp';
|
||||
default:
|
||||
return 'avatar.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建与后端 `POST /api/auth/me/avatar` 约定的 multipart(字段名 `file`)。
|
||||
* Native:`{ uri, name, type }`;Web:`File`,避免 RN FormData 在 Web 上不识别 `uri`。
|
||||
*/
|
||||
export async function buildAvatarUploadFormData(
|
||||
asset: ImagePicker.ImagePickerAsset,
|
||||
): Promise<FormData> {
|
||||
const uri = asset.uri;
|
||||
const mime = coerceMime(asset.mimeType, uri);
|
||||
const filename = mimeToFilename(mime);
|
||||
const form = new FormData();
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const webFile = asset.file;
|
||||
if (
|
||||
webFile instanceof File &&
|
||||
(webFile.type === 'image/jpeg' ||
|
||||
webFile.type === 'image/png' ||
|
||||
webFile.type === 'image/webp')
|
||||
) {
|
||||
form.append(
|
||||
'file',
|
||||
webFile,
|
||||
webFile.name || mimeToFilename(coerceMime(webFile.type, uri)),
|
||||
);
|
||||
return form;
|
||||
}
|
||||
|
||||
const res = await fetch(uri);
|
||||
const blob = await res.blob();
|
||||
const type = coerceMime(blob.type, uri);
|
||||
form.append('file', new File([blob], mimeToFilename(type), { type }));
|
||||
return form;
|
||||
}
|
||||
|
||||
form.append(
|
||||
'file',
|
||||
{
|
||||
uri,
|
||||
name: filename,
|
||||
type: mime,
|
||||
} as unknown as Blob,
|
||||
);
|
||||
|
||||
return form;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
SmsRegisterRequest,
|
||||
SmsRequest,
|
||||
TokenResponse,
|
||||
UpdateNicknameRequest,
|
||||
UserInfo,
|
||||
} from './types';
|
||||
|
||||
@@ -24,6 +25,16 @@ export const authKeys = {
|
||||
tokenCheck: ['auth', 'token-check'] as const,
|
||||
};
|
||||
|
||||
const PROFILE_QUERY_PREFIX = ['profile'] as const;
|
||||
|
||||
function syncSessionAndProfileQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
user: UserInfo,
|
||||
) {
|
||||
queryClient.setQueryData(authKeys.session, user);
|
||||
queryClient.invalidateQueries({ queryKey: PROFILE_QUERY_PREFIX });
|
||||
}
|
||||
|
||||
// ─── useSession ───
|
||||
|
||||
/**
|
||||
@@ -162,6 +173,45 @@ export function useSmsCode() {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Avatar / nickname ───
|
||||
|
||||
export function useAvatarPresets() {
|
||||
return useQuery({
|
||||
queryKey: ['avatar-presets'],
|
||||
queryFn: () => authApi.fetchAvatarPresets(),
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNickname() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: UpdateNicknameRequest) =>
|
||||
authApi.updateNickname(body),
|
||||
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadAvatar() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (form: FormData) => authApi.uploadAvatar(form),
|
||||
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetAvatarPreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (presetId: string) =>
|
||||
authApi.setAvatarPreset({ preset_id: presetId }),
|
||||
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useLogout ───
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,6 +79,15 @@ export interface UpdateNicknameRequest {
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export interface AvatarPresetItem {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SetAvatarPresetRequest {
|
||||
preset_id: string;
|
||||
}
|
||||
|
||||
// ─── Session state ───
|
||||
|
||||
export type SessionStatus =
|
||||
|
||||
@@ -15,6 +15,10 @@ interface UsePlayerResult {
|
||||
enqueue: (item: PlaybackItem) => void;
|
||||
/** Replace queue and play this item (e.g. user voice bubble vs other sources). */
|
||||
enqueueExclusive: (item: PlaybackItem) => Promise<void>;
|
||||
/** Pause native playback without draining queue(与 stop 清空队列不同)。 */
|
||||
pausePlayback: () => void;
|
||||
/** Continue after pausePlayback(需 status === 'paused') */
|
||||
resumePlayback: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
@@ -68,9 +72,11 @@ export function usePlayer(): UsePlayerResult {
|
||||
useEffect(() => {
|
||||
if (!currentSource || !player) return;
|
||||
if (!playerStatus.isLoaded) return;
|
||||
/** 先于 isLoaded「抢暂停」时需保留暂停,避免本条自动 play 覆盖 pause */
|
||||
if (status === 'paused') return;
|
||||
player.play();
|
||||
isPlayingRef.current = true;
|
||||
}, [currentSource, player, playerStatus.isLoaded]);
|
||||
}, [currentSource, player, playerStatus.isLoaded, status]);
|
||||
|
||||
const playNext = useCallback(async () => {
|
||||
if (isPlayNextInProgressRef.current) return;
|
||||
@@ -114,6 +120,7 @@ export function usePlayer(): UsePlayerResult {
|
||||
|
||||
// Detect playback completion → advance queue(必须曾 playing,避免换源瞬间沿用上一条的 duration/currentTime)
|
||||
useEffect(() => {
|
||||
if (status === 'paused') return;
|
||||
if (!currentSource || !isPlayingRef.current) return;
|
||||
|
||||
const { playing, currentTime, duration } = playerStatus;
|
||||
@@ -128,7 +135,32 @@ export function usePlayer(): UsePlayerResult {
|
||||
isPlayingRef.current = false;
|
||||
playNext();
|
||||
}
|
||||
}, [playerStatus, currentSource, playNext]);
|
||||
}, [playerStatus, currentSource, playNext, status]);
|
||||
|
||||
const pausePlayback = useCallback(() => {
|
||||
setStatus((s) => {
|
||||
if (s !== 'playing') return s;
|
||||
if (player) {
|
||||
player.pause();
|
||||
}
|
||||
isPlayingRef.current = false;
|
||||
return 'paused';
|
||||
});
|
||||
}, [player]);
|
||||
|
||||
const resumePlayback = useCallback(async () => {
|
||||
if (status !== 'paused') return;
|
||||
const acquired = await audioFocus.acquireForPlayback();
|
||||
if (!acquired) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
if (!player) return;
|
||||
if (!playerStatus.isLoaded) return;
|
||||
player.play();
|
||||
setStatus('playing');
|
||||
isPlayingRef.current = true;
|
||||
}, [status, player, playerStatus.isLoaded]);
|
||||
|
||||
// Subscribe to audioFocus owner changes for recorder → idle recovery
|
||||
useEffect(() => {
|
||||
@@ -205,6 +237,8 @@ export function usePlayer(): UsePlayerResult {
|
||||
currentPlaybackItem,
|
||||
enqueue,
|
||||
enqueueExclusive,
|
||||
pausePlayback,
|
||||
resumePlayback,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,212 +1,241 @@
|
||||
// This file is automatically generated by i18next-cli. Do not edit manually.
|
||||
interface Resources {
|
||||
app: {
|
||||
languages: {
|
||||
en: 'English';
|
||||
system: 'System';
|
||||
zh: 'Chinese';
|
||||
};
|
||||
name: 'Life Echo';
|
||||
tabs: {
|
||||
conversations: 'Chats';
|
||||
explore: 'Explore';
|
||||
home: 'Home';
|
||||
memoir: 'Memoir';
|
||||
profile: 'Profile';
|
||||
};
|
||||
theme: {
|
||||
default: 'Default';
|
||||
};
|
||||
};
|
||||
auth: {
|
||||
login: {
|
||||
codeLabel: 'Verification Code';
|
||||
getCode: 'Get Code';
|
||||
getCodeCountdown: 'Retry in {{seconds}}s';
|
||||
networkError: 'Network error. Please try again later.';
|
||||
phoneLabel: 'Phone Number';
|
||||
phonePlaceholder: 'Enter your phone number';
|
||||
privacyPolicy: 'Privacy Policy';
|
||||
submit: 'Login';
|
||||
termsAnd: 'and';
|
||||
termsIntro: 'I agree to the';
|
||||
termsRequired: 'Please agree to the User Agreement and Privacy Policy first';
|
||||
termsRequiredConfirm: 'OK';
|
||||
termsRequiredTitle: 'Agreement Required';
|
||||
userAgreement: 'User Agreement';
|
||||
welcomeSubtitle: 'Some lives grow richer the more you savor them.';
|
||||
welcomeTitle: 'Welcome back';
|
||||
};
|
||||
};
|
||||
common: {
|
||||
chapterLabel: '';
|
||||
chapterReading: {
|
||||
backgroundColor: '';
|
||||
bgPureWhite: '';
|
||||
bgSepia: '';
|
||||
close: '';
|
||||
fontSize: '';
|
||||
readingSettings: '';
|
||||
typography: '';
|
||||
};
|
||||
continueWriting: '';
|
||||
docs: 'Docs';
|
||||
emptySubtitle: '';
|
||||
emptyTitle: '';
|
||||
readMemory: '';
|
||||
startChapter: '';
|
||||
statusDrafting: '';
|
||||
statusLocked: '';
|
||||
statusPending: '';
|
||||
wordsCount: '';
|
||||
};
|
||||
conversation: {
|
||||
addMore: 'More';
|
||||
agentName: 'Life Echo';
|
||||
assistantReplying: 'Replying…';
|
||||
cancel: 'Cancel';
|
||||
cancelRecording: 'Cancel recording';
|
||||
cannotReadAloud: 'Read unavailable';
|
||||
chatQueueSendTimeout: 'Connection timed out. Check your network and try again.';
|
||||
chatTitle: 'Conversation';
|
||||
chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.';
|
||||
chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.';
|
||||
chatUnavailableTitle: 'Chat unavailable';
|
||||
confirm: 'OK';
|
||||
confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.';
|
||||
connectionConnected: 'Connected';
|
||||
connectionConnecting: 'Connecting...';
|
||||
connectionDisconnected: 'Disconnected';
|
||||
createError: 'Unable to create conversation. Please check your network and try again.';
|
||||
delete: 'Delete';
|
||||
deleteConversation: 'Delete Conversation';
|
||||
emptyGreetingSubtitle: 'Chat with your companion and record your stories.';
|
||||
greetingTitle: 'Say Hello';
|
||||
inputPlaceholder: 'Type a message...';
|
||||
inputPlaceholderVoice: 'Type here or hold the mic to speak...';
|
||||
me: 'Me';
|
||||
readAloudAgain: 'Play again';
|
||||
readingAloud: 'Reading aloud…';
|
||||
recentChats: 'Recent Chats';
|
||||
recordingPermissionDenied: 'Microphone permission is required to record';
|
||||
recordingStartFailed: 'Unable to start recording. Please try again.';
|
||||
resumeChatSubtitle: 'Open your latest conversation to keep talking.';
|
||||
resumeChatTitle: 'Continue chatting';
|
||||
send: 'Send';
|
||||
startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.';
|
||||
stopReadingAloud: 'Stop reading aloud';
|
||||
switchToText: 'Switch to text input';
|
||||
switchToVoice: 'Switch to voice input';
|
||||
tapToEndRecording: 'Tap to end';
|
||||
tapToStartRecording: 'Tap to start recording';
|
||||
timeDaysAgo_one: '{{count}} day ago';
|
||||
timeDaysAgo_other: '{{count}} days ago';
|
||||
timeHoursAgo_one: '{{count}} hour ago';
|
||||
timeHoursAgo_other: '{{count}} hours ago';
|
||||
timeJustNow: 'Just now';
|
||||
timeMinutesAgo_one: '{{count}} minute ago';
|
||||
timeMinutesAgo_other: '{{count}} minutes ago';
|
||||
viewAll: 'View All';
|
||||
voiceMessagePreview: 'Voice message';
|
||||
};
|
||||
explore: {};
|
||||
home: {};
|
||||
legal: {
|
||||
titlePrivacy: 'Privacy Policy';
|
||||
titleTerms: 'User Agreement';
|
||||
};
|
||||
memoir: {
|
||||
chapterLabel: 'Chapter {{index}}';
|
||||
chapterReading: {
|
||||
back: 'Back';
|
||||
backgroundColor: 'Background';
|
||||
bgPureWhite: 'White';
|
||||
bgSepia: 'Sepia';
|
||||
cancel: 'Cancel';
|
||||
chapterNotFound: 'Chapter not found';
|
||||
close: 'Close';
|
||||
confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.';
|
||||
deleteChapter: 'Delete Chapter';
|
||||
deleteChapterAction: 'Delete';
|
||||
fontSans: 'Sans';
|
||||
fontSerif: 'Serif';
|
||||
fontSize: 'Font Size';
|
||||
fontSizeDefault: 'Medium';
|
||||
fontSizeLarge: 'Large';
|
||||
fontSizeSmall: 'Small';
|
||||
readingSettings: 'Reading Settings';
|
||||
settings: 'Settings';
|
||||
typography: 'Typography';
|
||||
};
|
||||
continueWriting: 'Continue Writing';
|
||||
emptySubtitle: 'Chat with your companion to record your stories';
|
||||
emptyTitle: 'No memoir yet';
|
||||
frameworkChapters: {
|
||||
chapter1: 'Childhood and upbringing';
|
||||
chapter2: 'Education and young adulthood';
|
||||
chapter3: 'Early career';
|
||||
chapter4: 'Major achievements and peak moments';
|
||||
chapter5: 'Setbacks, challenges, and turning points';
|
||||
chapter6: 'Family and relationships';
|
||||
chapter7: 'Beliefs and values';
|
||||
chapter8: 'Life summary';
|
||||
};
|
||||
loadErrorMessage: 'Could not load chapters';
|
||||
loadErrorRetry: 'Retry';
|
||||
pageTitle: 'Memoir';
|
||||
readMemory: 'Read Memory';
|
||||
startChapter: 'Start Writing';
|
||||
statusDrafting: 'Drafting';
|
||||
statusLocked: 'Locked';
|
||||
statusPending: 'Pending';
|
||||
wordsCount: '{{count}} words';
|
||||
};
|
||||
profile: {
|
||||
about: {
|
||||
aboutUs: 'About Us';
|
||||
title: 'About';
|
||||
};
|
||||
appExperience: {
|
||||
language: 'Language';
|
||||
languageDesc: 'Display language';
|
||||
largeText: 'Large Text';
|
||||
largeTextDesc: 'Make reading easier';
|
||||
nightMode: 'Night Mode';
|
||||
nightModeDesc: 'Use dark theme';
|
||||
theme: 'Theme';
|
||||
themeDesc: 'Color theme';
|
||||
title: 'App Experience';
|
||||
};
|
||||
dataPrivacy: {
|
||||
deleteAll: 'Delete All Data';
|
||||
deleteUnderDevelopment: 'Delete data feature is under development.';
|
||||
exportAll: 'Export All Data';
|
||||
exportUnderDevelopment: 'Export feature is under development.';
|
||||
purgeDialogCancel: 'Cancel';
|
||||
purgeDialogConfirm: 'Delete permanently';
|
||||
purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.';
|
||||
purgeDialogTitle: 'Final confirmation';
|
||||
purgeInputLabel: 'Confirmation phrase';
|
||||
purgeInputPlaceholder: 'Type the phrase shown above';
|
||||
purgeOpenConfirm: 'I understand, continue';
|
||||
purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:';
|
||||
purgeSubmitting: 'Deleting…';
|
||||
purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.';
|
||||
purgeWarningTitle: 'Before you continue';
|
||||
title: 'Data & Privacy';
|
||||
};
|
||||
editAvatar: 'Edit Profile Picture';
|
||||
helpSupport: {
|
||||
faq: 'FAQ';
|
||||
feedback: 'Feedback & Support';
|
||||
feedbackPageTitle: 'Share your thoughts';
|
||||
title: 'Help & Support';
|
||||
};
|
||||
signOut: 'Sign Out';
|
||||
signingOut: 'Signing out...';
|
||||
userNamePlaceholder: 'User';
|
||||
userTier: '{{tier}}';
|
||||
};
|
||||
"app": {
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"system": "System",
|
||||
"zh": "Chinese"
|
||||
},
|
||||
"name": "Life Echo",
|
||||
"tabs": {
|
||||
"conversations": "Chats",
|
||||
"explore": "Explore",
|
||||
"home": "Home",
|
||||
"memoir": "Memoir",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"theme": {
|
||||
"default": "Default"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"codeLabel": "Verification Code",
|
||||
"getCode": "Get Code",
|
||||
"getCodeCountdown": "Retry in {{seconds}}s",
|
||||
"networkError": "Network error. Please try again later.",
|
||||
"phoneLabel": "Phone Number",
|
||||
"phonePlaceholder": "Enter your phone number",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"submit": "Login",
|
||||
"termsAnd": "and",
|
||||
"termsIntro": "I agree to the",
|
||||
"termsRequired": "Please agree to the User Agreement and Privacy Policy first",
|
||||
"termsRequiredConfirm": "OK",
|
||||
"termsRequiredTitle": "Agreement Required",
|
||||
"userAgreement": "User Agreement",
|
||||
"welcomeSubtitle": "Some lives grow richer the more you savor them.",
|
||||
"welcomeTitle": "Welcome back"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"chapterLabel": "",
|
||||
"chapterReading": {
|
||||
"backgroundColor": "",
|
||||
"bgPureWhite": "",
|
||||
"bgSepia": "",
|
||||
"close": "",
|
||||
"fontSize": "",
|
||||
"readingSettings": "",
|
||||
"typography": ""
|
||||
},
|
||||
"continueWriting": "",
|
||||
"docs": "Docs",
|
||||
"emptySubtitle": "",
|
||||
"emptyTitle": "",
|
||||
"readMemory": "",
|
||||
"startChapter": "",
|
||||
"statusDrafting": "",
|
||||
"statusLocked": "",
|
||||
"statusPending": "",
|
||||
"wordsCount": ""
|
||||
},
|
||||
"conversation": {
|
||||
"addMore": "More",
|
||||
"agentName": "Life Echo",
|
||||
"assistantReplying": "Replying…",
|
||||
"cancel": "Cancel",
|
||||
"cancelRecording": "Cancel recording",
|
||||
"cannotReadAloud": "Read unavailable",
|
||||
"chatQueueSendTimeout": "Connection timed out. Check your network and try again.",
|
||||
"chatTitle": "Conversation",
|
||||
"chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.",
|
||||
"chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.",
|
||||
"chatUnavailableTitle": "Chat unavailable",
|
||||
"confirm": "OK",
|
||||
"confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.",
|
||||
"connectionConnected": "Connected",
|
||||
"connectionConnecting": "Connecting...",
|
||||
"connectionDisconnected": "Disconnected",
|
||||
"createError": "Unable to create conversation. Please check your network and try again.",
|
||||
"delete": "Delete",
|
||||
"deleteConversation": "Delete Conversation",
|
||||
"emptyGreetingSubtitle": "Chat with your companion and record your stories.",
|
||||
"greetingTitle": "Say Hello",
|
||||
"inputPlaceholder": "Type a message...",
|
||||
"inputPlaceholderVoice": "Type here or hold the mic to speak...",
|
||||
"me": "Me",
|
||||
"readAloudAgain": "Play again",
|
||||
"readAloudPause": "Pause reading",
|
||||
"readAloudResume": "Resume reading",
|
||||
"readingAloud": "Reading aloud…",
|
||||
"recentChats": "Recent Chats",
|
||||
"recordingPermissionDenied": "Microphone permission is required to record",
|
||||
"recordingStartFailed": "Unable to start recording. Please try again.",
|
||||
"resumeChatSubtitle": "Open your latest conversation to keep talking.",
|
||||
"resumeChatTitle": "Continue chatting",
|
||||
"send": "Send",
|
||||
"startNewSubtitle": "Capture a new memory or share your thoughts with your companion.",
|
||||
"stopReadingAloud": "Stop reading aloud",
|
||||
"switchToText": "Switch to text input",
|
||||
"switchToVoice": "Switch to voice input",
|
||||
"tapToEndRecording": "Tap to end",
|
||||
"tapToStartRecording": "Tap to start recording",
|
||||
"timeDaysAgo_one": "{{count}} day ago",
|
||||
"timeDaysAgo_other": "{{count}} days ago",
|
||||
"timeHoursAgo_one": "{{count}} hour ago",
|
||||
"timeHoursAgo_other": "{{count}} hours ago",
|
||||
"timeJustNow": "Just now",
|
||||
"timeMinutesAgo_one": "{{count}} minute ago",
|
||||
"timeMinutesAgo_other": "{{count}} minutes ago",
|
||||
"viewAll": "View All",
|
||||
"voiceMessagePreview": "Voice message"
|
||||
},
|
||||
"explore": {
|
||||
|
||||
},
|
||||
"home": {
|
||||
|
||||
},
|
||||
"legal": {
|
||||
"titlePrivacy": "Privacy Policy",
|
||||
"titleTerms": "User Agreement"
|
||||
},
|
||||
"memoir": {
|
||||
"chapterLabel": "Chapter {{index}}",
|
||||
"chapterReading": {
|
||||
"back": "Back",
|
||||
"backgroundColor": "Background",
|
||||
"bgPureWhite": "White",
|
||||
"bgSepia": "Sepia",
|
||||
"cancel": "Cancel",
|
||||
"chapterNotFound": "Chapter not found",
|
||||
"close": "Close",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.",
|
||||
"deleteChapter": "Delete Chapter",
|
||||
"deleteChapterAction": "Delete",
|
||||
"fontSans": "Sans",
|
||||
"fontSerif": "Serif",
|
||||
"fontSize": "Font Size",
|
||||
"fontSizeDefault": "Medium",
|
||||
"fontSizeLarge": "Large",
|
||||
"fontSizeSmall": "Small",
|
||||
"readingSettings": "Reading Settings",
|
||||
"settings": "Settings",
|
||||
"typography": "Typography"
|
||||
},
|
||||
"continueWriting": "Continue Writing",
|
||||
"emptySubtitle": "Chat with your companion to record your stories",
|
||||
"emptyTitle": "No memoir yet",
|
||||
"frameworkChapters": {
|
||||
"chapter1": "Childhood and upbringing",
|
||||
"chapter2": "Education and young adulthood",
|
||||
"chapter3": "Early career",
|
||||
"chapter4": "Major achievements and peak moments",
|
||||
"chapter5": "Setbacks, challenges, and turning points",
|
||||
"chapter6": "Family and relationships",
|
||||
"chapter7": "Beliefs and values",
|
||||
"chapter8": "Life summary"
|
||||
},
|
||||
"loadErrorMessage": "Could not load chapters",
|
||||
"loadErrorRetry": "Retry",
|
||||
"pageTitle": "Memoir",
|
||||
"readMemory": "Read Memory",
|
||||
"startChapter": "Start Writing",
|
||||
"statusDrafting": "Drafting",
|
||||
"statusLocked": "Locked",
|
||||
"statusPending": "Pending",
|
||||
"wordsCount": "{{count}} words"
|
||||
},
|
||||
"profile": {
|
||||
"about": {
|
||||
"aboutUs": "About Us",
|
||||
"title": "About"
|
||||
},
|
||||
"appExperience": {
|
||||
"language": "Language",
|
||||
"languageDesc": "Display language",
|
||||
"largeText": "Large Text",
|
||||
"largeTextDesc": "Make reading easier",
|
||||
"nightMode": "Night Mode",
|
||||
"nightModeDesc": "Use dark theme",
|
||||
"theme": "Theme",
|
||||
"themeDesc": "Color theme",
|
||||
"title": "App Experience"
|
||||
},
|
||||
"dataPrivacy": {
|
||||
"deleteAll": "Delete All Data",
|
||||
"deleteUnderDevelopment": "Delete data feature is under development.",
|
||||
"exportAll": "Export All Data",
|
||||
"exportUnderDevelopment": "Export feature is under development.",
|
||||
"purgeDialogCancel": "Cancel",
|
||||
"purgeDialogConfirm": "Delete permanently",
|
||||
"purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.",
|
||||
"purgeDialogTitle": "Final confirmation",
|
||||
"purgeInputLabel": "Confirmation phrase",
|
||||
"purgeInputPlaceholder": "Type the phrase shown above",
|
||||
"purgeOpenConfirm": "I understand, continue",
|
||||
"purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:",
|
||||
"purgeSubmitting": "Deleting…",
|
||||
"purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.",
|
||||
"purgeWarningTitle": "Before you continue",
|
||||
"title": "Data & Privacy"
|
||||
},
|
||||
"editAvatar": "Edit Profile Picture",
|
||||
"helpSupport": {
|
||||
"faq": "FAQ",
|
||||
"feedback": "Feedback & Support",
|
||||
"feedbackPageTitle": "Share your thoughts",
|
||||
"title": "Help & Support"
|
||||
},
|
||||
"personalInfo": {
|
||||
"avatarPresetFailed": "Could not set preset avatar",
|
||||
"avatarUploadFailed": "Could not upload avatar",
|
||||
"birthPlacePlaceholder": "Birthplace",
|
||||
"birthYearPlaceholder": "Birth year",
|
||||
"cancel": "Cancel",
|
||||
"changeAvatar": "Change photo",
|
||||
"chooseFromLibrary": "Choose from library",
|
||||
"choosePreset": "Preset avatars",
|
||||
"grewUpPlaceholder": "Where you grew up",
|
||||
"libraryPermissionDenied": "Photo library access is required to pick an image",
|
||||
"nickname": "Nickname",
|
||||
"nicknamePlaceholder": "Enter nickname",
|
||||
"nicknameRequired": "Please enter a nickname",
|
||||
"occupationPlaceholder": "Occupation",
|
||||
"presetPickTitle": "Choose a preset",
|
||||
"save": "Save",
|
||||
"saveFailed": "Could not save",
|
||||
"savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.",
|
||||
"savePartialTitle": "Partially saved",
|
||||
"saving": "Saving…",
|
||||
"title": "Personal info"
|
||||
},
|
||||
"signOut": "Sign Out",
|
||||
"signingOut": "Signing out...",
|
||||
"userNamePlaceholder": "User",
|
||||
"userTier": "{{tier}}"
|
||||
}
|
||||
}
|
||||
|
||||
export default Resources;
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"recentChats": "Recent Chats",
|
||||
"stopReadingAloud": "Stop reading aloud",
|
||||
"readAloudAgain": "Play again",
|
||||
"readAloudPause": "Pause reading",
|
||||
"readAloudResume": "Resume reading",
|
||||
"cannotReadAloud": "Read unavailable",
|
||||
"readingAloud": "Reading aloud…",
|
||||
"recordingPermissionDenied": "Microphone permission is required to record",
|
||||
|
||||
@@ -33,6 +33,29 @@
|
||||
"title": "Data & Privacy"
|
||||
},
|
||||
"editAvatar": "Edit Profile Picture",
|
||||
"personalInfo": {
|
||||
"avatarPresetFailed": "Could not set preset avatar",
|
||||
"avatarUploadFailed": "Could not upload avatar",
|
||||
"cancel": "Cancel",
|
||||
"birthPlacePlaceholder": "Birthplace",
|
||||
"birthYearPlaceholder": "Birth year",
|
||||
"changeAvatar": "Change photo",
|
||||
"chooseFromLibrary": "Choose from library",
|
||||
"choosePreset": "Preset avatars",
|
||||
"grewUpPlaceholder": "Where you grew up",
|
||||
"libraryPermissionDenied": "Photo library access is required to pick an image",
|
||||
"nickname": "Nickname",
|
||||
"nicknamePlaceholder": "Enter nickname",
|
||||
"nicknameRequired": "Please enter a nickname",
|
||||
"occupationPlaceholder": "Occupation",
|
||||
"presetPickTitle": "Choose a preset",
|
||||
"save": "Save",
|
||||
"saveFailed": "Could not save",
|
||||
"savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.",
|
||||
"savePartialTitle": "Partially saved",
|
||||
"saving": "Saving…",
|
||||
"title": "Personal info"
|
||||
},
|
||||
"helpSupport": {
|
||||
"faq": "FAQ",
|
||||
"feedback": "Feedback & Support",
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"recentChats": "最近对话",
|
||||
"stopReadingAloud": "停止朗读",
|
||||
"readAloudAgain": "再读",
|
||||
"readAloudPause": "暂停朗读",
|
||||
"readAloudResume": "继续朗读",
|
||||
"cannotReadAloud": "暂无法朗读",
|
||||
"readingAloud": "朗读中…",
|
||||
"recordingPermissionDenied": "需要麦克风权限才能录音",
|
||||
|
||||
@@ -33,6 +33,29 @@
|
||||
"title": "数据与隐私"
|
||||
},
|
||||
"editAvatar": "编辑头像",
|
||||
"personalInfo": {
|
||||
"avatarPresetFailed": "设置预设头像失败",
|
||||
"avatarUploadFailed": "上传头像失败",
|
||||
"cancel": "取消",
|
||||
"birthPlacePlaceholder": "出生地",
|
||||
"birthYearPlaceholder": "出生年份",
|
||||
"changeAvatar": "更换头像",
|
||||
"chooseFromLibrary": "从相册选择",
|
||||
"choosePreset": "预设头像",
|
||||
"grewUpPlaceholder": "成长地",
|
||||
"libraryPermissionDenied": "需要相册权限才能选择图片",
|
||||
"nickname": "昵称",
|
||||
"nicknamePlaceholder": "请输入昵称",
|
||||
"nicknameRequired": "请填写昵称",
|
||||
"occupationPlaceholder": "职业",
|
||||
"presetPickTitle": "选择预设",
|
||||
"save": "保存",
|
||||
"saveFailed": "保存失败",
|
||||
"savePartialBody": "昵称已更新,但下面的档案字段未能保存。请检查网络后再次点击保存。",
|
||||
"savePartialTitle": "部分保存成功",
|
||||
"saving": "保存中…",
|
||||
"title": "个人信息"
|
||||
},
|
||||
"helpSupport": {
|
||||
"faq": "常见问题",
|
||||
"feedback": "反馈与客服",
|
||||
|
||||
Reference in New Issue
Block a user