- 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>
316 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|