Files
bw-expo-app/app/(tabs)/profile.tsx
2025-10-29 19:38:04 +08:00

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',
},
});