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

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { useAuth } from '@/hooks/use-auth';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { LoginModal } from './login-modal';
export interface AuthContextType {

View File

@@ -35,6 +35,105 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
// Web端CSS样式 - 覆盖autofill + 禁用密码自动填充
useEffect(() => {
if (Platform.OS === 'web') {
const cssStyles = `
/* 输入框基础样式 */
.login-input-field,
.login-input-field input,
.login-input-field textarea {
background-color: #1a1d23 !important;
color: #f5f6f8 !important;
}
/* 自动填充状态覆盖 */
.login-input-field:-webkit-autofill,
.login-input-field:-webkit-autofill:hover,
.login-input-field:-webkit-autofill:focus,
.login-input-field:-webkit-autofill:active {
background-color: #1a1d23 !important;
color: #f5f6f8 !important;
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
-webkit-text-fill-color: #f5f6f8 !important;
caret-color: #f5f6f8 !important;
transition: background-color 9999s ease-in-out 0s;
}
.login-input-field input:-webkit-autofill,
.login-input-field input:-webkit-autofill:hover,
.login-input-field input:-webkit-autofill:focus,
.login-input-field input:-webkit-autofill:active,
.login-input-field textarea:-webkit-autofill,
.login-input-field textarea:-webkit-autofill:hover,
.login-input-field textarea:-webkit-autofill:focus,
.login-input-field textarea:-webkit-autofill:active {
background-color: #1a1d23 !important;
color: #f5f6f8 !important;
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
-webkit-text-fill-color: #f5f6f8 !important;
caret-color: #f5f6f8 !important;
transition: background-color 9999s ease-in-out 0s;
}
/* Chrome内部autofill状态 */
.login-input-field:-internal-autofill-selected,
.login-input-field:-internal-autofill-previewed,
.login-input-field input:-internal-autofill-selected,
.login-input-field input:-internal-autofill-previewed,
.login-input-field textarea:-internal-autofill-selected,
.login-input-field textarea:-internal-autofill-previewed {
background-color: #1a1d23 !important;
color: #f5f6f8 !important;
-webkit-text-fill-color: #f5f6f8 !important;
caret-color: #f5f6f8 !important;
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
transition: background-color 9999s ease-in-out 0s;
}
/* 文本选择高亮 */
.login-input-field::selection {
background-color: #f6474d;
}
/* 密码输入框禁用自动填充 */
input[type="password"][class*="login-input-field"] {
-webkit-text-security: disc !important;
}
/* 隐藏自动填充样式覆盖提示 */
input[class*="login-input-field"]:-webkit-autofill,
input[class*="login-input-field"]:-webkit-autofill:hover,
input[class*="login-input-field"]:-webkit-autofill:focus,
input[class*="login-input-field"]:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
}
`;
// 创建style标签
const style = document.createElement('style');
style.innerHTML = cssStyles;
style.id = 'login-autofill-styles';
// 添加到head
const existingStyle = document.getElementById('login-autofill-styles');
if (!existingStyle) {
document.head.appendChild(style);
}
// 清理函数
return () => {
const element = document.getElementById('login-autofill-styles');
if (element) {
element.remove();
}
};
}
}, []);
useEffect(() => {
if (visible) {
Animated.parallel([
@@ -132,27 +231,35 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
handleClose();
};
// Web端CSS类名 - 仅使用CSS类名避开类型限制
const webInputProps = Platform.select({
web: {
className: 'login-input-field',
},
default: {},
});
return (
<Modal visible={visible} transparent animationType="none">
<Animated.View
style={[
styles.overlay,
{
opacity: fadeAnim,
},
]}
>
<TouchableOpacity
activeOpacity={1}
style={styles.backdrop}
onPress={handleBackdropPress}
<Animated.View
style={[
styles.overlay,
{
opacity: fadeAnim,
},
]}
>
<LinearGradient
colors={["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.5)"]}
style={StyleSheet.absoluteFillObject}
/>
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
activeOpacity={1}
style={styles.backdrop}
onPress={handleBackdropPress}
>
<LinearGradient
colors={["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.5)"]}
style={StyleSheet.absoluteFillObject}
/>
</TouchableOpacity>
</Animated.View>
<Animated.View
style={[
@@ -191,7 +298,7 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
<View style={styles.form}>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
style={[styles.input, Platform.select({ web: { outline: 'none' } as any })]}
placeholder="yours@example.com"
placeholderTextColor="#70737c"
value={email}
@@ -201,12 +308,13 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
keyboardType="email-address"
editable={!isLoading}
keyboardAppearance="dark"
{...webInputProps}
/>
</View>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
style={[styles.input, Platform.select({ web: { outline: 'none' } as any })]}
placeholder="email password"
placeholderTextColor="#70737c"
value={password}
@@ -215,6 +323,9 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
autoCorrect={false}
editable={!isLoading}
keyboardAppearance="dark"
// 密码框专用属性
textContentType={Platform.select({ web: undefined, default: "newPassword" })}
{...webInputProps}
/>
<TouchableOpacity
activeOpacity={0.7}
@@ -366,6 +477,7 @@ const styles = StyleSheet.create({
paddingVertical: 16,
fontSize: 16,
color: "#f5f6f8",
backgroundColor: "transparent",
letterSpacing: 0.3,
},
visibilityToggle: {

View File

@@ -1,16 +1,18 @@
import { Ionicons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { memo } from 'react';
import {
FlatList,
ListRenderItemInfo,
Pressable,
Image as RNImage,
StyleProp,
StyleSheet,
Text,
View,
ViewStyle,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
export type CommunityItem = {
id: string;
@@ -26,22 +28,43 @@ type CommunityGridProps = {
onPressAction?: (item: CommunityItem) => void;
};
const EMPTY_ITEM: CommunityItem = {
id: 'empty',
title: '',
image: '',
chip: '',
actionLabel: '',
};
export function CommunityGrid({ items, onPressCard, onPressAction }: CommunityGridProps) {
const renderItem = ({ item, index }: ListRenderItemInfo<CommunityItem>) => {
const isEmpty = item.id === 'empty';
const isLeftColumn = index % 2 === 0;
return (
<CommunityCard
item={item}
onPressCard={onPressCard}
onPressAction={onPressAction}
style={[styles.cardWrapper, isLeftColumn ? styles.cardLeft : styles.cardRight]}
/>
<View style={[styles.cardWrapper, isLeftColumn ? styles.cardLeft : styles.cardRight]}>
{isEmpty ? (
<View style={styles.emptyCard} />
) : (
<CommunityCard
item={item}
onPressCard={onPressCard}
onPressAction={onPressAction}
/>
)}
</View>
);
};
const data = items.length === 0
? [EMPTY_ITEM, EMPTY_ITEM]
: items.length === 1
? [...items, EMPTY_ITEM]
: items.slice(0, 4);
return (
<FlatList
data={items}
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.id}
numColumns={2}
@@ -62,11 +85,15 @@ type CommunityCardProps = {
const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
return (
<Pressable onPress={() => onPressCard?.(item)} style={[styles.card, style]}>
<View>
<Image source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" />
<View style={styles.imageWrapper}>
<ExpoImage source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" />
<LinearGradient
colors={['rgba(12, 14, 20, 0)', 'rgba(12, 14, 20, 0.85)']}
style={styles.imageGradient}
/>
<Chip label={item.chip} />
<Text style={styles.cardTitle}>{item.title}</Text>
</View>
<Text style={styles.cardTitle}>{item.title}</Text>
<ActionButton label={item.actionLabel} onPress={() => onPressAction?.(item)} />
</Pressable>
);
@@ -80,8 +107,8 @@ type ChipProps = {
const Chip = memo(({ label }: ChipProps) => {
return (
<View style={styles.chip}>
<Ionicons name="people-outline" size={14} color="#E5E9F2" style={styles.chipIcon} />
<Text style={styles.chipText}>{label}</Text>
<Ionicons name="people-outline" size={14} color="#F4F7FF" style={styles.chipIcon} />
</View>
);
});
@@ -95,7 +122,11 @@ type ActionButtonProps = {
const ActionButton = memo(({ label, onPress }: ActionButtonProps) => {
return (
<Pressable onPress={onPress} style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}>
<Ionicons name="flash-outline" size={16} color="#050505" style={styles.buttonIcon} />
<RNImage
source={require('@/assets/icons/start.svg')}
style={styles.buttonIcon}
resizeMode="contain"
/>
<Text style={styles.buttonText}>{label}</Text>
</Pressable>
);
@@ -119,61 +150,84 @@ const styles = StyleSheet.create({
cardRight: {
marginLeft: 8,
},
card: {
backgroundColor: '#101115',
emptyCard: {
backgroundColor: 'transparent',
borderRadius: 20,
padding: 14,
},
card: {
backgroundColor: '#2E3031',
borderRadius: 20,
padding: 0,
borderWidth: 1,
borderColor: '#1C1D22',
borderColor: '#1B1D24',
},
imageWrapper: {
position: 'relative',
borderRadius: 16,
overflow: 'hidden',
marginBottom: 0,
},
cardImage: {
width: '100%',
height: 148,
borderRadius: 16,
marginBottom: 14,
aspectRatio: 9 / 16,
height: undefined,
},
imageGradient: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 88,
},
cardTitle: {
fontSize: 13,
letterSpacing: 0.8,
position: 'absolute',
left: 16,
bottom: 18,
fontSize: 14,
letterSpacing: 1,
fontWeight: '700',
color: '#F5F8FF',
marginBottom: 12,
textTransform: 'uppercase',
},
chip: {
position: 'absolute',
top: 12,
right: 12,
backgroundColor: 'rgba(5, 5, 5, 0.72)',
borderRadius: 14,
top: 14,
right: 14,
backgroundColor: 'rgba(27, 43, 78, 0.92)',
borderRadius: 16,
paddingHorizontal: 12,
paddingVertical: 4,
paddingVertical: 5,
flexDirection: 'row',
alignItems: 'center',
},
chipIcon: {
marginRight: 6,
marginLeft: 6,
},
chipText: {
color: '#C7FF00',
fontSize: 11,
fontWeight: '700',
color: '#E4EBFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
},
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 14,
backgroundColor: '#B7FF2F',
paddingVertical: 14,
borderRadius: 16,
borderWidth: 0,
},
buttonPressed: {
opacity: 0.9,
opacity: 0.85,
},
buttonIcon: {
marginRight: 4,
width: 18,
height: 18,
marginRight: 8,
},
buttonText: {
fontSize: 14,
fontWeight: '700',
color: '#050505',
color: '#C7FF00',
},
});

View File

@@ -1,10 +1,10 @@
import { Image } from 'expo-image';
import { memo } from 'react';
import { Dimensions, FlatList, ListRenderItem, Pressable, StyleSheet, Text, View } from 'react-native';
import { Image } from 'expo-image';
const WINDOW_WIDTH = Dimensions.get('window').width;
const CARD_HORIZONTAL_GUTTER = 18;
const CARD_WIDTH = Math.min(WINDOW_WIDTH - 64, 360);
const CARD_WIDTH = Math.min(WINDOW_WIDTH - 124, 360);
export type FeatureItem = {
id: string;
@@ -20,7 +20,7 @@ type FeatureCarouselProps = {
export function FeatureCarousel({ items, onPress }: FeatureCarouselProps) {
const cardInterval = CARD_WIDTH + CARD_HORIZONTAL_GUTTER;
const renderItem: ListRenderItem<FeatureItem> = ({ item }) => <FeatureCard item={item} onPress={onPress} />;
return (
@@ -54,7 +54,9 @@ const FeatureCard = memo(({ item, onPress }: FeatureCardProps) => {
},
]}
>
<Image source={{ uri: item.image }} style={styles.image} contentFit="cover" />
<View style={styles.imageContainer}>
<Image source={{ uri: item.image }} style={styles.image} contentFit="cover" />
</View>
<View style={styles.cardContent}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardSubtitle}>{item.subtitle}</Text>
@@ -66,36 +68,47 @@ FeatureCard.displayName = 'FeatureCard';
const styles = StyleSheet.create({
contentContainer: {
paddingVertical: 18,
paddingVertical: 12,
paddingRight: 24,
},
card: {
width: CARD_WIDTH,
marginRight: CARD_HORIZONTAL_GUTTER,
borderRadius: 22,
overflow: 'hidden',
borderRadius: 24,
padding: 0,
paddingBottom: 16,
borderWidth: 1,
borderColor: '#1B1C20',
backgroundColor: '#0F1013',
shadowColor: '#000000',
shadowOpacity: 0.28,
shadowRadius: 18,
shadowOffset: { width: 0, height: 12 },
elevation: 12,
},
imageContainer: {
borderRadius: 18,
overflow: 'hidden',
marginBottom: 16,
},
image: {
width: '100%',
height: 184,
},
cardContent: {
padding: 18,
backgroundColor: '#111115',
paddingHorizontal: 2,
},
cardTitle: {
fontSize: 16,
fontWeight: '800',
color: '#F4F8FF',
textTransform: 'uppercase',
marginBottom: 6,
letterSpacing: 0.8,
letterSpacing: 1.2,
},
cardSubtitle: {
fontSize: 13,
color: '#9AA0AD',
color: '#B4BBC5',
lineHeight: 18,
},
});

File diff suppressed because one or more lines are too long

View File

@@ -57,8 +57,7 @@ export function FullscreenMediaModal({
}
}, [visible, currentIndex]);
// 处理媒体加载完成(移动平台)
const handleMediaLoad = () => {
const markMediaLoaded = () => {
setIsLoading(false);
};
@@ -156,11 +155,9 @@ export function FullscreenMediaModal({
style={styles.mediaContainer}
onPress={handleMediaPress}
activeOpacity={1}
pointerEvents="box-none"
>
<View
style={styles.mediaWrapper}
pointerEvents="auto"
{...panResponder.panHandlers}
>
{/* 根据模板类型显示对应内容 */}
@@ -179,7 +176,7 @@ export function FullscreenMediaModal({
autoPlay={true}
fullscreenMode={true}
onPress={handleMediaPress}
onReady={handleMediaLoad}
onReady={(_status) => markMediaLoaded()}
onWebLoadedData={handleWebVideoLoad}
onError={handleMediaError}
/>
@@ -189,7 +186,7 @@ export function FullscreenMediaModal({
source={{ uri: currentTemplate.coverImageUrl || '' }}
style={styles.image}
contentFit="contain"
onLoad={handleMediaLoad}
onLoad={() => markMediaLoaded()}
onError={handleMediaError}
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
transition={300}

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;

View File

@@ -13,7 +13,7 @@ import { TemplateGeneration } from '@/lib/types/template-run';
import { Image } from 'expo-image';
import { VideoPlayer } from '@/components/video/video-player';
import { useState } from 'react';
import { cacheDirectory, documentDirectory, downloadAsync } from 'expo-file-system';
import * as FileSystem from 'expo-file-system';
import * as MediaLibrary from 'expo-media-library';
interface ResultDisplayProps {
@@ -102,16 +102,14 @@ export function ResultDisplay({
}
// 下载文件
const targetDirectory = documentDirectory ?? cacheDirectory;
if (!targetDirectory) {
throw new Error('无法获取可写入的目录');
}
const downloadResult = await downloadAsync(
url,
`${targetDirectory}generated_${Date.now()}_${index}.${getFileExtension(url)}`
const targetDirectory = FileSystem.Paths.document ?? FileSystem.Paths.cache;
const destination = new FileSystem.File(
targetDirectory,
`generated_${Date.now()}_${index}.${getFileExtension(url)}`
);
const downloadResult = await FileSystem.File.downloadFileAsync(url, destination);
// 保存到媒体库
if (result.type === 'IMAGE') {
await MediaLibrary.saveToLibraryAsync(downloadResult.uri);

View File

@@ -1,4 +1,4 @@
import { View, StyleSheet, Animated, TouchableOpacity } from 'react-native';
import { View, StyleSheet, Animated, TouchableOpacity, type ColorValue } from 'react-native';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { LinearGradient } from 'expo-linear-gradient';
@@ -47,18 +47,18 @@ export function RunProgressView({ progress, onCancel }: RunProgressViewProps) {
}
}, [progress.status, pulseAnimation]);
const getStatusColor = () => {
const getStatusColor = (): readonly [ColorValue, ColorValue] => {
switch (progress.status) {
case 'pending':
return ['#FFA500', '#FF8C00']; // 橙色
return ['#FFA500', '#FF8C00'] as const; // 橙色
case 'running':
return ['#4ECDC4', '#44A3A0']; // 青色
return ['#4ECDC4', '#44A3A0'] as const; // 青色
case 'completed':
return ['#52B788', '#40916C']; // 绿色
return ['#52B788', '#40916C'] as const; // 绿色
case 'failed':
return ['#FF6B6B', '#FF5252']; // 红色
return ['#FF6B6B', '#FF5252'] as const; // 红色
default:
return ['#999999', '#666666'];
return ['#999999', '#666666'] as const;
}
};

View File

@@ -6,7 +6,7 @@ import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
export type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
@@ -20,6 +20,13 @@ const MAPPING = {
'chevron.right': 'chevron-right',
'person.fill': 'person',
'person': 'person',
'gearshape': 'settings',
'person.circle': 'person-outline',
'bell': 'notifications',
'lock': 'lock',
'info.circle': 'info',
'exclamationmark.bubble': 'feedback',
'arrow.right.square': 'logout',
} as IconMapping;
/**

View File

@@ -5,7 +5,7 @@ import { router } from 'expo-router';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { IconSymbol, type IconSymbolName } from '@/components/ui/icon-symbol';
interface SettingsListProps {
onLogout: () => void;
@@ -14,7 +14,7 @@ interface SettingsListProps {
interface SettingItem {
id: string;
title: string;
icon: string;
icon: IconSymbolName;
onPress?: () => void;
destructive?: boolean;
}
@@ -41,7 +41,8 @@ export function SettingsList({ onLogout }: SettingsListProps) {
id: 'privacy',
title: '隐私设置',
icon: 'lock',
onPress: () => router.push('/settings/privacy'),
onPress: () =>
Alert.alert('敬请期待', '隐私设置即将上线,敬请期待。'),
},
{
id: 'about',
@@ -53,7 +54,8 @@ export function SettingsList({ onLogout }: SettingsListProps) {
id: 'feedback',
title: '意见反馈',
icon: 'exclamationmark.bubble',
onPress: () => router.push('/settings/feedback'),
onPress: () =>
Alert.alert('感谢反馈', '请通过 support@bowong.cc 与我们联系。'),
},
{
id: 'logout',
@@ -162,4 +164,4 @@ const styles = StyleSheet.create({
settingTitle: {
fontSize: 16,
},
});
});

View File

@@ -1,5 +1,5 @@
import { ThemedText } from '@/components/themed-text';
import { AVPlaybackStatus, ResizeMode, Video } from 'expo-av';
import { type VideoReadyForDisplayEvent, ResizeMode, Video } from 'expo-av';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
@@ -59,12 +59,10 @@ export function FullscreenVideoModal({
}, [visible, autoPlay]);
// 处理视频加载完成
const handleVideoReady = (status: AVPlaybackStatus) => {
if (status.isLoaded) {
setIsLoading(false);
if (autoPlay) {
videoRef.current?.playAsync();
}
const handleVideoReady = (_event: VideoReadyForDisplayEvent) => {
setIsLoading(false);
if (autoPlay) {
videoRef.current?.playAsync();
}
};

View File

@@ -10,6 +10,7 @@ import {
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { Image } from 'expo-image';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
import {
extractVideoMetadata,
@@ -59,7 +60,8 @@ export function VideoPlayer({
onPress,
fullscreenMode = false, // 新增:默认非全屏模式
}: VideoPlayerProps) {
const videoRef = useRef<Video>(null);
const nativeVideoRef = useRef<Video | null>(null);
const webVideoRef = useRef<HTMLVideoElement | null>(null);
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
const [videoMetadata, setVideoMetadata] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -185,15 +187,29 @@ export function VideoPlayer({
}
// 如果视频已加载但未播放,开始播放
if (Platform.OS === 'web') {
webVideoRef.current?.play().catch(() => {});
return;
}
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
videoRef.current?.playAsync();
nativeVideoRef.current?.playAsync().catch(() => {});
}
};
// 自动播放逻辑
useEffect(() => {
if (autoPlay && videoRef.current && videoStatus?.isLoaded) {
videoRef.current.playAsync();
if (!autoPlay) {
return;
}
if (Platform.OS === 'web') {
webVideoRef.current?.play().catch(() => {});
return;
}
if (videoStatus?.isLoaded) {
nativeVideoRef.current?.playAsync().catch(() => {});
}
}, [autoPlay, videoStatus]);
@@ -260,7 +276,7 @@ export function VideoPlayer({
<>
{Platform.OS === 'web' ? (
<video
ref={videoRef}
ref={webVideoRef}
src={source.uri}
style={{
position: 'absolute',
@@ -291,7 +307,7 @@ export function VideoPlayer({
/>
) : (
<Video
ref={videoRef}
ref={nativeVideoRef}
source={source}
style={styles.video}
resizeMode={resizeMode}
@@ -339,9 +355,9 @@ export function VideoPlayer({
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && !hasVideoError && !fullscreenMode && (
<View style={styles.playButtonOverlay}>
<View style={styles.playButton}>
<ThemedView style={styles.playIcon}>
<ThemedText style={styles.playIcon}>
{'▶️'}
</ThemedView>
</ThemedText>
</View>
</View>
)}
@@ -349,9 +365,9 @@ export function VideoPlayer({
{/* 错误状态 - 只在非 Web 平台显示 */}
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
<View style={styles.errorOverlay}>
<ThemedView style={styles.errorMessage}>
<ThemedText style={styles.errorMessage}>
{'❌'}
</ThemedView>
</ThemedText>
</View>
)}
</ThemedView>
@@ -439,4 +455,4 @@ const styles = StyleSheet.create({
},
});
export default VideoPlayer;
export default VideoPlayer;