feat: 完整应用重构 - 优化界面架构与用户体验

主要变更:
- 重构应用界面:优化首页、登录、认证流程
- 新增用户档案模块:包含完整的档案管理组件
- 优化路由结构:重新组织页面布局和导航
- 改进API集成:新增活动、内容分类等API模块
- 删除冗余组件:清理不必要的文件和依赖

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-11-03 12:44:12 +08:00
parent b9399bf4cf
commit af64049c69
52 changed files with 2832 additions and 2007 deletions

View File

@@ -0,0 +1,228 @@
import React from 'react';
import { View, FlatList, ActivityIndicator, RefreshControl, StyleSheet, Image } from 'react-native';
import { VideoPlayer } from '@/components/video/video-player';
import { ThemedText } from '@/components/themed-text';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useColorScheme } from '@/hooks/use-color-scheme';
import type { TemplateGeneration } from '@/lib/api/template-generations';
export function ContentGallery({
generations,
isRefreshing,
onRefresh,
isLoadingMore,
hasMore,
onLoadMore,
}: {
generations: TemplateGeneration[];
isRefreshing: boolean;
onRefresh: () => void;
isLoadingMore: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
const renderItem = ({ item }: { item: TemplateGeneration }) => (
<ContentItem palette={palette} generation={item} />
);
const renderFooter = () => {
if (isLoadingMore) {
return (
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
<ActivityIndicator size="small" color={palette.accent} />
</View>
);
}
return null;
};
return (
<FlatList
data={generations}
renderItem={renderItem}
ListFooterComponent={renderFooter}
numColumns={2}
keyExtractor={(item) => item.id}
columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={palette.accent}
colors={[palette.accent]}
/>
}
onEndReached={onLoadMore}
onEndReachedThreshold={0.5}
/>
);
}
function ContentItem({
palette,
generation,
}: {
palette: any;
generation: TemplateGeneration;
}) {
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
const renderMedia = () => {
const mediaUrl = generation.resultUrl[0];
if (generation.type === 'IMAGE') {
return (
<View style={styles.mediaContainer}>
<Image
source={{ uri: mediaUrl }}
style={styles.mediaImage}
resizeMode="cover"
/>
</View>
);
} else if (generation.type === 'VIDEO') {
return (
<View style={styles.mediaContainer}>
<VideoPlayer
source={{ uri: mediaUrl }}
style={styles.mediaVideo}
shouldPlay={false}
isLooping={true}
isMuted={true}
useNativeControls={false}
showPoster={true}
maxHeight={300}
/>
</View>
);
}
return null;
};
return (
<View
style={[
styles.contentItem,
{
backgroundColor: palette.surface,
borderColor: palette.border,
},
]}
>
{renderMedia()}
<View style={styles.contentInfo}>
<ThemedText
style={[styles.contentTitle, { color: palette.textPrimary }]}
numberOfLines={1}
>
{generation.template.title}
</ThemedText>
<View style={styles.contentMeta}>
<View style={styles.metaItem}>
<MaterialIcons name="schedule" size={14} color={palette.textSecondary} />
<ThemedText style={[styles.metaText, { color: palette.textSecondary }]}>
{formatDate(generation.createdAt)}
</ThemedText>
</View>
{generation.status !== 'completed' && (
<View style={styles.statusBadge}>
<ThemedText style={[styles.statusText, { color: palette.textSecondary }]}>
{generation.status}
</ThemedText>
</View>
)}
</View>
</View>
</View>
);
}
const darkPalette = {
background: '#050505',
surface: '#121216',
border: '#1D1E24',
textPrimary: '#F6F7FA',
textSecondary: '#8E9098',
accent: '#B7FF2F',
};
const lightPalette = {
background: '#F7F8FB',
surface: '#FFFFFF',
border: '#E2E5ED',
textPrimary: '#0F1320',
textSecondary: '#5E6474',
accent: '#405CFF',
};
const styles = StyleSheet.create({
columnWrapper: {
justifyContent: 'space-between',
},
contentItem: {
flex: 1,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
overflow: 'hidden',
marginHorizontal: 6,
},
mediaContainer: {
width: '100%',
aspectRatio: 1,
backgroundColor: '#000',
},
mediaImage: {
width: '100%',
height: '100%',
},
mediaVideo: {
width: '100%',
height: '100%',
},
contentInfo: {
padding: 12,
},
contentTitle: {
fontSize: 15,
fontWeight: '600',
marginBottom: 8,
},
contentMeta: {
flexDirection: 'row',
alignItems: 'center',
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 12,
},
metaText: {
fontSize: 12,
marginLeft: 4,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
statusText: {
fontSize: 11,
fontWeight: '600',
textTransform: 'capitalize' as const,
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
},
});

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
type TabKey = 'all' | 'image' | 'video';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'image', label: 'Image' },
{ key: 'video', label: 'Video' },
];
export function ContentTabs({
activeTab,
onChangeTab,
}: {
activeTab: TabKey;
onChangeTab: (tab: TabKey) => void;
}) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
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>
);
}
const darkPalette = {
pill: '#16171C',
border: '#1D1E24',
tabActive: '#FFFFFF',
onAccent: '#050505',
textSecondary: '#8E9098',
};
const lightPalette = {
pill: '#E8EBF4',
border: '#E2E5ED',
tabActive: '#FFFFFF',
onAccent: '#FFFFFF',
textSecondary: '#5E6474',
};
const styles = {
tabsBar: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
borderWidth: 0,
borderRadius: 999,
padding: 0,
},
tabButton: {
flex: 1,
borderRadius: 999,
paddingVertical: 10,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
tabLabel: {
fontSize: 15,
fontWeight: '600' as const,
},
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { View } from 'react-native';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Divider() {
const colorScheme = useColorScheme();
const borderColor = colorScheme === 'dark' ? '#1D1E24' : '#E2E5ED';
return <View style={[styles.divider, { backgroundColor: borderColor }]} />;
}
const styles = {
divider: {
height: 1,
marginVertical: 24,
},
};

View File

@@ -0,0 +1,9 @@
export { ProfileScreen, default } from './profile-screen';
export { ProfileHeader } from './profile-header';
export { ProfileIdentity } from './profile-identity';
export { ContentTabs } from './content-tabs';
export { ContentGallery } from './content-gallery';
export { ProfileEmptyState } from './profile-empty-state';
export { ProfileLoadingState } from './profile-loading-state';
export { ProfileErrorState } from './profile-error-state';
export { Divider } from './divider';

View File

@@ -0,0 +1,333 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import * as ImagePicker from 'expo-image-picker';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
TextInput,
TouchableOpacity,
View,
type ImageSourcePropType,
} from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { deriveInitials } from '@/utils/profile-data';
type ProfileEditModalProps = {
visible: boolean;
avatarSource?: ImageSourcePropType;
initialName: string;
onClose: () => void;
onSave: (payload: { name: string; avatar?: ImageSourcePropType }) => void;
};
export function ProfileEditModal({
visible,
avatarSource,
initialName,
onClose,
onSave,
}: ProfileEditModalProps) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? palettes.dark : palettes.light;
const [nameValue, setNameValue] = useState(initialName);
const [avatarCandidate, setAvatarCandidate] = useState<ImageSourcePropType | undefined>(avatarSource);
useEffect(() => {
if (!visible) {
return;
}
setNameValue(initialName);
setAvatarCandidate(avatarSource);
}, [visible, initialName, avatarSource]);
const trimmedName = nameValue.trim();
const initialTrimmedName = initialName.trim();
const hasNameChanged = trimmedName !== initialTrimmedName;
const avatarKey = getSourceKey(avatarCandidate);
const initialAvatarKey = getSourceKey(avatarSource);
const hasAvatarChanged = avatarKey !== initialAvatarKey;
const isSaveDisabled = trimmedName.length === 0 || (!hasNameChanged && !hasAvatarChanged);
const handlePickAvatar = useCallback(async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert('Permission Required', 'Allow photo library access to choose a new portrait.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
if (!asset.uri) {
return;
}
setAvatarCandidate({ uri: asset.uri });
} catch (error) {
console.error('Failed to pick avatar', error);
Alert.alert('Selection Failed', 'We could not open your photo library. Please try again.');
}
}, []);
const handleSave = useCallback(() => {
if (trimmedName.length === 0) {
return;
}
onSave({
name: trimmedName,
avatar: avatarCandidate,
});
}, [avatarCandidate, onSave, trimmedName]);
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button" />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.avoider}
>
<View style={[styles.card, { backgroundColor: palette.surface, borderColor: palette.frame }]}>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Close profile editor"
onPress={onClose}
style={[styles.closeButton, { backgroundColor: palette.buttonGhost, borderColor: palette.frame }]}
activeOpacity={0.85}
>
<MaterialIcons name="close" size={18} color={palette.muted} />
</TouchableOpacity>
<View style={styles.avatarSection}>
<View
style={[
styles.avatarShell,
{ backgroundColor: palette.avatarBackdrop, borderColor: palette.frame },
]}
>
{avatarCandidate ? (
<Image source={avatarCandidate} style={styles.avatarImage} resizeMode="cover" />
) : (
<ThemedText style={[styles.avatarInitials, { color: palette.primary }]}>
{deriveInitials(trimmedName || initialName)}
</ThemedText>
)}
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Choose a new profile picture"
onPress={handlePickAvatar}
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
activeOpacity={0.85}
>
<MaterialIcons name="photo-camera" size={18} color={palette.onAccent} />
</TouchableOpacity>
</View>
<ThemedText style={[styles.modalTitle, { color: palette.primary }]}>
Edit Profile
</ThemedText>
<View style={[styles.fieldWrapper, { backgroundColor: palette.field, borderColor: palette.frame }]}>
<TextInput
value={nameValue}
onChangeText={setNameValue}
placeholder="Display name"
placeholderTextColor={palette.placeholder}
style={[styles.textInput, { color: palette.primary }]}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Save profile changes"
onPress={handleSave}
disabled={isSaveDisabled}
style={[
styles.saveButton,
{
backgroundColor: isSaveDisabled ? palette.buttonGhost : palette.accent,
borderColor: isSaveDisabled ? palette.frame : 'transparent',
borderWidth: isSaveDisabled ? StyleSheet.hairlineWidth : 0,
},
]}
activeOpacity={0.9}
>
<ThemedText
style={[
styles.saveLabel,
{ color: isSaveDisabled ? palette.muted : palette.onAccent },
]}
>
Save
</ThemedText>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
const palettes = {
dark: {
surface: '#121216',
frame: '#1D1E24',
avatarBackdrop: '#1F2026',
field: '#18181D',
primary: '#F6F7FA',
muted: '#8E9098',
placeholder: '#5B5D66',
accent: '#B7FF2F',
onAccent: '#050505',
buttonGhost: '#101014',
},
light: {
surface: '#FFFFFF',
frame: '#D8DCE7',
avatarBackdrop: '#E2E5EF',
field: '#F3F5FC',
primary: '#0F1320',
muted: '#5E6474',
placeholder: '#8C92A3',
accent: '#405CFF',
onAccent: '#FFFFFF',
buttonGhost: '#EBEEF6',
},
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5,5,5,0.68)',
},
avoider: {
width: '100%',
paddingHorizontal: 28,
},
card: {
borderRadius: 24,
paddingHorizontal: 24,
paddingTop: 32,
paddingBottom: 28,
borderWidth: 1,
alignSelf: 'center',
width: '100%',
maxWidth: 360,
},
closeButton: {
alignSelf: 'flex-end',
width: 34,
height: 34,
borderRadius: 17,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
avatarSection: {
alignItems: 'center',
marginBottom: 24,
},
avatarShell: {
width: 88,
height: 88,
borderRadius: 44,
overflow: 'hidden',
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: '100%',
height: '100%',
},
avatarInitials: {
fontSize: 28,
fontWeight: '700',
},
cameraButton: {
position: 'absolute',
right: 18,
bottom: 6,
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
marginBottom: 20,
},
fieldWrapper: {
borderRadius: 14,
borderWidth: 1,
marginBottom: 24,
paddingHorizontal: 16,
paddingVertical: 12,
},
textInput: {
fontSize: 16,
fontWeight: '600',
},
saveButton: {
borderRadius: 999,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
},
saveLabel: {
fontSize: 16,
fontWeight: '700',
},
});
function getSourceKey(source?: ImageSourcePropType): string | null {
if (!source) {
return null;
}
if (typeof source === 'number') {
return String(source);
}
if (Array.isArray(source)) {
return source.map(getSourceKey).join('|');
}
if ('uri' in source && source.uri) {
return source.uri;
}
return null;
}

View File

@@ -0,0 +1,127 @@
import { router } from 'expo-router';
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
type TabKey = 'all' | 'image' | 'video';
export function ProfileEmptyState({ activeTab }: { activeTab: TabKey }) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
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.',
};
}
const darkPalette = {
textPrimary: '#F6F7FA',
textSecondary: '#8E9098',
glyphBackdrop: '#18181D',
border: '#1D1E24',
accent: '#B7FF2F',
elevated: '#101014',
};
const lightPalette = {
textPrimary: '#0F1320',
textSecondary: '#5E6474',
glyphBackdrop: '#E8EBF4',
border: '#E2E5ED',
accent: '#405CFF',
elevated: '#F0F2F8',
};
const styles = {
emptyWrap: {
alignItems: 'center' as const,
paddingTop: 32,
},
emptyGlyph: {
width: 112,
height: 112,
borderRadius: 28,
borderWidth: 1,
alignItems: 'center' as const,
justifyContent: 'center' as const,
marginBottom: 24,
},
emptyTitle: {
fontSize: 18,
fontWeight: '700' as const,
marginBottom: 6,
},
emptySubtitle: {
fontSize: 14,
lineHeight: 20,
textAlign: 'center' as const,
marginBottom: 28,
paddingHorizontal: 24,
},
generateButton: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 28,
paddingVertical: 12,
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
generateIcon: {
marginRight: 8,
},
generateLabel: {
fontSize: 15,
fontWeight: '700' as const,
},
};

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function ProfileErrorState({ onRetry }: { onRetry: () => void }) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
return (
<View style={styles.emptyWrap}>
<View
style={[
styles.emptyGlyph,
{
backgroundColor: palette.glyphBackdrop,
borderColor: palette.border,
},
]}
>
<MaterialIcons name="error-outline" size={40} color={palette.textSecondary} />
</View>
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>Failed to load</ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>
There was an error loading your content
</ThemedText>
<TouchableOpacity
activeOpacity={0.9}
onPress={onRetry}
style={[
styles.generateButton,
{
borderColor: palette.accent,
backgroundColor: palette.elevated,
},
]}
>
<MaterialIcons name="refresh" size={18} color={palette.accent} style={styles.generateIcon} />
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Retry</ThemedText>
</TouchableOpacity>
</View>
);
}
const darkPalette = {
textPrimary: '#F6F7FA',
textSecondary: '#8E9098',
glyphBackdrop: '#18181D',
border: '#1D1E24',
accent: '#B7FF2F',
elevated: '#101014',
};
const lightPalette = {
textPrimary: '#0F1320',
textSecondary: '#5E6474',
glyphBackdrop: '#E8EBF4',
border: '#E2E5ED',
accent: '#405CFF',
elevated: '#F0F2F8',
};
const styles = {
emptyWrap: {
alignItems: 'center' as const,
paddingTop: 32,
},
emptyGlyph: {
width: 112,
height: 112,
borderRadius: 28,
borderWidth: 1,
alignItems: 'center' as const,
justifyContent: 'center' as const,
marginBottom: 24,
},
emptyTitle: {
fontSize: 18,
fontWeight: '700' as const,
marginBottom: 6,
},
emptySubtitle: {
fontSize: 14,
lineHeight: 20,
textAlign: 'center' as const,
marginBottom: 28,
paddingHorizontal: 24,
},
generateButton: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 28,
paddingVertical: 12,
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
generateIcon: {
marginRight: 8,
},
generateLabel: {
fontSize: 15,
fontWeight: '700' as const,
},
};

View File

@@ -0,0 +1,174 @@
import { router } from 'expo-router';
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
type BillingMode = 'monthly' | 'lifetime';
const BILLING_OPTIONS: { key: BillingMode; label: string }[] = [
{ key: 'monthly', label: '月付' },
];
export function ProfileHeader({
billingMode,
onChangeBilling,
credits,
}: {
billingMode: BillingMode;
onChangeBilling: (mode: BillingMode) => void;
credits: number;
}) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
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: any;
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>
);
}
const darkPalette = {
pill: '#16171C',
border: '#1D1E24',
tabActive: '#FFFFFF',
onAccent: '#050505',
textSecondary: '#8E9098',
textPrimary: '#F6F7FA',
accent: '#B7FF2F',
elevated: '#101014',
};
const lightPalette = {
pill: '#E8EBF4',
border: '#E2E5ED',
tabActive: '#FFFFFF',
onAccent: '#FFFFFF',
textSecondary: '#5E6474',
textPrimary: '#0F1320',
accent: '#405CFF',
elevated: '#F0F2F8',
};
const styles = {
headerRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
marginBottom: 28,
},
settingsButton: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
billingShell: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
padding: 4,
borderWidth: 1,
borderRadius: 999,
},
billingOptions: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
},
billingOption: {
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 999,
},
billingLabel: {
fontSize: 13,
fontWeight: '600' as const,
},
lightningShell: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
marginLeft: 8,
},
lightningValue: {
fontSize: 13,
fontWeight: '600' as const,
marginLeft: 6,
},
};

View File

@@ -0,0 +1,184 @@
import React from 'react';
import { View, TouchableOpacity, Image, type ImageSourcePropType } from 'react-native';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { deriveInitials, type IdentityStat } from '@/utils/profile-data';
export function ProfileIdentity({
displayName,
avatarSource,
avatarSize,
stats,
onEdit,
}: {
displayName: string;
avatarSource?: ImageSourcePropType;
avatarSize: number;
stats: IdentityStat[];
onEdit: () => void;
}) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
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={onEdit}
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: any;
size: number;
source?: ImageSourcePropType;
fallback: string;
}) {
const initials = deriveInitials(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: any; 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>
);
}
const darkPalette = {
textPrimary: '#F6F7FA',
textSecondary: '#8E9098',
avatarBackdrop: '#1F2026',
surface: '#121216',
border: '#1D1E24',
};
const lightPalette = {
textPrimary: '#0F1320',
textSecondary: '#5E6474',
avatarBackdrop: '#D5D8E2',
surface: '#FFFFFF',
border: '#E2E5ED',
};
const styles = {
identityRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
marginBottom: 24,
},
avatarShell: {
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
avatarInitials: {
fontSize: 28,
fontWeight: '700' as const,
},
identityDetails: {
flex: 1,
marginLeft: 18,
},
nameRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
marginBottom: 10,
},
username: {
flex: 1,
fontSize: 20,
fontWeight: '700' as const,
marginRight: 12,
},
editButton: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 20,
paddingVertical: 8,
},
editIcon: {
marginRight: 6,
},
editLabel: {
fontSize: 14,
fontWeight: '600' as const,
},
statRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
},
statItem: {
flexDirection: 'row' as const,
alignItems: 'baseline' as const,
marginRight: 18,
},
statValue: {
fontSize: 15,
fontWeight: '700' as const,
marginRight: 4,
},
statLabel: {
fontSize: 13,
fontWeight: '500' as const,
textTransform: 'lowercase' as const,
},
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { View, ActivityIndicator } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function ProfileLoadingState() {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
return (
<View style={styles.loadingWrap}>
<ActivityIndicator size="large" color={palette.accent} />
<ThemedText style={[styles.loadingText, { color: palette.textSecondary }]}>
Loading content...
</ThemedText>
</View>
);
}
const darkPalette = {
textSecondary: '#8E9098',
accent: '#B7FF2F',
};
const lightPalette = {
textSecondary: '#5E6474',
accent: '#405CFF',
};
const styles = {
loadingWrap: {
alignItems: 'center' as const,
paddingTop: 48,
paddingBottom: 48,
},
loadingText: {
marginTop: 12,
fontSize: 14,
},
};

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { View, useWindowDimensions, type ImageSourcePropType } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAuth } from '@/hooks/use-auth';
import { useProfileData } from '@/hooks/use-profile-data';
import { createStats, deriveAvatarSource, deriveDisplayName, deriveNumericValue } from '@/utils/profile-data';
import { StyleSheet } from 'react-native';
import { ContentGallery } from './content-gallery';
import { ContentTabs } from './content-tabs';
import { Divider } from './divider';
import { ProfileEditModal } from './profile-edit-modal';
import { ProfileEmptyState } from './profile-empty-state';
import { ProfileErrorState } from './profile-error-state';
import { ProfileHeader } from './profile-header';
import { ProfileIdentity } from './profile-identity';
import { ProfileLoadingState } from './profile-loading-state';
type TabKey = 'all' | 'image' | 'video';
type BillingMode = 'monthly' | 'lifetime';
export function ProfileScreen() {
const { user } = useAuth();
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
const [activeTab, setActiveTab] = useState<TabKey>('all');
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
const [presentedDisplayName, setPresentedDisplayName] = useState(displayNameFromUser);
const [isEditingIdentity, setIsEditingIdentity] = useState(false);
const {
generations,
isLoading,
error,
isRefreshing,
isLoadingMore,
hasMore,
handleRefresh,
handleLoadMore,
} = useProfileData(activeTab);
const horizontalPadding = width < 400 ? 20 : 24;
const avatarSize = width < 400 ? 74 : 82;
const avatarSource = deriveAvatarSource(user);
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
const stats = createStats(user);
const [presentedAvatar, setPresentedAvatar] = useState<ImageSourcePropType | undefined>(avatarSource);
useEffect(() => {
setPresentedDisplayName(displayNameFromUser);
}, [displayNameFromUser]);
useEffect(() => {
setPresentedAvatar(avatarSource);
}, [avatarSource]);
if (isLoading) {
return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<ProfileLoadingState />
</View>
);
}
if (error) {
return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<ProfileErrorState onRetry={handleRefresh} />
</View>
);
}
return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<View style={{ paddingHorizontal: horizontalPadding }}>
<ProfileHeader
billingMode={billingMode}
onChangeBilling={setBillingMode}
credits={creditBalance}
/>
<ProfileIdentity
displayName={presentedDisplayName}
avatarSource={presentedAvatar}
avatarSize={avatarSize}
stats={stats}
onEdit={() => setIsEditingIdentity(true)}
/>
<Divider />
<ContentTabs
activeTab={activeTab}
onChangeTab={setActiveTab}
/>
<Divider />
</View>
{generations.length === 0 ? (
<ProfileEmptyState activeTab={activeTab} />
) : (
<View style={[styles.galleryContainer, { paddingHorizontal: horizontalPadding / 2 }]}>
<ContentGallery
generations={generations}
isRefreshing={isRefreshing}
onRefresh={handleRefresh}
isLoadingMore={isLoadingMore}
hasMore={hasMore}
onLoadMore={handleLoadMore}
/>
</View>
)}
<ProfileEditModal
visible={isEditingIdentity}
avatarSource={presentedAvatar}
initialName={presentedDisplayName}
onClose={() => setIsEditingIdentity(false)}
onSave={({ name, avatar }) => {
setPresentedDisplayName(name);
if (avatar) {
setPresentedAvatar(avatar);
}
setIsEditingIdentity(false);
}}
/>
</View>
);
}
const stylesVars = {
background: '#050505',
paddingBottom: 96,
};
const styles = StyleSheet.create({
screen: {
flex: 1,
},
galleryContainer: {
paddingTop: 8,
},
});
export default ProfileScreen;