expo-popcore-app/app/(tabs)/index.tsx

670 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FlashList } from '@shopify/flash-list';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { memo as ReactMemo, useEffect, useRef, useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
Platform,
Pressable,
StatusBar as RNStatusBar,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { DownArrowIcon, PointsIcon, SearchIcon } from '@/components/icon';
import ErrorState from '@/components/ErrorState';
import LoadingState from '@/components/LoadingState';
import RefreshControl from '@/components/RefreshControl';
import { useActivates } from '@/hooks/use-activates';
import { useCategories } from '@/hooks/use-categories';
import { CategoryTemplate } from '@repo/sdk';
const { width: screenWidth } = Dimensions.get('window')
// 卡片组件 - 使用 useCallback 缓存以优化 FlashList 性能
const Card = ReactMemo(({ card, cardWidth, t, onPress }: {
card: CategoryTemplate & { webpPreviewUrl?: string }
cardWidth: number
t: any
onPress: (id: string) => void
}) => {
// 解析 aspectRatio 字符串为数字(如 "128:128" -> 1
const aspectRatio = card.aspectRatio?.includes(':')
? (() => {
const [w, h] = card.aspectRatio.split(':').map(Number)
return w / h
})()
: card.aspectRatio
return (
<Pressable
style={[styles.card, { width: cardWidth }]}
onPress={() => onPress(card.id!)}
>
<View
style={[
styles.cardImageContainer,
{ aspectRatio },
]}
>
<Image
source={{ uri: card.webpPreviewUrl || card.previewUrl }}
style={[
styles.cardImage,
{ aspectRatio },
]}
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}
/>
<Text style={styles.cardTitle} numberOfLines={1}>
{card.title}
</Text>
</View>
</Pressable>
)
})
export default function HomeScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const params = useLocalSearchParams()
const [activeTab, setActiveTab] = useState(0)
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories()
const { load, data: activatesData, error } = useActivates()
useEffect(() => {
load()
loadCategories()
}, [])
const handleRefresh = async () => {
setRefreshing(true)
await Promise.all([load(), loadCategories()])
setRefreshing(false)
}
useEffect(() => {
// 当分类数据加载完成后,默认选中第一个分类
if (categoriesData?.categories && categoriesData.categories.length > 0 && !selectedCategoryId) {
setSelectedCategoryId(categoriesData.categories[0].id)
}
}, [categoriesData, categoriesError])
// 监听从 channels 页面传递过来的 categoryId 参数
useEffect(() => {
const categoryIdFromParams = params.categoryId as string | undefined
if (categoryIdFromParams && categoriesData?.categories) {
setSelectedCategoryId(categoryIdFromParams)
// 同时更新 activeTab 索引
const categoryIndex = categoriesData.categories.findIndex(cat => cat.id === categoryIdFromParams)
if (categoryIndex !== -1) {
setActiveTab(categoryIndex)
}
}
}, [params.categoryId, categoriesData])
// 使用接口返回的分类数据,如果没有则使用默认翻译
const categories = categoriesData?.categories || []
const tabs = categories.length > 0
? categories.map(cat => cat.name)
: [
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 currentCategory = categories.find(cat => cat.id === selectedCategoryId)
const categoryTemplates = currentCategory?.templates || []
// 将 CategoryTemplate 数据转换为卡片数据格式
const displayCardData = categoryTemplates.length > 0
? categoryTemplates
.filter((template) => {
// 过滤掉视频类型的模板,只显示图片
const previewUrl = (template as any).webpPreviewUrl || template.previewUrl || template.coverImageUrl || ``
const isVideo = previewUrl.includes('.mp4') || previewUrl.includes('.mov') || previewUrl.includes('.webm')
return !isVideo
})
: []
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 horizontalPadding = 8 * 2 // gridContainer 的左右 padding
const cardGap = 5 // 两个卡片之间的间距
const numColumns = 3 // 每行显示3个卡片
const cardWidth = (gridWidth - horizontalPadding - cardGap * (numColumns - 1)) / numColumns
// 判断是否显示加载状态
const showLoading = categoriesLoading
// 判断是否显示分类空状态
const showEmptyState = !categoriesLoading && categoriesData?.categories && categoriesData.categories.length === 0
// 判断是否显示模板空状态(当前分类下没有模板)
const showEmptyTemplates = !categoriesLoading && !showEmptyState && displayCardData.length === 0
// 渲染标签导航的函数
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)
// 设置选中的分类ID
if (categories.length > 0 && categories[index]) {
setSelectedCategoryId(categories[index].id)
} else {
setSelectedCategoryId(null)
}
}}
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({
pathname: '/channels' as any,
params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
})}
>
<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 && !showLoading && !showEmptyState && (
<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}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
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>
{/* 标签导航 - 只要有分类数据就显示标签 */}
{!showLoading && !showEmptyState && (
<View
onLayout={(event) => {
const { y, height } = event.nativeEvent.layout
tabsPositionRef.current = y
setTabsHeight(height)
}}
style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined}
>
{renderTabs()}
</View>
)}
{/* 加载状态 */}
{showLoading && <LoadingState />}
{/* 错误状态 - 分类加载失败 */}
{categoriesError && (
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} />
)}
{/* 空状态 - 分类数据为空 */}
{showEmptyState && !categoriesError && (
<ErrorState message="暂无分类数据" onRetry={() => loadCategories()} />
)}
{/* 空状态 - 当前分类下没有模板 */}
{showEmptyTemplates && (
<ErrorState message="该分类暂无模板" onRetry={() => loadCategories()} />
)}
{/* 内容网格 - 使用 FlashList 优化性能 */}
{!showLoading && !showEmptyState && !showEmptyTemplates && (
<View
style={styles.gridContainer}
onLayout={(event) => {
const { width } = event.nativeEvent.layout
setGridWidth(width)
}}
>
<FlashList
data={displayCardData}
renderItem={({ item, index }) => (
<Card
card={item}
cardWidth={cardWidth}
t={t}
onPress={(id) => {
router.push({
pathname: '/templateDetail' as any,
params: { id: id.toString() },
})
}}
/>
)}
keyExtractor={(item) => item.id!}
numColumns={numColumns}
showsVerticalScrollIndicator={false}
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
/>
</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,
paddingHorizontal: 5,
},
flashListContent: {
gap: 10,
},
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,
},
})