diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 0aecba8..c8afa5c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -5,7 +5,6 @@ import { StatusBar } from 'expo-status-bar' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { - Animated, Dimensions, Platform, Pressable, @@ -13,114 +12,95 @@ import { ScrollView, StyleSheet, Text, - View, + View } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { DownArrowIcon, PointsIcon, SearchIcon, WhiteStarIcon } from '@/components/icon' +import { HomeSkeleton } from '@/components/skeleton/HomeSkeleton' import { useActivates } from '@/hooks/use-activates' +import { useCategories } from '@/hooks/use-categories' const { width: screenWidth } = Dimensions.get('window') -// 卡片数据 - 根据 Figma 设计更新 -const cardData = [ - { - id: 1, - title: '宠物写真', - image: require('@/assets/images/android-icon-background.png'), - isHot: true, - users: 6349, - height: 214, - }, - { - id: 2, - title: '我和小猫的人生合照', - image: require('@/assets/images/android-icon-background.png'), - users: 6349, - height: 236, - }, - { - id: 3, - title: '猫:晚安~人', - image: require('@/assets/images/favicon.png'), - users: 6349, - height: 214, - }, - { - id: 4, - title: '穿越时空的相聚', - image: require('@/assets/images/icon.png'), - users: 6349, - height: 100, - }, - { - id: 5, - title: '睡衣版猫咪', - image: require('@/assets/images/android-icon-background.png'), - users: 6349, - height: 120, - }, - { - id: 6, - title: '猫咪写真', - image: require('@/assets/images/android-icon-background.png'), - users: 6349, - height: 214, - }, - { - id: 7, - title: '站姐视角写真', - image: require('@/assets/images/android-icon-background.png'), - users: 6349, - height: 214, - }, - { - id: 8, - title: '猫咪写真', - image: require('@/assets/images/android-icon-background.png'), - users: 6349, - height: 200, - }, -] - - export default function HomeScreen() { const { t } = useTranslation() const insets = useSafeAreaInsets() const router = useRouter() const [activeTab, setActiveTab] = useState(0) - - // 标签数据 - 根据 Figma 设计更新 - const tabs = [ - 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 [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 scrollY = useRef(new Animated.Value(0)).current + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories() const { load, data: activatesData, error } = useActivates() useEffect(() => { load() + loadCategories() }, []) useEffect(() => { console.log({ activatesData, error }) }, [activatesData, error]) + useEffect(() => { + console.log({ categoriesData, categoriesError }) + // 当分类数据加载完成后,默认选中第一个分类 + if (categoriesData?.categories && categoriesData.categories.length > 0 && !selectedCategoryId) { + setSelectedCategoryId(categoriesData.categories[0].id) + } + }, [categoriesData, categoriesError]) + + // 使用接口返回的分类数据,如果没有则使用默认翻译 + 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.map((template, idx) => ({ + id: template.id || `template-${idx}`, + title: template.title, + image: { uri: template.previewUrl || template.coverImageUrl || `` }, + isHot: false, + users: 0, + height: undefined, + previewUrl: template.previewUrl, + aspectRatio: template.aspectRatio, + tag: template.tag, + })) + : [] + 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 cardWidth = (gridWidth - horizontalPadding - cardGap) / 2 + // 判断是否显示加载状态 + const showLoading = categoriesLoading + + // 判断是否显示分类空状态 + const showEmptyState = !categoriesLoading && categoriesData?.categories && categoriesData.categories.length === 0 + + // 判断是否显示模板空状态(当前分类下没有模板) + const showEmptyTemplates = !categoriesLoading && !showEmptyState && displayCardData.length === 0 + // 渲染标签导航的函数 const renderTabs = (wrapperStyle?: any) => ( @@ -141,7 +121,15 @@ export default function HomeScreen() { {tabs.map((tab, index) => ( setActiveTab(index)} + onPress={() => { + setActiveTab(index) + // 设置选中的分类ID + if (categories.length > 0 && categories[index]) { + setSelectedCategoryId(categories[index].id) + } else { + setSelectedCategoryId(null) + } + }} style={styles.tab} > @@ -216,7 +204,7 @@ export default function HomeScreen() { {/* 吸顶的标签导航 - 适配 iOS 和 Android 的安全区域 */} - {tabsSticky && ( + {tabsSticky && !showLoading && !showEmptyState && ( - {/* 标签导航 */} - { - const { y, height } = event.nativeEvent.layout - tabsPositionRef.current = y - setTabsHeight(height) - }} - style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined} - > - {renderTabs()} - + {/* 标签导航 - 只要有分类数据就显示标签 */} + {!showLoading && !showEmptyState && ( + { + const { y, height } = event.nativeEvent.layout + tabsPositionRef.current = y + setTabsHeight(height) + }} + style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined} + > + {renderTabs()} + + )} + + {/* 加载状态 */} + {showLoading && } + + {/* 空状态 - 分类数据为空 */} + {showEmptyState && ( + + 暂无分类数据 + loadCategories()}> + 重新加载 + + + )} + + {/* 空状态 - 当前分类下没有模板 */} + {showEmptyTemplates && ( + + 该分类暂无模板 + loadCategories()}> + 刷新 + + + )} {/* 内容网格 */} - { - const { width } = event.nativeEvent.layout - setGridWidth(width) - }} - > - {cardData.map((card, index) => ( - { - router.push({ - pathname: '/templateDetail' as any, - params: { id: card.id.toString() }, - }) - }} - > - { + const { width } = event.nativeEvent.layout + setGridWidth(width) + }} + > + {displayCardData.map((card, index) => ( + { + router.push({ + pathname: '/templateDetail' as any, + params: { id: card.id.toString() }, + }) + }} > - - - {card.isHot ? ( - - 🔥 - {t('home.hotTemplate')} - - ) : ( - - - - {card.users}{t('home.peopleUsed')} - - - )} - - {card.title} - - - - ))} - + + + + {card.isHot ? ( + + 🔥 + {t('home.hotTemplate')} + + ) : ( + + + + {card.users}{t('home.peopleUsed')} + + + )} + + {card.title} + + + + ))} + + )} ) @@ -616,4 +631,26 @@ const styles = StyleSheet.create({ color: '#F5F5F5', lineHeight: 20, }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + gap: 16, + }, + emptyStateText: { + color: '#ABABAB', + fontSize: 14, + }, + retryButton: { + backgroundColor: '#FF6699', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + retryButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, }) diff --git a/app/(tabs)/video.tsx b/app/(tabs)/video.tsx index dbd8d63..eafbd34 100644 --- a/app/(tabs)/video.tsx +++ b/app/(tabs)/video.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect, useCallback, memo } from 'react' import { View, Text, @@ -7,176 +7,187 @@ import { FlatList, Pressable, StatusBar as RNStatusBar, + ActivityIndicator, + RefreshControl, } from 'react-native' import { StatusBar } from 'expo-status-bar' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { SafeAreaView } from 'react-native-safe-area-context' import { Image, ImageLoadEventData } from 'expo-image' import { useTranslation } from 'react-i18next' -import { SameStyleIcon, VideoIcon, WhiteStarIcon } from '@/components/icon' +import { SameStyleIcon, WhiteStarIcon } from '@/components/icon' import { useRouter } from 'expo-router' +import { useTemplates, type TemplateDetail } from '@/hooks' const { width: screenWidth, height: screenHeight } = Dimensions.get('window') -const TAB_BAR_HEIGHT = 83 // 底部导航栏高度 +const TAB_BAR_HEIGHT = 83 -// 视频数据 -const videoData = [ - { - id: 1, - videoUrl: require('@/assets/images/android-icon-background.png'), - thumbnailUrl: require('@/assets/images/android-icon-background.png'), - title: '雅琳圣诞写真', - duration: '00:03', - }, - { - id: 2, - videoUrl: require('@/assets/images/android-icon-background.png'), - thumbnailUrl: require('@/assets/images/android-icon-background.png'), - title: '宠物写真', - duration: '00:05', - }, - { - id: 3, - videoUrl: require('@/assets/images/android-icon-background.png'), - thumbnailUrl: require('@/assets/images/android-icon-background.png'), - title: '我和小猫的人生合照', - duration: '00:04', - }, -] +// 统一的布局组件 +const Layout = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + +) -interface VideoItemProps { - item: typeof videoData[0] - index: number +const LoadingState = () => ( + + + + 加载中... + + +) + +const ErrorState = () => ( + + + 加载失败,请下拉刷新重试 + + +) + +const FooterLoading = () => ( + + + +) + +// 计算图片显示尺寸 +const calculateImageSize = ( + imageSize: { width: number; height: number } | null, videoHeight: number +) => { + if (!imageSize) return { width: screenWidth, height: videoHeight } + + const imageAspectRatio = imageSize.width / imageSize.height + const screenAspectRatio = screenWidth / videoHeight + + return imageAspectRatio > screenAspectRatio + ? { width: screenWidth, height: screenWidth / imageAspectRatio } + : { width: videoHeight * imageAspectRatio, height: videoHeight } } -function VideoItem({ item, videoHeight }: VideoItemProps) { +const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; videoHeight: number }) => { const { t } = useTranslation() const router = useRouter() const [isPlaying, setIsPlaying] = useState(false) const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null) - const handleImageLoad = (event: ImageLoadEventData) => { + const handleImageLoad = useCallback((event: ImageLoadEventData) => { const { source } = event if (source?.width && source?.height) { setImageSize({ width: source.width, height: source.height }) } - } + }, []) - // 根据图片比例计算显示尺寸 - const getImageStyle = () => { - if (!imageSize) { - return { - width: screenWidth, - height: videoHeight, - } - } + const handlePress = useCallback(() => { + router.push({ + pathname: '/generateVideo' as any, + params: { template: JSON.stringify(item) }, + }) + }, [router, item]) - const imageAspectRatio = imageSize.width / imageSize.height - const screenAspectRatio = screenWidth / videoHeight - - let displayWidth = screenWidth - let displayHeight = videoHeight - - if (imageAspectRatio > screenAspectRatio) { - // 图片更宽,以宽度为准 - displayHeight = screenWidth / imageAspectRatio - } else { - // 图片更高,以高度为准 - displayWidth = videoHeight * imageAspectRatio - } - - return { - width: displayWidth, - height: displayHeight, - } - } + const imageStyle = calculateImageSize(imageSize, videoHeight) return ( - {/* 主视频区域 */} - {/* 播放按钮 */} {!isPlaying && ( - setIsPlaying(true)} - > + setIsPlaying(true)}> )} - - {/* 左下角原图缩略图 */} - + - - {/* 左下角按钮 */} {item.title} - - {/* 右下角按钮 */} - { - router.push({ - pathname: '/generateVideo' as any, - params: { - template: JSON.stringify(item), - }, - }) - }} - > + {t('video.makeSame')} ) -} +}) export default function VideoScreen() { const flatListRef = useRef(null) - const insets = useSafeAreaInsets() const videoHeight = screenHeight - TAB_BAR_HEIGHT + const { templates, loading, error, execute, refetch, loadMore, hasMore } = useTemplates({ + sortBy: 'likeCount', + sortOrder: 'desc', + }) + const [refreshing, setRefreshing] = useState(false) + + useEffect(() => { + execute() + }, [execute]) + + const handleRefresh = useCallback(async () => { + setRefreshing(true) + await refetch() + setRefreshing(false) + }, [refetch]) + + const handleLoadMore = useCallback(() => { + if (!hasMore || loading) return + loadMore() + }, [hasMore, loading, loadMore]) + + const renderItem = useCallback(({ item }: { item: TemplateDetail }) => ( + + ), [videoHeight]) + + const keyExtractor = useCallback((item: TemplateDetail) => item.id, []) + + const getItemLayout = useCallback((_: any, index: number) => ({ + length: videoHeight, + offset: videoHeight * index, + index, + }), [videoHeight]) + + if (loading && templates.length === 0) return + if (error && templates.length === 0) return return ( - - - + ( - - )} - keyExtractor={(item) => item.id.toString()} + data={templates} + renderItem={renderItem} + keyExtractor={keyExtractor} + getItemLayout={getItemLayout} pagingEnabled showsVerticalScrollIndicator={false} snapToInterval={videoHeight} snapToAlignment="start" decelerationRate="fast" - getItemLayout={(_, index) => ({ - length: videoHeight, - offset: videoHeight * index, - index, - })} + refreshControl={ + + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={loading ? : null} /> - + ) } @@ -185,24 +196,33 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#090A0B', }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + messageText: { + color: '#FFFFFF', + fontSize: 14, + textAlign: 'center', + }, + footerLoading: { + paddingVertical: 20, + alignItems: 'center', + }, videoContainer: { width: screenWidth, - backgroundColor: '#090A0B', position: 'relative', }, videoWrapper: { flex: 1, position: 'relative', - backgroundColor: '#090A0B', alignItems: 'center', justifyContent: 'center', }, playButton: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, + ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.3)', @@ -236,7 +256,6 @@ const styles = StyleSheet.create({ overflow: 'hidden', borderWidth: 2, borderColor: '#FFFFFF', - backgroundColor: '#090A0B', }, thumbnail: { width: '100%', diff --git a/app/searchResults.tsx b/app/searchResults.tsx index d289831..b8c6a05 100644 --- a/app/searchResults.tsx +++ b/app/searchResults.tsx @@ -10,63 +10,38 @@ import { useRouter, useLocalSearchParams } from 'expo-router' import SearchResultsGrid from '@/components/SearchResultsGrid' import SearchBar from '@/components/SearchBar' +import type { TemplateGeneration } from '@/hooks' + +const createMockResult = (id: string, height: number): TemplateGeneration & { height: number; title: string; image: any } => ({ + id, + userId: 'test', + templateId: `template-${id}`, + type: 'VIDEO', + resultUrl: [], + status: 'completed', + createdAt: new Date(), + updatedAt: new Date(), + title: '猫咪圣诞写真', + image: require('@/assets/images/android-icon-background.png'), + height, + template: { + id: `template-${id}`, + title: '猫咪圣诞写真', + titleEn: 'Cat Christmas', + coverImageUrl: '', + }, +}) -// 搜索结果数据 const searchResults = [ - { - id: 1, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 236, - }, - { - id: 2, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 131, - }, - { - id: 3, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 236, - }, - { - id: 4, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 236, - }, - { - id: 5, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 95, - }, - { - id: 6, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 236, - }, - { - id: 7, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 228, - }, - { - id: 8, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 95, - }, - { - id: 9, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 228, - }, + createMockResult('1', 236), + createMockResult('2', 131), + createMockResult('3', 236), + createMockResult('4', 236), + createMockResult('5', 95), + createMockResult('6', 236), + createMockResult('7', 228), + createMockResult('8', 95), + createMockResult('9', 228), ] export default function SearchResultsScreen() { @@ -79,13 +54,10 @@ export default function SearchResultsScreen() { - {/* Top Bar with Search */} { - // 搜索结果页面的搜索按钮可以保持当前页面或执行搜索 - }} + onSearch={(text) => {}} onBack={() => router.back()} readOnly={true} onInputPress={() => { @@ -103,7 +75,6 @@ export default function SearchResultsScreen() { marginBottom={12} /> - {/* 搜索结果网格 */} ) @@ -115,4 +86,3 @@ const styles = StyleSheet.create({ backgroundColor: '#090A0B', }, }) - diff --git a/components/SearchResultsGrid.tsx b/components/SearchResultsGrid.tsx index 6df04d7..693bf0d 100644 --- a/components/SearchResultsGrid.tsx +++ b/components/SearchResultsGrid.tsx @@ -59,7 +59,6 @@ export default function SearchResultsGrid({ results }: SearchResultsGridProps) { return ( { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const runTemplate = async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => { + setLoading(true) + setError(null) + + try { + const template = root.get(TemplateController) + const result = await template.run({ + templateId: params.templateId, + data: params.data || {}, + }) + setLoading(false) + return { generationId: result.generationId } + } catch (e) { + setError(e as ApiError) + setLoading(false) + return { error: e as ApiError } + } + } + + return { + loading, + error, + runTemplate, + } +} diff --git a/hooks/use-templates.ts b/hooks/use-templates.ts index 2619621..5987e30 100644 --- a/hooks/use-templates.ts +++ b/hooks/use-templates.ts @@ -4,9 +4,9 @@ import { useCallback, useRef, useState } from 'react' import { type ApiError } from '@/lib/types' +import { OWNER_ID } from '@/lib/auth' import { handleError } from './use-error' -const OWNER_ID = process.env.EXPO_PUBLIC_OWNER_ID || '' type ListTemplatesParams = Omit @@ -30,14 +30,15 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => { currentPageRef.current = params?.page || 1 const template = root.get(TemplateController) + const requestParams: ListTemplatesInput = { + ...DEFAULT_PARAMS, + ...initialParams, + ...params, + ownerId: OWNER_ID, + } + const { data, error } = await handleError( - async () => - await template.list({ - ...DEFAULT_PARAMS, - ...initialParams, - ...params, - ownerId: OWNER_ID, - }), + async () => await template.list(requestParams), ) if (error) { @@ -60,14 +61,15 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => { const nextPage = currentPageRef.current + 1 const template = root.get(TemplateController) + const requestParams: ListTemplatesInput = { + ...DEFAULT_PARAMS, + ...initialParams, + page: nextPage, + ownerId: OWNER_ID, + } + const { data: newData, error } = await handleError( - async () => - await template.list({ - ...DEFAULT_PARAMS, - ...initialParams, - page: nextPage, - ownerId: OWNER_ID, - }), + async () => await template.list(requestParams), ) if (error) { diff --git a/tsconfig.json b/tsconfig.json index ab947d2..91cc957 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,6 @@ "@/*": ["./*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"], + "exclude": ["node_modules", "babel.config.js", "metro.config.js", "jest.config.js", "android", "ios"] }