Initial commit: expo-popcore-app

This commit is contained in:
imeepos
2025-12-25 16:25:55 +08:00
commit 02d0c807cd
160 changed files with 39560 additions and 0 deletions

137
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { Tabs } from 'expo-router'
import React from 'react'
import { StyleSheet, View, Text } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { useTranslation } from 'react-i18next'
import { HomeIcon, MessageIcon, MyIcon, VideoIcon } from '@/components/icon'
export default function TabLayout() {
const { t } = useTranslation()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#FFFFFF',
tabBarInactiveTintColor: '#FFFFFF',
headerShown: false,
tabBarHideOnKeyboard: true,
tabBarShowLabel: true,
tabBarStyle: {
backgroundColor: '#1C1E22CC',
height: 83,
paddingTop: 4,
paddingBottom: 4,
paddingLeft: 4,
paddingRight: 4,
borderTopWidth: 0,
},
tabBarLabelStyle: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '400',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: t('tabs.home'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.home')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<HomeIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: 0.6 }}>
<HomeIcon />
</View>
)
},
}}
/>
<Tabs.Screen
name="video"
options={{
title: t('tabs.video'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.video')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<VideoIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: 0.6 }}>
<VideoIcon />
</View>
)
},
}}
/>
<Tabs.Screen
name="message"
options={{
title: t('tabs.message'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.message')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<MessageIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: focused ? 1 : 0.6 }}>
<MessageIcon />
</View>
)
},
}}
/>
<Tabs.Screen
name="my"
options={{
title: t('tabs.my'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.my')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<MyIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: focused ? 1 : 0.6 }}>
<MyIcon />
</View>
)
},
}}
/>
</Tabs>
)
}
const styles = StyleSheet.create({
iconContainer: {
width: 87,
height: 41,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 100,
},
tabLabel: {
fontSize: 10,
color: '#FFFFFF',
textAlign: 'center',
},
})

624
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,624 @@
import { useState, useRef, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
Dimensions,
ScrollView,
Pressable,
StatusBar as RNStatusBar,
Animated,
Platform,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PointsIcon, SearchIcon, DownArrowIcon, WhiteStarIcon } from '@/components/icon'
import { AuthForm } from '@/components/blocks/AuthForm'
import { useActivates } from '@/hooks/use-activates'
const { width: screenWidth } = Dimensions.get('window')
// 卡片数据 - 根据 Figma 设计更新
const cardData = [
{
id: 1,
title: '宠物写真',
image: require('@/assets/images/android-icon-background.png'),
isHot: true,
users: 6349,
height: 214,
},
{
id: 2,
title: '我和小猫的人生合照',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 236,
},
{
id: 3,
title: '猫:晚安~人',
image: require('@/assets/images/favicon.png'),
users: 6349,
height: 214,
},
{
id: 4,
title: '穿越时空的相聚',
image: require('@/assets/images/icon.png'),
users: 6349,
height: 100,
},
{
id: 5,
title: '睡衣版猫咪',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 120,
},
{
id: 6,
title: '猫咪写真',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 214,
},
{
id: 7,
title: '站姐视角写真',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 214,
},
{
id: 8,
title: '猫咪写真',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 200,
},
]
export default function HomeScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const [activeTab, setActiveTab] = useState(0)
// 标签数据 - 根据 Figma 设计更新
const tabs = [
t('home.tabs.featured'),
t('home.tabs.christmas'),
t('home.tabs.pets'),
t('home.tabs.avatar'),
t('home.tabs.theater1'),
t('home.tabs.theater2'),
]
const [gridWidth, setGridWidth] = useState(screenWidth)
const [showTabArrow, setShowTabArrow] = useState(false)
const [tabsSticky, setTabsSticky] = useState(false)
const [tabsHeight, setTabsHeight] = useState(0)
const tabsPositionRef = useRef(0)
const titleBarHeightRef = useRef(0)
const scrollY = useRef(new Animated.Value(0)).current
const { load, data: activatesData, error } = useActivates()
useEffect(() => {
load()
}, [])
useEffect(() => {
console.log({ activatesData, error })
}, [activatesData, error])
const horizontalPadding = 8 * 2 // gridContainer 的左右 padding
const cardGap = 5 // 两个卡片之间的间距
const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2
// 渲染标签导航的函数
const renderTabs = (wrapperStyle?: any) => (
<View style={[styles.tabsWrapper, wrapperStyle]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabsContainer}
contentContainerStyle={[
styles.tabsContent,
showTabArrow && styles.tabsContentWithArrow,
]}
onContentSizeChange={(contentWidth) => {
const tabsContainerPadding = 16 * 2 // tabsContent 的左右 padding
const availableWidth = screenWidth - tabsContainerPadding
setShowTabArrow(contentWidth > availableWidth)
}}
>
{tabs.map((tab, index) => (
<Pressable
key={index}
onPress={() => setActiveTab(index)}
style={styles.tab}
>
<View style={styles.tabLabelWrapper}>
{activeTab === index && (
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.tabUnderline}
/>
)}
<Text
style={[
styles.tabText,
activeTab === index && styles.tabTextActive,
]}
>
{tab}
</Text>
</View>
</Pressable>
))}
</ScrollView>
{showTabArrow && (
<View style={styles.tabArrowContainer}>
<LinearGradient
colors={['#090A0B', 'rgba(9, 10, 11, 0)']}
locations={[0.38, 1.0]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={styles.tabArrowGradient}
>
<Pressable
style={styles.tabArrow}
onPress={() => router.push('/channels')}
>
<DownArrowIcon />
</Pressable>
</LinearGradient>
</View>
)}
</View>
)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 标题栏 */}
<View
style={styles.titleBar}
onLayout={(event) => {
const { height } = event.nativeEvent.layout
titleBarHeightRef.current = height
}}
>
<Text style={styles.appTitle}>Popcore</Text>
<View style={styles.headerRight}>
<Pressable
style={styles.pointsContainer}
onPress={() => router.push('/membership' as any)}
>
<PointsIcon />
<Text style={styles.pointsText}>60</Text>
</Pressable>
<Pressable
style={styles.searchButton}
onPress={() => router.push('/searchTemplate')}
>
<SearchIcon />
</Pressable>
</View>
</View>
{/* 吸顶的标签导航 - 适配 iOS 和 Android 的安全区域 */}
{tabsSticky && (
<View
style={[
styles.stickyTabsWrapper,
// 加上 insets.top 以适配不同设备的状态栏高度iOS 刘海屏、Android 状态栏等)
{ top: titleBarHeightRef.current + insets.top },
]}
>
{renderTabs(styles.stickyTabs)}
</View>
)}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={(event) => {
const scrollY = event.nativeEvent.contentOffset.y
if (scrollY >= tabsPositionRef.current) {
setTabsSticky(true)
} else {
setTabsSticky(false)
}
}}
// iOS 使用 16ms (60fps)Android 使用 50ms 以获得更好的性能
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
>
{/* 图片区域 */}
<View style={styles.heroSection}>
<ScrollView
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.heroSliderContent}
>
{activatesData?.activities.map((activity) => (
<Pressable key={activity.id} style={styles.heroMainSlide} onPress={() => router.push(activity.link as any)}>
<Image
source={{ uri: activity.coverUrl }}
style={styles.heroMainImage}
contentFit="cover"
/>
<View style={styles.heroTextContainer}>
<Text style={styles.heroText}>{activity.title}</Text>
<Text style={styles.heroSubtext}>{activity.desc}</Text>
</View>
</Pressable>
))}
</ScrollView>
</View>
{/* 标签导航 */}
<View
onLayout={(event) => {
const { y, height } = event.nativeEvent.layout
tabsPositionRef.current = y
setTabsHeight(height)
}}
style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined}
>
{renderTabs()}
</View>
<View className='py-6'>
<AuthForm mode='register' />
</View>
{/* 内容网格 */}
<View
style={styles.gridContainer}
onLayout={(event) => {
const { width } = event.nativeEvent.layout
setGridWidth(width)
}}
>
{cardData.map((card, index) => (
<Pressable
key={card.id}
style={[
styles.card,
{ width: cardWidth },
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
]}
onPress={() => {
router.push({
pathname: '/templateDetail' as any,
params: { id: card.id.toString() },
})
}}
>
<View
style={[
styles.cardImageContainer,
{ height: card.height || cardWidth * 1.2 },
]}
>
<Image
source={card.image}
style={styles.cardImage}
contentFit="cover"
/>
<LinearGradient
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.cardImageGradient}
/>
{card.isHot ? (
<View style={styles.hotBadge}>
<Text style={styles.hotEmoji}>🔥</Text>
<Text style={styles.hotText}>{t('home.hotTemplate')}</Text>
</View>
) : (
<View style={styles.hotBadge}>
<WhiteStarIcon />
<Text style={styles.hotText}>
{card.users}{t('home.peopleUsed')}
</Text>
</View>
)}
<Text style={styles.cardTitle} numberOfLines={1}>
{card.title}
</Text>
</View>
</Pressable>
))}
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
backgroundColor: '#090A0B',
paddingTop: 8,
paddingBottom: 12,
},
statusBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
},
time: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
statusIcons: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
signalBars: {
width: 18,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
wifiIcon: {
width: 16,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
batteryIcon: {
width: 24,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
titleBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 7,
paddingTop: 19,
},
appTitle: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '500',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
pointsContainer: {
backgroundColor: '#1C1E22',
borderRadius: 12,
paddingLeft: 8,
paddingRight: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
},
pointsText: {
color: '#FFCF00',
fontSize: 12,
fontWeight: '600',
},
searchButton: {
padding: 4,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
},
heroSection: {
flexDirection: 'row',
paddingLeft: 12,
paddingTop: 12,
marginBottom: 40,
overflow: 'hidden',
},
heroSliderContent: {
gap: 12,
},
heroMainSlide: {
width: '100%',
},
heroMainImage: {
width: 265,
height: 150,
borderRadius: 12,
},
heroTextContainer: {
paddingTop: 12,
paddingHorizontal: 8,
},
heroText: {
color: '#ABABAB',
fontSize: 12,
marginBottom: 4,
},
heroSubtext: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '500',
},
heroSide: {
width: 120,
borderRadius: 12,
overflow: 'hidden',
},
heroSideImage: {
width: '100%',
height: 140,
},
heroSideTextContainer: {
padding: 8,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
heroSideText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '500',
marginBottom: 2,
},
heroSideSubtext: {
color: '#FFFFFF',
fontSize: 11,
opacity: 0.9,
},
tabsWrapper: {
position: 'relative',
marginBottom: 18,
},
stickyTabsWrapper: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 100,
backgroundColor: '#090A0B',
},
stickyTabs: {
marginBottom: 0,
},
tabsContainer: {
marginBottom: 0,
},
tabsContent: {
paddingHorizontal: 16,
gap: 20,
alignItems: 'center',
},
tabsContentWithArrow: {
paddingRight: 60,
},
tab: {
paddingBottom: 4,
position: 'relative',
},
tabLabelWrapper: {
position: 'relative',
paddingBottom: 2,
alignSelf: 'flex-start',
justifyContent: 'flex-end',
},
tabText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
tabTextActive: {
opacity: 1,
fontWeight: '600',
},
tabUnderline: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 12,
},
tabArrowContainer: {
position: 'absolute',
right: 0,
top: 0,
zIndex: 10,
},
tabArrowGradient: {
paddingLeft: 30,
paddingRight: 16,
paddingVertical: 4,
},
tabArrow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 8,
justifyContent: 'space-between',
},
card: {
marginBottom: 12,
},
cardLeft: {
marginRight: 0,
},
cardRight: {
marginLeft: 0,
},
cardImageContainer: {
width: '100%',
borderRadius: 16,
overflow: 'hidden',
position: 'relative',
},
cardImage: {
width: '100%',
height: '100%',
},
cardImageGradient: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '33.33%',
},
hotBadge: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: '#191A1F80',
paddingHorizontal: 7,
paddingVertical: 4,
borderRadius: 100,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
},
hotEmoji: {
fontSize: 10,
},
hotText: {
color: '#F5F5F5',
fontSize: 11,
fontWeight: '500',
},
cardTitle: {
position: 'absolute',
bottom: 12,
left: 12,
fontSize: 14,
fontWeight: '500',
color: '#F5F5F5',
lineHeight: 20,
},
})

326
app/(tabs)/message.tsx Normal file
View File

@@ -0,0 +1,326 @@
import { useState, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
StatusBar as RNStatusBar,
Pressable,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
// 消息卡片数据
interface MessageCard {
id: number
title: string
subtitle: string
body: string
time: string
type: 'notice' | 'other'
isNew?: boolean // 是否为新消息
}
const messageCards: MessageCard[] = [
{
id: 1,
title: '恭喜你获得双12新用户专享福利',
subtitle: '打开推送的内容,限时优惠,多种玩法,快来体验~',
body: '图片占位。',
time: '2023-12-29 18:32:21',
type: 'notice',
isNew: true, // 新消息
},
{
id: 2,
title: '新功能上线AI 智能生成',
subtitle: '全新 AI 功能已上线,快来体验吧!',
body: '图片占位。',
time: '2023-12-28 15:20:10',
type: 'notice',
isNew: true, // 新消息
},
{
id: 3,
title: '系统维护通知',
subtitle: '系统将于今晚进行维护升级',
body: '图片占位。',
time: '2023-12-27 10:15:30',
type: 'other',
isNew: false, // 非新消息
},
{
id: 4,
title: '限时活动:分享有礼',
subtitle: '分享你的作品,赢取丰厚奖励',
body: '图片占位。',
time: '2023-12-26 14:05:22',
type: 'notice',
isNew: false, // 非新消息
},
{
id: 5,
title: '版本更新提醒',
subtitle: '新版本已发布,建议及时更新',
body: '图片占位。',
time: '2023-12-25 09:30:45',
type: 'other',
isNew: false, // 非新消息
},
]
export default function MessageScreen() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all')
// 根据选中的标签过滤卡片
const filteredCards = messageCards.filter((card) => {
if (activeTab === 'all') return true
return card.type === activeTab
})
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 固定在顶部的标签选择器 */}
<View style={styles.segment}>
<Pressable onPress={() => setActiveTab('all')}>
{activeTab === 'all' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text
style={[
styles.segmentText,
styles.segmentTextActiveText,
]}
>
{t('message.all')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.all')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('notice')}>
{activeTab === 'notice' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text
style={[
styles.segmentText,
styles.segmentTextActiveText,
]}
>
{t('message.notice')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.notice')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('other')}>
{activeTab === 'other' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text
style={[
styles.segmentText,
styles.segmentTextActiveText,
]}
>
{t('message.other')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.other')}</Text>
)}
</Pressable>
</View>
{/* 可滚动的消息卡片列表 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 消息卡片列表 */}
{filteredCards.length > 0 ? (
filteredCards.map((card) => (
<View key={card.id} style={styles.cardContainer}>
{/* 新消息绿色指示点 */}
{card.isNew && (
<View style={styles.newMessageDotContainer}>
<View style={styles.newMessageDot} />
</View>
)}
<Text style={styles.cardTitle}>
{card.title}
</Text>
<Text style={styles.cardSubtitle} numberOfLines={2}>
{card.subtitle}
</Text>
<View style={styles.cardBody}>
<Text style={styles.cardBodyText}>
{/* TODO:这里是图片 */}
{card.body}
</Text>
</View>
<Text style={styles.cardTime}>
{card.time}
</Text>
</View>
))
) : (
<View style={styles.emptyContainer}>
{/* <NoNewsIcon /> */}
💭
<Text style={styles.emptyText}>{t('message.noMessages')}</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
paddingHorizontal: 4,
paddingTop: 12,
},
appMiniIcon: {
width: 28,
height: 18,
borderRadius: 4,
backgroundColor: '#FF66AA',
},
segment: {
flexDirection: 'row',
paddingHorizontal: 8,
paddingTop: 19,
paddingBottom: 12,
backgroundColor: '#090A0B',
gap: 16,
},
segmentText: {
fontSize: 14,
color: '#FFFFFF',
},
segmentTextActiveWrapper: {
position: 'relative',
paddingBottom: 2,
justifyContent: 'flex-end',
alignSelf: 'flex-start',
},
segmentTextActiveBg: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 10,
backgroundColor: '#FF9966',
},
segmentTextActiveText: {
zIndex: 1,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#00FF66',
marginRight: 4,
},
cardContainer: {
backgroundColor: '#16181B',
borderRadius: 16,
padding: 16,
marginBottom: 12,
marginHorizontal: 4,
position: 'relative', // 用于定位新消息指示点
},
newMessageDotContainer: {
position: 'absolute',
top: -4,
right: -2,
width: 16,
height: 16,
borderWidth: 4,
borderColor: '#090A0B',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
newMessageDot: {
width: 6,
height: 6,
borderRadius: 4,
backgroundColor: '#00FF66', // 绿色
zIndex: 1,
},
cardTitle: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600',
marginBottom: 8,
},
cardSubtitle: {
color: '#ABABAB',
fontSize: 11,
marginBottom: 16,
},
cardBody: {
backgroundColor: '#26292E',
borderRadius: 12,
height:100,
marginBottom: 12,
},
cardBodyText: {
color: '#FFFFFF',
fontSize: 12,
opacity: 0.8,
},
cardTime: {
color: '#8A8A8A',
fontSize: 10,
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 200,
},
emptyText: {
color: '#8A8A8A',
fontSize: 12,
marginTop: 16,
},
})

440
app/(tabs)/my.tsx Normal file
View File

@@ -0,0 +1,440 @@
import { useState, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
import Dropdown from '@/components/ui/dropdown'
const { width: screenWidth } = Dimensions.get('window')
const GALLERY_GAP = 2
const GALLERY_HORIZONTAL_PADDING = 0
// 计算每个卡片的宽度:屏幕宽度 - 左右padding - 2个间距然后除以3使用 Math.floor 确保整数像素
const GALLERY_ITEM_SIZE = Math.floor(
(screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3
)
// status状态有running pending completed
const works = [
{ id: 1, status: 'running' as const, count: 1 },
{ id: 2, status: 'completed' as const, count: 1 },
{ id: 3, status: 'completed' as const, count: 1 },
{ id: 4, status: 'completed' as const, count: 2 },
{ id: 5, status: 'pending' as const, count: 1 },
{ id: 6, status: 'pending' as const, count: 1 },
{ id: 7, status: 'pending' as const, count: 1 },
{ id: 8, status: 'pending' as const, count: 1 },
{ id: 9, status: 'completed' as const, count: 1 },
{ id: 10, status: 'completed' as const, count: 1 },
{ id: 11, status: 'completed' as const, count: 1 },
{ id: 12, status: 'completed' as const, count: 1 },
{ id: 13, status: 'completed' as const, count: 1 },
{ id: 14, status: 'completed' as const, count: 1 },
{ id: 15, status: 'completed' as const, count: 1 },
{ id: 16, status: 'completed' as const, count: 1 },
{ id: 17, status: 'completed' as const, count: 1 },
]
export default function My() {
const router = useRouter()
const { t, i18n } = useTranslation()
const [editDrawerVisible, setEditDrawerVisible] = useState(false)
const [profileName, setProfileName] = useState('乔乔乔乔')
// 处理设置菜单选择
const handleSettingsSelect = (value: string) => {
if (value === 'changePassword') {
router.push('/changePassword' as any)
} else if (value === 'language') {
// 切换语言
const newLang = i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN'
i18n.changeLanguage(newLang)
}
}
// 设置菜单选项
const getLanguageLabel = () => {
if (i18n.language === 'zh-CN') {
return t('my.languageSwitch')
} else {
return t('my.languageSwitchEn')
}
}
const settingsOptions = [
{ label: t('my.changePassword'), value: 'changePassword' },
{ label: getLanguageLabel(), value: 'language' },
]
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部积分与设置 */}
<View style={styles.topBar}>
<Pressable
style={styles.pointsPill}
onPress={() => router.push('/membership' as any)}
>
<PointsIcon />
<Text style={styles.pointsPillText}>60</Text>
</Pressable>
<Dropdown
options={settingsOptions}
onSelect={(value) => handleSettingsSelect(value)}
renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable onPress={toggle}>
<SettingsIcon />
</Pressable>
)}
dropdownStyle={{
minWidth: 160,
right: 10,
backgroundColor: '#2A2A2A80',
}}
/>
</View>
{/* 个人信息区 */}
<View style={styles.profileSection}>
<Image
source={require('@/assets/images/icon.png')}
style={styles.avatar}
contentFit="cover"
/>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{profileName}</Text>
<Text style={styles.profileSubTitle}>ID 12345678</Text>
</View>
<Pressable
style={styles.editButton}
onPress={() => setEditDrawerVisible(true)}
>
<Text style={styles.editButtonText}>{t('my.editProfile')}</Text>
</Pressable>
</View>
{/* "生成作品" 标题行 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('my.generatedWorks')}</Text>
<Pressable
style={styles.sectionMoreButton}
onPress={() => router.push('/worksList' as any)}
>
<SearchIcon />
</Pressable>
</View>
{/* 作品九宫格 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.galleryGrid}>
{works.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
// 每行的前两个item有右边距第三个没有
index % 3 !== 2 && styles.galleryItemMarginRight,
// 所有item都有下边距最后一行也会有但影响不大
styles.galleryItemMarginBottom,
]}
onPress={() => {
// 只有已完成的作品才能点击进入详情页
if (item.status === 'completed') {
router.push({
pathname: '/generationRecord' as any,
params: { id: item.id.toString() },
})
}
}}
disabled={item.status !== 'completed'}
>
<Image
source={require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
{/* 生成中遮罩 */}
{item.status != 'completed' && (
<View style={styles.generatingOverlay} />
)}
{/* 右上角作品数量角标 */}
{item.status === 'completed'&&<View style={styles.counterBadge}>
<Text style={styles.counterText}>
{item.count}
</Text>
</View>}
{/* "生成中"角标:覆盖在图片左下角 */}
{item.status === 'running' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>
{t('my.generating')}
</Text>
</View>
)}
{item.status === 'pending' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>
{t('my.queuing')}
</Text>
</View>
)}
</Pressable>
))}
</View>
</ScrollView>
{/* 编辑资料抽屉 */}
<EditProfileDrawer
visible={editDrawerVisible}
onClose={() => setEditDrawerVisible(false)}
initialName={profileName}
onSave={(name) => setProfileName(name)}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
topBar: {
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
paddingTop: 19,
paddingRight: 16,
flexDirection: 'row',
justifyContent: 'flex-end',
backgroundColor: '#090A0B',
gap: 12,
},
pointsPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 100,
backgroundColor: '#1C1E22',
},
pointsPillText: {
color: '#FFCF00',
fontSize: 12,
fontWeight: '600',
},
header: {
backgroundColor: '#000000',
paddingTop: 8,
paddingBottom: 12,
},
statusBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
},
time: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
statusIcons: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
signalBars: {
width: 18,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
wifiIcon: {
width: 16,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
batteryIcon: {
width: 24,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
titleBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
},
appTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
pointsContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
pointsText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
searchButton: {
padding: 4,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
},
profileSection: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
marginTop: 20,
marginBottom: 32,
backgroundColor: '#090A0B',
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
marginRight: 16,
},
profileInfo: {
flex: 1,
},
profileName: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
profileSubTitle: {
color: '#FFFFFF',
fontSize: 12,
opacity: 0.7,
},
editButton: {
paddingHorizontal: 8, //左右各留 8 像素的内边距
paddingVertical: 6, //上下各留 6 像素的内边距
borderRadius: 6,
backgroundColor: '#1C1E22',
},
editButtonText: {
color: '#FFFFFF',
fontSize: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
marginHorizontal: 12,
backgroundColor: '#090A0B',
},
sectionTitle: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
sectionMoreButton: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
sectionMoreIcon: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FFFFFF33',
},
galleryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 10,
},
galleryItem: {
width: GALLERY_ITEM_SIZE,
// 使用等比例 1:1保证容器永远是正方形
aspectRatio: 1,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
galleryItemMarginRight: {
marginRight: GALLERY_GAP,
},
galleryItemMarginBottom: {
marginBottom: GALLERY_GAP,
},
galleryImage: {
width: '100%',
// 高度由 aspectRatio 决定,避免拉伸
height: undefined,
aspectRatio: 1,
},
generatingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#00000080',
},
counterBadge: {
position: 'absolute',
right: 8,
bottom: 8,
paddingHorizontal: 10,
paddingVertical: 1,
borderRadius: 6,
backgroundColor: '#16181B1A',
},
counterText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
generatingBadge: {
position: 'absolute',
left: 8,
bottom: 8,
},
generatingBadgeText: {
color: '#F5F5F5',
fontSize: 9,
fontWeight: '500',
},
})

275
app/(tabs)/video.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { useState, useRef } from 'react'
import {
View,
Text,
StyleSheet,
Dimensions,
FlatList,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Image, ImageLoadEventData } from 'expo-image'
import { useTranslation } from 'react-i18next'
import { SameStyleIcon, VideoIcon, WhiteStarIcon } from '@/components/icon'
import { useRouter } from 'expo-router'
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
const TAB_BAR_HEIGHT = 83 // 底部导航栏高度
// 视频数据
const videoData = [
{
id: 1,
videoUrl: require('@/assets/images/android-icon-background.png'),
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
title: '雅琳圣诞写真',
duration: '00:03',
},
{
id: 2,
videoUrl: require('@/assets/images/android-icon-background.png'),
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
title: '宠物写真',
duration: '00:05',
},
{
id: 3,
videoUrl: require('@/assets/images/android-icon-background.png'),
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
title: '我和小猫的人生合照',
duration: '00:04',
},
]
interface VideoItemProps {
item: typeof videoData[0]
index: number
videoHeight: number
}
function VideoItem({ item, videoHeight }: VideoItemProps) {
const { t } = useTranslation()
const router = useRouter()
const [isPlaying, setIsPlaying] = useState(false)
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
const handleImageLoad = (event: ImageLoadEventData) => {
const { source } = event
if (source?.width && source?.height) {
setImageSize({ width: source.width, height: source.height })
}
}
// 根据图片比例计算显示尺寸
const getImageStyle = () => {
if (!imageSize) {
return {
width: screenWidth,
height: videoHeight,
}
}
const imageAspectRatio = imageSize.width / imageSize.height
const screenAspectRatio = screenWidth / videoHeight
let displayWidth = screenWidth
let displayHeight = videoHeight
if (imageAspectRatio > screenAspectRatio) {
// 图片更宽,以宽度为准
displayHeight = screenWidth / imageAspectRatio
} else {
// 图片更高,以高度为准
displayWidth = videoHeight * imageAspectRatio
}
return {
width: displayWidth,
height: displayHeight,
}
}
return (
<View style={[styles.videoContainer, { height: videoHeight }]}>
{/* 主视频区域 */}
<View style={styles.videoWrapper}>
<Image
source={item.videoUrl}
style={[getImageStyle()]}
contentFit="contain"
onLoad={handleImageLoad}
/>
{/* 播放按钮 */}
{!isPlaying && (
<Pressable
style={styles.playButton}
onPress={() => setIsPlaying(true)}
>
<View style={styles.playIcon}>
<View style={styles.playTriangle} />
</View>
</Pressable>
)}
{/* 左下角原图缩略图 */}
<View style={styles.thumbnailContainer}>
<Image
source={item.thumbnailUrl}
style={styles.thumbnail}
contentFit="cover"
/>
</View>
</View>
{/* 左下角按钮 */}
<Pressable style={styles.actionButtonLeft}>
<WhiteStarIcon />
<Text style={styles.actionButtonTextTitle}>{item.title}</Text>
</Pressable>
{/* 右下角按钮 */}
<Pressable
style={styles.actionButtonRight}
onPress={() => {
router.push({
pathname: '/generateVideo' as any,
params: {
template: JSON.stringify(item),
},
})
}}
>
<SameStyleIcon />
<Text style={styles.actionButtonText}>{t('video.makeSame')}</Text>
</Pressable>
</View>
)
}
export default function VideoScreen() {
const flatListRef = useRef<FlatList>(null)
const insets = useSafeAreaInsets()
const videoHeight = screenHeight - TAB_BAR_HEIGHT
return (
<SafeAreaView style={styles.container} edges={[]}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<FlatList
ref={flatListRef}
data={videoData}
renderItem={({ item, index }) => (
<VideoItem item={item} index={index} videoHeight={videoHeight} />
)}
keyExtractor={(item) => item.id.toString()}
pagingEnabled
showsVerticalScrollIndicator={false}
snapToInterval={videoHeight}
snapToAlignment="start"
decelerationRate="fast"
getItemLayout={(_, index) => ({
length: videoHeight,
offset: videoHeight * index,
index,
})}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
videoContainer: {
width: screenWidth,
backgroundColor: '#090A0B',
position: 'relative',
},
videoWrapper: {
flex: 1,
position: 'relative',
backgroundColor: '#090A0B',
alignItems: 'center',
justifyContent: 'center',
},
playButton: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
playIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
alignItems: 'center',
justifyContent: 'center',
},
playTriangle: {
width: 0,
height: 0,
borderLeftWidth: 18,
borderTopWidth: 11,
borderBottomWidth: 11,
borderLeftColor: '#000000',
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
marginLeft: 3,
},
thumbnailContainer: {
position: 'absolute',
left: 12,
bottom: 80,
width: 56,
height: 56,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#FFFFFF',
backgroundColor: '#090A0B',
},
thumbnail: {
width: '100%',
height: '100%',
},
actionButtonLeft: {
position: 'absolute',
left: 12,
bottom: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 6,
paddingVertical: 8,
backgroundColor: '#191B1F',
borderRadius: 8,
borderWidth: 1,
borderColor: '#2F3134',
},
actionButtonRight: {
position: 'absolute',
right: 13,
bottom: 13,
flexDirection: 'column',
alignItems: 'center',
},
actionButtonTextTitle: {
color: '#F5F5F5',
fontSize: 11,
},
actionButtonText: {
color: '#CCCCCC',
fontSize: 10,
fontWeight: '500',
},
})