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

364 lines
12 KiB
TypeScript

import { useLocalSearchParams, useRouter } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import React, { useEffect, useCallback, useState, useMemo } from 'react'
import {
Platform,
FlatList,
StyleSheet,
StatusBar as RNStatusBar,
View,
ActivityIndicator,
RefreshControl,
Dimensions,
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { TitleBar, HeroSlider, TabNavigation, TemplateCard } from '@/components/blocks/home'
import ErrorState from '@/components/ErrorState'
import LoadingState from '@/components/LoadingState'
import { useActivates } from '@/hooks/use-activates'
import { useCategoriesWithTags } from '@/hooks/use-categories-with-tags'
import { useCategoryTemplates } from '@/hooks/use-category-templates'
import { useStickyTabs } from '@/hooks/use-sticky-tabs'
import { useTabNavigation } from '@/hooks/use-tab-navigation'
import { useTemplateFilter } from '@/hooks/use-template-filter'
import { useUserBalance } from '@/hooks/use-user-balance'
const NUM_COLUMNS = 3
const HORIZONTAL_PADDING = 16
const CARD_GAP = 5
const SCREEN_WIDTH = Dimensions.get('window').width
// 计算卡片宽度
const calculateCardWidth = () => {
return (SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - CARD_GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
}
const CARD_WIDTH = calculateCardWidth()
export default function HomeScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const params = useLocalSearchParams()
// 获取积分余额
const { balance } = useUserBalance()
// 分类数据加载
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategoriesWithTags()
const { load: loadActivates, data: activatesData } = useActivates()
// 模板数据加载(分页)
const {
templates,
loading: templatesLoading,
loadingMore,
execute: loadTemplates,
loadMore,
hasMore,
} = useCategoryTemplates()
// 标签导航状态
const {
activeIndex,
selectedCategoryId,
tabs,
selectTab,
selectCategoryById,
} = useTabNavigation({
categories: categoriesData?.categories || [],
initialCategoryId: params.categoryId as string | undefined,
})
// 吸顶状态
const {
isSticky,
tabsHeight,
titleBarHeightRef,
handleScroll,
handleTabsLayout,
handleTitleBarLayout,
} = useStickyTabs()
// 模板过滤 - 使用 useMemo 缓存
const { filteredTemplates } = useTemplateFilter({
templates,
excludeVideo: true,
})
// 初始化加载
useEffect(() => {
loadCategories()
loadActivates()
}, [])
// 当分类变化时加载模板
useEffect(() => {
if (selectedCategoryId) {
loadTemplates({ categoryId: selectedCategoryId })
}
}, [selectedCategoryId])
// 路由参数同步
useEffect(() => {
if (params.categoryId) {
selectCategoryById(params.categoryId as string)
}
}, [params.categoryId])
// 状态判断 - 使用 useMemo 缓存
const showLoading = categoriesLoading
const showEmptyState = useMemo(() =>
!categoriesLoading && categoriesData?.categories?.length === 0,
[categoriesLoading, categoriesData?.categories?.length]
)
const showEmptyTemplates = useMemo(() =>
!categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0,
[categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length]
)
const [refreshing, setRefreshing] = useState(false)
// 下拉刷新处理 - 只刷新当前分类的模板
const handleRefresh = useCallback(async () => {
if (!selectedCategoryId) return
setRefreshing(true)
await loadTemplates({ categoryId: selectedCategoryId })
setRefreshing(false)
}, [selectedCategoryId, loadTemplates])
// 加载更多处理
const handleEndReached = useCallback(() => {
if (!loadingMore && hasMore && !templatesLoading) {
loadMore()
}
}, [loadingMore, hasMore, templatesLoading, loadMore])
// 导航处理 - 使用 useCallback memoize
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
const handleSearchPress = useCallback(() => router.push('/searchTemplate'), [router])
const handleActivityPress = useCallback((link: string) => router.push(link as any), [router])
const handleArrowPress = useCallback(() => router.push({
pathname: '/channels' as any,
params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
}), [router, selectedCategoryId])
const handleTemplatePress = useCallback((id: string) => router.push({
pathname: '/templateDetail' as any,
params: { id },
}), [router])
// 渲染模板卡片
const renderTemplateItem = useCallback(({ item }: { item: typeof filteredTemplates[0] }) => {
if (!item.id) return null
return (
<TemplateCard
id={item.id}
title={item.title}
previewUrl={item.previewUrl}
webpPreviewUrl={item.webpPreviewUrl}
coverImageUrl={item.coverImageUrl}
aspectRatio={item.aspectRatio}
cardWidth={CARD_WIDTH}
onPress={handleTemplatePress}
/>
)
}, [handleTemplatePress])
// 提取 key
const keyExtractor = useCallback((item: typeof filteredTemplates[0]) => item.id || '', [])
// 列表头部组件
const ListHeaderComponent = useMemo(() => (
<>
{/* 活动轮播图 */}
<HeroSlider
activities={activatesData?.activities || []}
onActivityPress={handleActivityPress}
/>
{/* 标签导航 */}
{!showLoading && !showEmptyState && (
<View
onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
style={isSticky ? { opacity: 0, height: tabsHeight } : undefined}
>
<TabNavigation
tabs={tabs}
activeIndex={activeIndex}
onTabPress={selectTab}
showArrow={true}
onArrowPress={handleArrowPress}
/>
</View>
)}
{/* 加载状态 */}
{showLoading && <LoadingState />}
{/* 错误状态 */}
{categoriesError && (
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} variant="error" />
)}
{/* 空状态 - 分类数据为空 */}
{showEmptyState && !categoriesError && (
<ErrorState message="暂无分类数据" onRetry={() => loadCategories()} />
)}
{/* 空状态 - 当前分类下没有模板 */}
{showEmptyTemplates && (
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
)}
{/* 模板加载中 */}
{templatesLoading && !showLoading && (
<View style={styles.templatesLoadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</>
), [
activatesData?.activities,
handleActivityPress,
showLoading,
showEmptyState,
isSticky,
tabsHeight,
tabs,
activeIndex,
selectTab,
handleArrowPress,
categoriesError,
showEmptyTemplates,
templatesLoading,
selectedCategoryId,
handleTabsLayout,
loadCategories,
loadTemplates,
])
// 列表底部组件
const ListFooterComponent = useMemo(() => {
if (loadingMore) {
return (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
)
}
return null
}, [loadingMore])
// 获取有效的模板列表(过滤掉没有 id 的)
const validTemplates = useMemo(() =>
filteredTemplates.filter(t => !!t.id),
[filteredTemplates]
)
// 只有在非加载状态且有数据时才显示列表
const showTemplateList = !showLoading && !showEmptyState && !showEmptyTemplates && !templatesLoading
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 标题栏 */}
<TitleBar
points={balance}
onPointsPress={handlePointsPress}
onSearchPress={handleSearchPress}
onLayout={handleTitleBarLayout}
/>
{/* 吸顶标签导航 */}
{isSticky && !showLoading && !showEmptyState && (
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
<TabNavigation
tabs={tabs}
activeIndex={activeIndex}
onTabPress={selectTab}
showArrow={true}
onArrowPress={handleArrowPress}
isSticky={true}
/>
</View>
)}
<FlatList
data={showTemplateList ? validTemplates : []}
keyExtractor={keyExtractor}
renderItem={renderTemplateItem}
numColumns={NUM_COLUMNS}
columnWrapperStyle={styles.columnWrapper}
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: 60 + insets.bottom + 20 }]}
showsVerticalScrollIndicator={false}
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
removeClippedSubviews={Platform.OS === 'android'}
maxToRenderPerBatch={12}
windowSize={5}
initialNumToRender={12}
getItemLayout={(_, index) => ({
length: CARD_WIDTH * 1.5,
offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS),
index,
})}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#9966FF"
colors={['#9966FF', '#FF6699', '#FF9966']}
progressBackgroundColor="#1C1E22"
progressViewOffset={10}
/>
}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
paddingHorizontal: HORIZONTAL_PADDING,
paddingBottom: 20,
backgroundColor: '#090A0B',
},
columnWrapper: {
gap: CARD_GAP,
marginBottom: CARD_GAP,
},
stickyTabsWrapper: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 100,
backgroundColor: '#090A0B',
},
templatesLoadingContainer: {
paddingVertical: 40,
alignItems: 'center',
justifyContent: 'center',
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
})