feat: add TemplateGrid and TitleBar components with tests

- Implemented TemplateGrid component for displaying templates in a grid layout.
- Added calculateCardWidth helper function for dynamic card sizing.
- Created TitleBar component for displaying the title and points with interaction.
- Added unit tests for TemplateGrid and TitleBar components to ensure proper functionality.
- Introduced useStickyTabs and useTabNavigation hooks with tests for managing sticky tab behavior and navigation logic.
- Implemented useTemplateFilter hook for filtering templates based on video content.
- Added comprehensive tests for all new hooks and components to validate behavior and edge cases.
This commit is contained in:
imeepos
2026-01-26 12:43:20 +08:00
parent bf11241d68
commit 2757b68756
23 changed files with 2637 additions and 586 deletions

657
app/(tabs)/index.old.md Normal file
View File

@@ -0,0 +1,657 @@
import { FlatList } from 'react-native';
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 { 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 { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories()
const { load, data: activatesData, error } = useActivates()
useEffect(() => {
load()
loadCategories()
}, [])
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}
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)
}}
>
<FlatList
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}
scrollEnabled={false}
/>
</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: {
flexGrow: 1,
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: {
flex: 1,
paddingHorizontal: 8,
},
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,
},
})