Initial commit: expo-popcore project
This commit is contained in:
329
components/profile/content-gallery.tsx
Normal file
329
components/profile/content-gallery.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { router } from 'expo-router';
|
||||
import type { TemplateGeneration } from '@/lib/api/template-generations';
|
||||
|
||||
/**
|
||||
* 根据文件 URL 后缀名判断媒体类型
|
||||
*/
|
||||
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||
if (!url) return 'unknown';
|
||||
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||
|
||||
if (imageExts.includes(extension)) return 'image';
|
||||
if (videoExts.includes(extension)) return 'video';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function ContentGallery({
|
||||
generations,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
ListHeaderComponent,
|
||||
}: {
|
||||
generations: TemplateGeneration[];
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
|
||||
}) {
|
||||
const palette = darkPalette;
|
||||
|
||||
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} />
|
||||
<ThemedText style={[styles.loadingMoreText, { color: palette.textSecondary }]}>
|
||||
加载更多...
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMore && generations.length > 0) {
|
||||
return (
|
||||
<View style={[styles.noMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ThemedText style={[styles.noMoreText, { color: palette.textSecondary }]}>
|
||||
没有更多内容了
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={generations}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
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}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={6}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={10}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ContentItem = memo(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 (!mediaUrl) {
|
||||
return (
|
||||
<View style={[styles.mediaContainer, styles.placeholderContainer]}>
|
||||
<ThemedText style={styles.placeholderText}>
|
||||
{generation.type === 'VIDEO' ? '🎬' : generation.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaType = getMediaType(mediaUrl);
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<video
|
||||
src={mediaUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover' as any,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
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 (
|
||||
<View style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
backgroundColor: palette.surface,
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/result?generationId=${generation.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{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>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
contentContainer: {
|
||||
paddingBottom: 120,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
placeholderContainer: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
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',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingMoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
noMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noMoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
102
components/profile/content-skeleton.tsx
Normal file
102
components/profile/content-skeleton.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
export function ContentSkeleton() {
|
||||
const palette = darkPalette;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: palette.background }]}>
|
||||
<View style={styles.skeletonGrid}>
|
||||
<View style={styles.column}>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<SkeletonItem key={`left-${i}`} palette={palette} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.column}>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<SkeletonItem key={`right-${i}`} palette={palette} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonItem({ palette }: { palette: any }) {
|
||||
return (
|
||||
<View style={[styles.skeletonItem, { backgroundColor: palette.skeletonBackground }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.skeletonImage,
|
||||
{
|
||||
backgroundColor: palette.skeletonHighlight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={styles.skeletonContent}>
|
||||
<View
|
||||
style={[
|
||||
styles.skeletonLine,
|
||||
{
|
||||
backgroundColor: palette.skeletonHighlight,
|
||||
width: '80%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.skeletonLine,
|
||||
{
|
||||
backgroundColor: palette.skeletonHighlight,
|
||||
width: '60%',
|
||||
marginTop: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
background: '#050505',
|
||||
skeletonBackground: '#0D0E12',
|
||||
skeletonHighlight: '#1A1B20',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
background: '#F7F8FB',
|
||||
skeletonBackground: '#FFFFFF',
|
||||
skeletonHighlight: '#F0F2F8',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
skeletonGrid: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
column: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
skeletonItem: {
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
skeletonImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
},
|
||||
skeletonContent: {
|
||||
padding: 12,
|
||||
},
|
||||
skeletonLine: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
90
components/profile/content-tabs.tsx
Normal file
90
components/profile/content-tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
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,
|
||||
isLoading = false,
|
||||
}: {
|
||||
activeTab: TabKey;
|
||||
onChangeTab: (tab: TabKey) => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const palette = darkPalette;
|
||||
|
||||
return (
|
||||
<View style={[styles.tabsBar]}>
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.key === activeTab;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => onChangeTab(tab.key)}
|
||||
disabled={isLoading}
|
||||
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 styles = {
|
||||
tabsBar: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'flex-start' as const,
|
||||
borderWidth: 0,
|
||||
borderRadius: 999,
|
||||
padding: 0,
|
||||
gap: 4,
|
||||
},
|
||||
tabButton: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
loadingIndicator: {
|
||||
height: 18,
|
||||
},
|
||||
};
|
||||
15
components/profile/divider.tsx
Normal file
15
components/profile/divider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export function Divider() {
|
||||
const borderColor = '#1D1E24';
|
||||
|
||||
return <View style={[styles.divider, { backgroundColor: borderColor }]} />;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 8,
|
||||
},
|
||||
};
|
||||
9
components/profile/index.ts
Normal file
9
components/profile/index.ts
Normal 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';
|
||||
391
components/profile/profile-edit-modal.tsx
Normal file
391
components/profile/profile-edit-modal.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ImageSourcePropType,
|
||||
} from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { deriveInitials } from '@/utils/profile-data';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { patchApiProfileName, patchApiProfileAvatar } from '@repo/loomart-sdk';
|
||||
import { loomartClient } from '@/lib/api/loomart-client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
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 palette = palettes.dark;
|
||||
const { refetch } = useAuth();
|
||||
const [nameValue, setNameValue] = useState(initialName);
|
||||
const [avatarCandidate, setAvatarCandidate] = useState<ImageSourcePropType | undefined>(avatarSource);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
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('需要权限', '请允许访问相册以选择头像');
|
||||
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;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadResponse = await uploadFile(asset.uri, 'image');
|
||||
setIsUploading(false);
|
||||
|
||||
if (uploadResponse.success && uploadResponse.data?.url) {
|
||||
setAvatarCandidate({ uri: uploadResponse.data.url });
|
||||
} else {
|
||||
Alert.alert('上传失败', uploadResponse.message || '图片上传失败,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
console.error('Failed to pick avatar', error);
|
||||
Alert.alert('选择失败', '无法打开相册,请稍后重试');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (trimmedName.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (hasNameChanged) {
|
||||
const nameResponse = await patchApiProfileName({
|
||||
client: loomartClient,
|
||||
body: { name: trimmedName },
|
||||
});
|
||||
|
||||
if (!(nameResponse.data as any)?.success) {
|
||||
throw new Error('更新用户名失败');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAvatarChanged && avatarCandidate && (avatarCandidate as any).uri) {
|
||||
const avatarResponse = await patchApiProfileAvatar({
|
||||
client: loomartClient,
|
||||
body: { image: (avatarCandidate as any).uri! },
|
||||
});
|
||||
|
||||
if (!(avatarResponse.data as any)?.success) {
|
||||
throw new Error('更新头像失败');
|
||||
}
|
||||
}
|
||||
|
||||
await refetch();
|
||||
|
||||
onSave({
|
||||
name: trimmedName,
|
||||
avatar: avatarCandidate,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
Alert.alert('保存失败', error instanceof Error ? error.message : '保存用户信息失败,请稍后重试');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [avatarCandidate, hasNameChanged, hasAvatarChanged, onClose, onSave, trimmedName, refetch]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="slide"
|
||||
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}
|
||||
disabled={isUploading}
|
||||
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator size="small" color={palette.onAccent} />
|
||||
) : (
|
||||
<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 || isSaving}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{
|
||||
backgroundColor: (isSaveDisabled || isSaving) ? palette.buttonGhost : palette.accent,
|
||||
borderColor: (isSaveDisabled || isSaving) ? palette.frame : 'transparent',
|
||||
borderWidth: (isSaveDisabled || isSaving) ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color={palette.onAccent} />
|
||||
) : (
|
||||
<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: '#D1FE17',
|
||||
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: 'flex-end',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5,5,5,0.68)',
|
||||
},
|
||||
avoider: {
|
||||
width: '100%',
|
||||
},
|
||||
card: {
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 28,
|
||||
borderWidth: 1,
|
||||
borderBottomWidth: 0,
|
||||
width: '100%',
|
||||
},
|
||||
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;
|
||||
}
|
||||
114
components/profile/profile-empty-state.tsx
Normal file
114
components/profile/profile-empty-state.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
|
||||
export function ProfileEmptyState({ activeTab }: { activeTab: TabKey }) {
|
||||
const palette = darkPalette;
|
||||
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)/home')}
|
||||
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 styles = {
|
||||
emptyWrap: {
|
||||
alignItems: 'center' as const,
|
||||
paddingTop: 32,
|
||||
},
|
||||
emptyGlyph: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
104
components/profile/profile-error-state.tsx
Normal file
104
components/profile/profile-error-state.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
export function ProfileErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
const palette = darkPalette;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
154
components/profile/profile-header.tsx
Normal file
154
components/profile/profile-header.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
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 palette = darkPalette;
|
||||
|
||||
return (
|
||||
<View style={styles.headerRow}>
|
||||
<View></View>
|
||||
<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={() => router.push('/exchange')}
|
||||
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>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/exchange')}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.lightningShell
|
||||
]}
|
||||
>
|
||||
<Image source={require('@/assets/images/credits.png')} style={{ width: 16, height: 16 }} />
|
||||
<ThemedText style={[styles.lightningValue, { color: `#FED059` }]}>{credits}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
pill: '#16171C',
|
||||
border: '#1D1E24',
|
||||
tabActive: '#FFFFFF',
|
||||
onAccent: '#050505',
|
||||
textSecondary: '#8E9098',
|
||||
textPrimary: '#F6F7FA',
|
||||
accent: '#B7FF2F',
|
||||
elevated: '#101014',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
headerRow: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'space-between' as const,
|
||||
marginBottom: 14,
|
||||
marginTop: 14
|
||||
},
|
||||
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,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
lightningValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600' as const,
|
||||
marginLeft: 6,
|
||||
},
|
||||
};
|
||||
177
components/profile/profile-identity.tsx
Normal file
177
components/profile/profile-identity.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
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 { 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 palette = darkPalette;
|
||||
|
||||
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>
|
||||
</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,
|
||||
},
|
||||
};
|
||||
38
components/profile/profile-loading-state.tsx
Normal file
38
components/profile/profile-loading-state.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
|
||||
export function ProfileLoadingState() {
|
||||
const palette = darkPalette;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
135
components/profile/profile-screen.tsx
Normal file
135
components/profile/profile-screen.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, View, useWindowDimensions, type ImageSourcePropType } from 'react-native';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useBalance } from '@/hooks/use-balance';
|
||||
import { useProfileData } from '@/hooks/use-profile-data';
|
||||
import { PROFILE_THEME } from '@/theme/profile';
|
||||
import { createStats, deriveAvatarSource, deriveDisplayName } from '@/utils/profile-data';
|
||||
import { ContentGallery } from './content-gallery';
|
||||
import { ContentTabs } from './content-tabs';
|
||||
import { Divider } from './divider';
|
||||
import { ProfileEditModal } from './profile-edit-modal';
|
||||
import { ProfileHeader } from './profile-header';
|
||||
import { ProfileIdentity } from './profile-identity';
|
||||
import { Page } from '../sker/page';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
type BillingMode = 'monthly' | 'lifetime';
|
||||
|
||||
export function ProfileScreen() {
|
||||
const { user } = useAuth();
|
||||
const { balance } = useBalance();
|
||||
const { width } = useWindowDimensions();
|
||||
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||
const [isEditingIdentity, setIsEditingIdentity] = useState(false);
|
||||
const [editedIdentity, setEditedIdentity] = useState<{
|
||||
name?: string;
|
||||
avatar?: ImageSourcePropType;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
generations,
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
isTabSwitching,
|
||||
handleRefresh,
|
||||
handleLoadMore,
|
||||
} = useProfileData(activeTab);
|
||||
|
||||
const isSmallScreen = width < PROFILE_THEME.breakpoints.small;
|
||||
const avatarSize = isSmallScreen
|
||||
? PROFILE_THEME.avatar.size.small
|
||||
: PROFILE_THEME.avatar.size.large;
|
||||
|
||||
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
||||
const avatarSource = deriveAvatarSource(user);
|
||||
const creditBalance = balance.remainingTokenBalance;
|
||||
const stats = createStats(user);
|
||||
|
||||
const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;
|
||||
const presentedAvatar = editedIdentity?.avatar ?? avatarSource;
|
||||
|
||||
const headerContainerStyle = useMemo(
|
||||
() => ({
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: 0,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const headerComponent = useMemo(
|
||||
() => (
|
||||
<View style={headerContainerStyle}>
|
||||
<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}
|
||||
isLoading={isTabSwitching}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
</View>
|
||||
),
|
||||
[
|
||||
headerContainerStyle,
|
||||
billingMode,
|
||||
creditBalance,
|
||||
presentedDisplayName,
|
||||
presentedAvatar,
|
||||
avatarSize,
|
||||
stats,
|
||||
activeTab,
|
||||
isTabSwitching,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ContentGallery
|
||||
generations={generations}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
ListHeaderComponent={headerComponent}
|
||||
/>
|
||||
|
||||
<ProfileEditModal
|
||||
visible={isEditingIdentity}
|
||||
avatarSource={presentedAvatar}
|
||||
initialName={presentedDisplayName}
|
||||
onClose={() => setIsEditingIdentity(false)}
|
||||
onSave={({ name, avatar }) => {
|
||||
setEditedIdentity({ name, avatar });
|
||||
setIsEditingIdentity(false);
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flexContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default ProfileScreen;
|
||||
Reference in New Issue
Block a user