expo-popcore-app/app/(tabs)/index.old.md

21 KiB
Raw Blame History

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, }, })