Initial commit: expo-popcore project

This commit is contained in:
imeepos
2025-12-25 16:31:17 +08:00
commit 0cbb74e03a
188 changed files with 20796 additions and 0 deletions

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

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

View 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,
},
};

View 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,
},
};

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

View 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,
},
};

View 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,
},
};

View 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,
},
};

View 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,
},
};

View 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,
},
};

View 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;