653 lines
16 KiB
TypeScript
653 lines
16 KiB
TypeScript
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
import { router } from 'expo-router';
|
|
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
Image,
|
|
type ImageSourcePropType,
|
|
ScrollView,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
View,
|
|
useWindowDimensions,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
import { ThemedText } from '@/components/themed-text';
|
|
import { useAuth } from '@/hooks/use-auth';
|
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
|
|
type TabKey = 'all' | 'image' | 'video';
|
|
type BillingMode = 'monthly' | 'lifetime';
|
|
|
|
type Palette = {
|
|
background: string;
|
|
surface: string;
|
|
elevated: string;
|
|
border: string;
|
|
pill: string;
|
|
textPrimary: string;
|
|
textSecondary: string;
|
|
accent: string;
|
|
onAccent: string;
|
|
tabActive: string;
|
|
avatarBackdrop: string;
|
|
glyphBackdrop: string;
|
|
};
|
|
|
|
type IdentityStat = {
|
|
key: string;
|
|
label: string;
|
|
value: number;
|
|
};
|
|
|
|
const TABS: { key: TabKey; label: string }[] = [
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'image', label: 'Image' },
|
|
{ key: 'video', label: 'Video' },
|
|
];
|
|
|
|
const BILLING_OPTIONS: { key: BillingMode; label: string }[] = [
|
|
{ key: 'monthly', label: '月付' },
|
|
{ key: 'lifetime', label: '终身' },
|
|
];
|
|
|
|
const palettes: Record<'dark' | 'light', Palette> = {
|
|
dark: {
|
|
background: '#050505',
|
|
surface: '#121216',
|
|
elevated: '#101014',
|
|
border: '#1D1E24',
|
|
pill: '#16171C',
|
|
textPrimary: '#F6F7FA',
|
|
textSecondary: '#8E9098',
|
|
accent: '#B7FF2F',
|
|
onAccent: '#050505',
|
|
tabActive: '#FFFFFF',
|
|
avatarBackdrop: '#1F2026',
|
|
glyphBackdrop: '#18181D',
|
|
},
|
|
light: {
|
|
background: '#F7F8FB',
|
|
surface: '#FFFFFF',
|
|
elevated: '#F0F2F8',
|
|
border: '#E2E5ED',
|
|
pill: '#E8EBF4',
|
|
textPrimary: '#0F1320',
|
|
textSecondary: '#5E6474',
|
|
accent: '#405CFF',
|
|
onAccent: '#FFFFFF',
|
|
tabActive: '#FFFFFF',
|
|
avatarBackdrop: '#D5D8E2',
|
|
glyphBackdrop: '#E8EBF4',
|
|
},
|
|
};
|
|
|
|
const STAT_BLUEPRINT: IdentityStat[] = [
|
|
{ key: 'likes', label: 'likes', value: 0 },
|
|
{ key: 'posts', label: 'posts', value: 0 },
|
|
{ key: 'views', label: 'views', value: 0 },
|
|
];
|
|
|
|
export default function ProfileScreen() {
|
|
const { user } = useAuth();
|
|
const colorScheme = useColorScheme();
|
|
const palette = palettes[colorScheme === 'dark' ? 'dark' : 'light'];
|
|
const insets = useSafeAreaInsets();
|
|
const { width } = useWindowDimensions();
|
|
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
|
|
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
|
|
|
const displayName = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
|
const avatarSource = deriveAvatarSource(user);
|
|
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
|
|
const stats = useMemo(() => createStats(user), [user]);
|
|
|
|
const horizontalPadding = width < 400 ? 20 : 24;
|
|
const avatarSize = width < 400 ? 74 : 82;
|
|
|
|
return (
|
|
<View style={[styles.screen, { backgroundColor: palette.background }]}>
|
|
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: horizontalPadding,
|
|
paddingTop: 20,
|
|
paddingBottom: Math.max(insets.bottom + 36, 96),
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<HeaderRow
|
|
palette={palette}
|
|
billingMode={billingMode}
|
|
credits={creditBalance}
|
|
onChangeBilling={setBillingMode}
|
|
/>
|
|
<IdentitySection
|
|
palette={palette}
|
|
displayName={displayName}
|
|
avatarSource={avatarSource}
|
|
avatarSize={avatarSize}
|
|
stats={stats}
|
|
/>
|
|
<Divider palette={palette} />
|
|
<ContentTabs palette={palette} activeTab={activeTab} onChangeTab={setActiveTab} />
|
|
<Divider palette={palette} />
|
|
<EmptyGallery palette={palette} activeTab={activeTab} />
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function HeaderRow({
|
|
palette,
|
|
billingMode,
|
|
onChangeBilling,
|
|
credits,
|
|
}: {
|
|
palette: Palette;
|
|
billingMode: BillingMode;
|
|
onChangeBilling: (mode: BillingMode) => void;
|
|
credits: number;
|
|
}) {
|
|
return (
|
|
<View style={styles.headerRow}>
|
|
<TouchableOpacity
|
|
onPress={() => router.push('/settings/account')}
|
|
style={[styles.settingsButton, { backgroundColor: palette.pill }]}
|
|
activeOpacity={0.85}
|
|
>
|
|
<MaterialIcons name="settings" size={24} color={palette.textPrimary} />
|
|
</TouchableOpacity>
|
|
<BillingBadge palette={palette} billingMode={billingMode} onChangeBilling={onChangeBilling} credits={credits} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function BillingBadge({
|
|
palette,
|
|
billingMode,
|
|
onChangeBilling,
|
|
credits,
|
|
}: {
|
|
palette: Palette;
|
|
billingMode: BillingMode;
|
|
onChangeBilling: (mode: BillingMode) => void;
|
|
credits: number;
|
|
}) {
|
|
return (
|
|
<View style={[styles.billingShell, { backgroundColor: palette.pill, borderColor: palette.border }]}>
|
|
<View style={styles.billingOptions}>
|
|
{BILLING_OPTIONS.map(option => {
|
|
const isActive = option.key === billingMode;
|
|
return (
|
|
<TouchableOpacity
|
|
key={option.key}
|
|
onPress={() => onChangeBilling(option.key)}
|
|
activeOpacity={0.85}
|
|
style={[
|
|
styles.billingOption,
|
|
{
|
|
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText
|
|
style={[
|
|
styles.billingLabel,
|
|
{
|
|
color: isActive ? palette.onAccent : palette.textSecondary,
|
|
},
|
|
]}
|
|
>
|
|
{option.label}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
<View
|
|
style={[
|
|
styles.lightningShell,
|
|
{
|
|
backgroundColor: palette.elevated,
|
|
borderColor: palette.border,
|
|
},
|
|
]}
|
|
>
|
|
<MaterialIcons name="flash-on" size={16} color={palette.accent} />
|
|
<ThemedText style={[styles.lightningValue, { color: palette.textPrimary }]}>{credits}</ThemedText>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function IdentitySection({
|
|
palette,
|
|
displayName,
|
|
avatarSource,
|
|
avatarSize,
|
|
stats,
|
|
}: {
|
|
palette: Palette;
|
|
displayName: string;
|
|
avatarSource?: ImageSourcePropType;
|
|
avatarSize: number;
|
|
stats: IdentityStat[];
|
|
}) {
|
|
return (
|
|
<View style={styles.identityRow}>
|
|
<Avatar palette={palette} size={avatarSize} source={avatarSource} fallback={displayName} />
|
|
<View style={styles.identityDetails}>
|
|
<View style={styles.nameRow}>
|
|
<ThemedText numberOfLines={1} style={[styles.username, { color: palette.textPrimary }]}>
|
|
{displayName}
|
|
</ThemedText>
|
|
<TouchableOpacity
|
|
onPress={() => router.push('/settings/account')}
|
|
activeOpacity={0.85}
|
|
style={[
|
|
styles.editButton,
|
|
{
|
|
borderColor: palette.border,
|
|
backgroundColor: palette.surface,
|
|
},
|
|
]}
|
|
>
|
|
<MaterialIcons name="edit" size={16} color={palette.textPrimary} style={styles.editIcon} />
|
|
<ThemedText style={[styles.editLabel, { color: palette.textPrimary }]}>Edit</ThemedText>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<View style={styles.statRow}>
|
|
{stats.map(stat => (
|
|
<StatItem key={stat.key} palette={palette} label={stat.label} value={stat.value} />
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Avatar({
|
|
palette,
|
|
size,
|
|
source,
|
|
fallback,
|
|
}: {
|
|
palette: Palette;
|
|
size: number;
|
|
source?: ImageSourcePropType;
|
|
fallback: string;
|
|
}) {
|
|
const initials = getInitials(fallback);
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.avatarShell,
|
|
{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: size / 2,
|
|
backgroundColor: palette.avatarBackdrop,
|
|
},
|
|
]}
|
|
>
|
|
{source ? (
|
|
<Image source={source} style={{ width: size, height: size, borderRadius: size / 2 }} resizeMode="cover" />
|
|
) : (
|
|
<ThemedText style={[styles.avatarInitials, { color: palette.textPrimary }]}>{initials}</ThemedText>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function StatItem({ palette, label, value }: { palette: Palette; label: string; value: number }) {
|
|
return (
|
|
<View style={styles.statItem}>
|
|
<ThemedText style={[styles.statValue, { color: palette.textPrimary }]}>{value}</ThemedText>
|
|
<ThemedText style={[styles.statLabel, { color: palette.textSecondary }]}>{label}</ThemedText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Divider({ palette }: { palette: Palette }) {
|
|
return <View style={[styles.divider, { backgroundColor: palette.border }]} />;
|
|
}
|
|
|
|
function ContentTabs({
|
|
palette,
|
|
activeTab,
|
|
onChangeTab,
|
|
}: {
|
|
palette: Palette;
|
|
activeTab: TabKey;
|
|
onChangeTab: (tab: TabKey) => void;
|
|
}) {
|
|
return (
|
|
<View style={[styles.tabsBar, { backgroundColor: palette.pill, borderColor: palette.border }]}>
|
|
{TABS.map(tab => {
|
|
const isActive = tab.key === activeTab;
|
|
return (
|
|
<TouchableOpacity
|
|
key={tab.key}
|
|
activeOpacity={0.85}
|
|
onPress={() => onChangeTab(tab.key)}
|
|
style={[
|
|
styles.tabButton,
|
|
{
|
|
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText
|
|
style={[
|
|
styles.tabLabel,
|
|
{
|
|
color: isActive ? palette.onAccent : palette.textSecondary,
|
|
},
|
|
]}
|
|
>
|
|
{tab.label}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function EmptyGallery({ palette, activeTab }: { palette: Palette; activeTab: TabKey }) {
|
|
const copy = deriveEmptyCopy(activeTab);
|
|
return (
|
|
<View style={styles.emptyWrap}>
|
|
<View
|
|
style={[
|
|
styles.emptyGlyph,
|
|
{
|
|
backgroundColor: palette.glyphBackdrop,
|
|
borderColor: palette.border,
|
|
},
|
|
]}
|
|
>
|
|
<MaterialIcons name="image" size={40} color={palette.accent} />
|
|
</View>
|
|
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>{copy.title}</ThemedText>
|
|
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>{copy.subtitle}</ThemedText>
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
onPress={() => router.push('/(tabs)/explore')}
|
|
style={[
|
|
styles.generateButton,
|
|
{
|
|
borderColor: palette.accent,
|
|
backgroundColor: palette.elevated,
|
|
},
|
|
]}
|
|
>
|
|
<MaterialIcons name="auto-awesome" size={18} color={palette.accent} style={styles.generateIcon} />
|
|
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Generate</ThemedText>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function deriveEmptyCopy(activeTab: TabKey) {
|
|
if (activeTab === 'image') {
|
|
return {
|
|
title: 'No Images yet',
|
|
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
|
};
|
|
}
|
|
if (activeTab === 'video') {
|
|
return {
|
|
title: 'No Videos yet',
|
|
subtitle: 'Pick a style, enter a prompt, and craft your first clip.',
|
|
};
|
|
}
|
|
return {
|
|
title: 'No Content yet',
|
|
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
|
};
|
|
}
|
|
|
|
function createStats(user: unknown): IdentityStat[] {
|
|
const source = (user as Record<string, unknown>) ?? {};
|
|
return STAT_BLUEPRINT.map(stat => ({
|
|
...stat,
|
|
value: deriveNumericValue(source[stat.key]),
|
|
}));
|
|
}
|
|
|
|
function deriveDisplayName(user: unknown): string | null {
|
|
if (!user || typeof user !== 'object') {
|
|
return null;
|
|
}
|
|
const data = user as Record<string, unknown>;
|
|
return ensureString(data.username) ?? ensureString(data.name) ?? null;
|
|
}
|
|
|
|
function deriveAvatarSource(user: unknown): ImageSourcePropType | undefined {
|
|
if (!user || typeof user !== 'object') {
|
|
return undefined;
|
|
}
|
|
const image = ensureString((user as Record<string, unknown>).image);
|
|
if (!image) {
|
|
return undefined;
|
|
}
|
|
return { uri: image };
|
|
}
|
|
|
|
function ensureString(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function deriveNumericValue(value: unknown): number {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function getInitials(name: string): string {
|
|
const tokens = name
|
|
.replace(/[_-]+/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
if (tokens.length === 0) {
|
|
return 'AI';
|
|
}
|
|
const initials = tokens.slice(0, 2).map(part => part[0]?.toUpperCase() ?? '');
|
|
return initials.join('') || tokens[0][0]?.toUpperCase() || 'AI';
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
screen: {
|
|
flex: 1,
|
|
},
|
|
scroll: {
|
|
flexGrow: 1,
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 28,
|
|
},
|
|
settingsButton: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
billingShell: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 4,
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
},
|
|
billingOptions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
billingOption: {
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 6,
|
|
borderRadius: 999,
|
|
},
|
|
billingLabel: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
lightningShell: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
marginLeft: 8,
|
|
},
|
|
lightningValue: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
marginLeft: 6,
|
|
},
|
|
identityRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: 24,
|
|
},
|
|
avatarShell: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
avatarInitials: {
|
|
fontSize: 28,
|
|
fontWeight: '700',
|
|
},
|
|
identityDetails: {
|
|
flex: 1,
|
|
marginLeft: 18,
|
|
},
|
|
nameRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginBottom: 10,
|
|
},
|
|
username: {
|
|
flex: 1,
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
marginRight: 12,
|
|
},
|
|
editButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: 999,
|
|
borderWidth: 1,
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 8,
|
|
},
|
|
editIcon: {
|
|
marginRight: 6,
|
|
},
|
|
editLabel: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
statRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
statItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'baseline',
|
|
marginRight: 18,
|
|
},
|
|
statValue: {
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
marginRight: 4,
|
|
},
|
|
statLabel: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
textTransform: 'lowercase',
|
|
},
|
|
divider: {
|
|
height: 1,
|
|
marginVertical: 24,
|
|
},
|
|
tabsBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
padding: 4,
|
|
},
|
|
tabButton: {
|
|
flex: 1,
|
|
borderRadius: 999,
|
|
paddingVertical: 10,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
tabLabel: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
},
|
|
emptyWrap: {
|
|
alignItems: 'center',
|
|
paddingTop: 32,
|
|
},
|
|
emptyGlyph: {
|
|
width: 112,
|
|
height: 112,
|
|
borderRadius: 28,
|
|
borderWidth: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 24,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
marginBottom: 6,
|
|
},
|
|
emptySubtitle: {
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
textAlign: 'center',
|
|
marginBottom: 28,
|
|
paddingHorizontal: 24,
|
|
},
|
|
generateButton: {
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 28,
|
|
paddingVertical: 12,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
generateIcon: {
|
|
marginRight: 8,
|
|
},
|
|
generateLabel: {
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
},
|
|
});
|