Initial commit: expo-popcore-app
This commit is contained in:
137
app/(tabs)/_layout.tsx
Normal file
137
app/(tabs)/_layout.tsx
Normal 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
624
app/(tabs)/index.tsx
Normal 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
326
app/(tabs)/message.tsx
Normal 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
440
app/(tabs)/my.tsx
Normal 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
275
app/(tabs)/video.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user