Files
life-echo/app-expo/src/app/(tabs)/profile.tsx
Kevin 7ad52fce89 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>
2026-05-06 13:51:43 +08:00

316 lines
9.1 KiB
TypeScript

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';
import type { LucideIcon } from 'lucide-react-native';
import {
ChevronRight,
Download,
Globe,
HelpCircle,
Info,
LogOut,
MessageCircle,
Moon,
Pencil,
Trash2,
Type,
User,
} from 'lucide-react-native';
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';
function SectionCard({
title,
icon: SectionIcon,
children,
}: {
title: string;
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<View className="overflow-hidden rounded-xl border border-border bg-card">
<View className="flex-row items-center gap-3 border-b border-border px-4 py-3">
<Icon as={SectionIcon} className="text-primary" size={24} />
<Text variant="large" className="text-foreground">
{title}
</Text>
</View>
{children}
</View>
);
}
function RowButton({
icon: RowIcon,
label,
onPress,
}: {
icon: LucideIcon;
label: string;
onPress?: () => void;
}) {
return (
<Pressable
className="flex-row items-center justify-between px-4 py-3.5 active:bg-muted"
onPress={onPress}
>
<View className="flex-row items-center gap-4">
<Icon as={RowIcon} className="text-muted-foreground" size={20} />
<Text className="font-medium text-foreground">{label}</Text>
</View>
<Icon as={ChevronRight} className="text-muted-foreground" size={20} />
</Pressable>
);
}
function LanguageRow({
label,
description,
currentLabel,
onPress,
}: {
label: string;
description: string;
currentLabel: string;
onPress: () => void;
}) {
return (
<Pressable
className="flex-row items-center justify-between border-b border-border px-4 py-3.5 active:bg-muted"
onPress={onPress}
>
<View className="flex-row items-center gap-4">
<Icon as={Globe} className="text-muted-foreground" size={20} />
<View>
<Text className="font-medium text-foreground">{label}</Text>
<Text variant="bodySmall" className="text-muted-foreground">
{description}
</Text>
</View>
</View>
<View className="flex-row items-center gap-2">
<Text variant="bodySmall" className="text-muted-foreground">
{currentLabel}
</Text>
<Icon as={ChevronRight} className="text-muted-foreground" size={20} />
</View>
</Pressable>
);
}
function SettingRow({
icon: SettingIcon,
label,
description,
value,
onValueChange,
}: {
icon: LucideIcon;
label: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) {
return (
<View className="flex-row items-center justify-between border-b border-border px-4 py-3.5 last:border-b-0">
<View className="flex-row items-center gap-4">
<Icon as={SettingIcon} className="text-muted-foreground" size={20} />
<View>
<Text className="font-medium text-foreground">{label}</Text>
<Text variant="bodySmall" className="text-muted-foreground">
{description}
</Text>
</View>
</View>
<Switch checked={value} onCheckedChange={onValueChange} />
</View>
);
}
function planDisplayName(
subscriptionType: string | undefined,
planName?: string,
) {
if (planName) return planName;
switch (subscriptionType) {
case 'free':
return 'Free';
case 'pro':
case 'premium':
return 'Pro';
case 'pro_plus':
return 'Pro+';
default:
return 'Free';
}
}
export default function ProfileScreen() {
const { t } = useTranslation('profile');
const { t: tApp } = useTranslation('app');
const { user } = useSession();
const logout = useLogout();
const { data: currentPlan } = useCurrentPlan();
const {
ready: settingsReady,
language,
hasLanguageOverride,
languageOptions,
themeName,
themeOptions,
largeText,
changeLargeText,
darkMode,
changeDarkMode,
} = useAppSettings();
const tierLabel =
currentPlan?.plan_name ?? planDisplayName(user?.subscription_type);
const currentLanguageLabel =
hasLanguageOverride && language
? (languageOptions.find((o) => o.code === language)?.label ?? language)
: tApp('languages.system');
const currentThemeLabel =
themeOptions.find((o) => o.value === themeName)?.label ??
tApp('theme.default');
const avatarUri = resolveApiMediaUrl(user?.avatar_url ?? null);
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
className="flex-1 bg-background"
contentContainerStyle={{ paddingBottom: 32, gap: 16, padding: 16 }}
>
{/* Profile header */}
<View className="items-center gap-4 py-8">
<View className="relative">
<View
className="h-24 w-24 items-center justify-center overflow-hidden rounded-full bg-muted"
style={{ borderCurve: 'continuous' }}
>
{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"
style={{ borderCurve: 'continuous' }}
accessibilityLabel={t('editAvatar')}
onPress={() => router.push('/(main)/personal-info')}
>
<Icon as={Pencil} className="text-primary-foreground" size={16} />
</Pressable>
</View>
<View className="items-center gap-1">
<Text variant="h3" className="text-foreground">
{user?.nickname ?? t('userNamePlaceholder')}
</Text>
<Text variant="bodySmall" className="text-muted-foreground">
{t('userTier', { tier: tierLabel })}
</Text>
</View>
</View>
{/* App Experience */}
<SectionCard title={t('appExperience.title')} icon={Type}>
{settingsReady && (
<LanguageRow
label={t('appExperience.language')}
description={t('appExperience.languageDesc')}
currentLabel={currentLanguageLabel}
onPress={() => router.push('/(main)/language')}
/>
)}
{settingsReady && (
<LanguageRow
label={t('appExperience.theme')}
description={t('appExperience.themeDesc')}
currentLabel={currentThemeLabel}
onPress={() => router.push('/(main)/theme')}
/>
)}
<SettingRow
icon={Type}
label={t('appExperience.largeText')}
description={t('appExperience.largeTextDesc')}
value={largeText}
onValueChange={changeLargeText}
/>
<SettingRow
icon={Moon}
label={t('appExperience.nightMode')}
description={t('appExperience.nightModeDesc')}
value={darkMode}
onValueChange={changeDarkMode}
/>
</SectionCard>
{/* Data & Privacy */}
<SectionCard title={t('dataPrivacy.title')} icon={Download}>
<RowButton
icon={Download}
label={t('dataPrivacy.exportAll')}
onPress={() => router.push('/(main)/export-data')}
/>
<RowButton
icon={Trash2}
label={t('dataPrivacy.deleteAll')}
onPress={() => router.push('/(main)/delete-data')}
/>
</SectionCard>
{/* Help & Support */}
<SectionCard title={t('helpSupport.title')} icon={HelpCircle}>
<RowButton
icon={HelpCircle}
label={t('helpSupport.faq')}
onPress={() => router.push('/(main)/faq')}
/>
<RowButton
icon={MessageCircle}
label={t('helpSupport.feedback')}
onPress={() => router.push('/(main)/feedback')}
/>
</SectionCard>
{/* About */}
<SectionCard title={t('about.title')} icon={Info}>
<RowButton
icon={Info}
label={t('about.aboutUs')}
onPress={() => router.push('/(main)/about')}
/>
</SectionCard>
{/* Sign out */}
<Pressable
className="mt-6 flex-row items-center justify-center gap-2 rounded-xl bg-destructive/10 py-6 active:bg-destructive/20"
onPress={() => logout.mutate()}
disabled={logout.isPending}
>
<Icon as={LogOut} className="text-destructive" size={20} />
<Text className="font-semibold text-destructive">
{logout.isPending ? t('signingOut') : t('signOut')}
</Text>
</Pressable>
</ScrollView>
);
}