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

456 lines
16 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 { 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, TemplateGrid } 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'
import { useTemplateLike, useTemplateFavorite } from '@/hooks'
import { useTemplateSocialStore } from '@/stores/templateSocialStore'
import { root } from '@repo/core'
import { TemplateSocialController } from '@repo/sdk'
import { handleError } from '@/hooks/use-error'
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()
// 使用 Store 中的点赞/收藏状态
const { setLiked, setFavorited, incrementLikeCount, decrementLikeCount, setLikeCountStates } = useTemplateSocialStore()
// 模板过滤 - 使用 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])
// 同步模板的点赞数量到 store用于初始化 store 中的数据)
useEffect(() => {
if (filteredTemplates.length > 0) {
const likeCountMap: Record<string, number> = {}
filteredTemplates.forEach(template => {
if (template.id && 'likeCount' in template && template.likeCount !== undefined) {
likeCountMap[template.id] = template.likeCount
}
})
if (Object.keys(likeCountMap).length > 0) {
setLikeCountStates(likeCountMap)
}
}
}, [filteredTemplates, setLikeCountStates])
// 状态判断 - 使用 useMemo 缓存
// 统一 loading 状态:只要有任何一个在加载,就显示 loading
const isLoading = useMemo(() =>
categoriesLoading || templatesLoading,
[categoriesLoading, templatesLoading]
)
const showEmptyState = useMemo(() =>
!isLoading && categoriesData?.categories?.length === 0,
[isLoading, categoriesData?.categories?.length]
)
const showEmptyTemplates = useMemo(() =>
!isLoading && !showEmptyState && filteredTemplates.length === 0,
[isLoading, 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 && !isLoading) {
loadMore()
}
}, [loadingMore, hasMore, isLoading, 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])
// 获取 social controller
const getSocialController = useCallback(() => {
return root.get(TemplateSocialController)
}, [])
// 点赞处理
const handleLike = useCallback(async (id: string) => {
setLiked(id, true) // 乐观更新状态
incrementLikeCount(id) // 乐观更新数量
try {
const social = getSocialController()
await handleError(() => social.like({ templateId: id }))
console.log('Liked template:', id)
} catch (e) {
// 失败时回滚状态
setLiked(id, false)
decrementLikeCount(id)
console.error('Like failed:', e)
}
}, [setLiked, incrementLikeCount, decrementLikeCount, getSocialController])
// 取消点赞处理
const handleUnlike = useCallback(async (id: string) => {
setLiked(id, false) // 乐观更新状态
decrementLikeCount(id) // 乐观更新数量
try {
const social = getSocialController()
await handleError(() => social.unlike({ templateId: id }))
console.log('Unliked template:', id)
} catch (e) {
// 失败时回滚状态
setLiked(id, true)
incrementLikeCount(id)
console.error('Unlike failed:', e)
}
}, [setLiked, incrementLikeCount, decrementLikeCount, getSocialController])
// 收藏处理
const handleFavorite = useCallback(async (id: string) => {
setFavorited(id, true) // 乐观更新
try {
const social = getSocialController()
await handleError(() => social.favorite({ templateId: id }))
console.log('Favorited template:', id)
} catch (e) {
// 失败时回滚状态
setFavorited(id, false)
console.error('Favorite failed:', e)
}
}, [setFavorited, getSocialController])
// 取消收藏处理
const handleUnfavorite = useCallback(async (id: string) => {
setFavorited(id, false) // 乐观更新
try {
const social = getSocialController()
await handleError(() => social.unfavorite({ templateId: id }))
console.log('Unfavorited template:', id)
} catch (e) {
// 失败时回滚状态
setFavorited(id, true)
console.error('Unfavorite failed:', e)
}
}, [setFavorited, getSocialController])
// 渲染模板卡片
const renderTemplateItem = useCallback(({ item }: { item: typeof filteredTemplates[0] }) => {
if (!item.id) return null
return (
<TemplateCard
id={item.id}
title={item.title}
titleEn={item.titleEn}
previewUrl={item.previewUrl}
webpPreviewUrl={item.webpPreviewUrl}
coverImageUrl={item.coverImageUrl}
aspectRatio={item.aspectRatio}
cardWidth={CARD_WIDTH}
onPress={handleTemplatePress}
liked={'isLiked' in item ? item.isLiked : undefined}
favorited={'isFavorited' in item ? item.isFavorited : undefined}
likeCount={'likeCount' in item ? item.likeCount : undefined}
onLike={handleLike}
onUnlike={handleUnlike}
onFavorite={handleFavorite}
onUnfavorite={handleUnfavorite}
/>
)
}, [handleTemplatePress, handleLike, handleUnlike, handleFavorite, handleUnfavorite])
// 提取 key
const keyExtractor = useCallback((item: typeof filteredTemplates[0]) => item.id || '', [])
// 列表头部组件
const ListHeaderComponent = useMemo(() => (
<>
{/* 活动轮播图 */}
<HeroSlider
activities={activatesData?.activities || []}
onActivityPress={handleActivityPress}
/>
{/* 标签导航 */}
{!isLoading && !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>
)}
{/* 统一的加载状态 */}
{isLoading && <LoadingState />}
{/* 错误状态 */}
{categoriesError && (
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} variant="error" />
)}
{/* 空状态 - 分类数据为空 */}
{showEmptyState && !categoriesError && (
<ErrorState message="暂无分类数据" onRetry={() => loadCategories()} />
)}
{/* 空状态 - 当前分类下没有模板 */}
{showEmptyTemplates && (
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
)}
</>
), [
activatesData?.activities,
handleActivityPress,
isLoading,
showEmptyState,
isSticky,
tabsHeight,
tabs,
activeIndex,
selectTab,
handleArrowPress,
categoriesError,
showEmptyTemplates,
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 = !isLoading && !showEmptyState && !showEmptyTemplates
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 标题栏 */}
<TitleBar
points={balance}
onPointsPress={handlePointsPress}
onSearchPress={handleSearchPress}
onLayout={handleTitleBarLayout}
/>
{/* 吸顶标签导航 */}
{isSticky && !isLoading && !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',
},
})