diff --git a/app/worksList.test.tsx b/app/worksList.test.tsx new file mode 100644 index 0000000..ac6e28a --- /dev/null +++ b/app/worksList.test.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react-native' +import WorksListScreen from './worksList' + +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})) + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'worksList.title': 'My Works', + 'worksList.all': '全部', + 'worksList.pets': '萌宠', + 'worksList.portrait': '写真', + 'worksList.together': '合拍', + } + return translations[key] || key + }, + }), +})) + +jest.mock('@/hooks/useWorksList', () => ({ + useWorksList: () => ({ + works: [ + { id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' }, + { id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' }, + ], + loading: false, + error: null, + refreshing: false, + hasMore: true, + loadMore: jest.fn(), + refresh: jest.fn(), + }), +})) + +describe('WorksListScreen', () => { + it('should render loading state initially', () => { + const mockUseWorksList = require('@/hooks/useWorksList').useWorksList + mockUseWorksList.mockReturnValueOnce({ + works: [], + loading: true, + error: null, + refreshing: false, + hasMore: false, + loadMore: jest.fn(), + refresh: jest.fn(), + }) + + const { getByTestId } = render() + expect(getByTestId('loading-state')).toBeTruthy() + }) + + it('should render error state when error occurs', () => { + const mockUseWorksList = require('@/hooks/useWorksList').useWorksList + mockUseWorksList.mockReturnValueOnce({ + works: [], + loading: false, + error: 'Failed to load', + refreshing: false, + hasMore: false, + loadMore: jest.fn(), + refresh: jest.fn(), + }) + + const { getByTestId } = render() + expect(getByTestId('error-state')).toBeTruthy() + }) + + it('should render works gallery when data is loaded', async () => { + const { getByText } = render() + + await waitFor(() => { + expect(getByText('My Works')).toBeTruthy() + }) + }) +}) diff --git a/app/worksList.tsx b/app/worksList.tsx index ae5d904..f3d926a 100644 --- a/app/worksList.tsx +++ b/app/worksList.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { View, Text, @@ -13,47 +13,47 @@ import { useTranslation } from 'react-i18next' import { LeftArrowIcon, SearchIcon } from '@/components/icon' import WorksGallery, { type Category, type WorkItem } from '@/components/WorksGallery' - -const worksData: WorkItem[] = [ - { id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' }, - { id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' }, - { id: 3, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' }, - { id: 4, date: new Date(2025, 10, 27), duration: '00:05', category: '合拍' }, - { id: 5, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' }, - { id: 6, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' }, - { id: 7, date: new Date(2025, 10, 26), duration: '00:05', category: '合拍' }, - { id: 8, date: new Date(2025, 10, 26), duration: '00:05', category: '写真' }, -] +import RefreshControl from '@/components/RefreshControl' +import LoadingState from '@/components/LoadingState' +import ErrorState from '@/components/ErrorState' +import PaginationLoader from '@/components/PaginationLoader' +import { useWorksList } from '@/hooks/useWorksList' export default function WorksListScreen() { const { t } = useTranslation() const router = useRouter() - + const { works, loading, error, refreshing, hasMore, loadMore, refresh } = useWorksList() + const categories: Category[] = [ t('worksList.all') as Category, t('worksList.pets') as Category, t('worksList.portrait') as Category, t('worksList.together') as Category, ] - + const [selectedCategory, setSelectedCategory] = useState(categories[0]) - const filteredWorks = selectedCategory === categories[0] - ? worksData - : worksData.filter(work => work.category === selectedCategory) + const filteredWorks = useMemo(() => + selectedCategory === categories[0] + ? works + : works.filter(work => work.category === selectedCategory), + [selectedCategory, works, categories] + ) - const filteredGroupedWorks = filteredWorks.reduce((acc, work) => { - // 使用日期作为分组键,格式化为 YYYY-MM-DD 以便正确分组 - const dateKey = work.date instanceof Date - ? work.date.toISOString().split('T')[0] - : new Date(work.date).toISOString().split('T')[0] - - if (!acc[dateKey]) { - acc[dateKey] = [] - } - acc[dateKey].push(work) - return acc - }, {} as Record) + const filteredGroupedWorks = useMemo(() => + filteredWorks.reduce((acc, work) => { + const dateKey = work.date instanceof Date + ? work.date.toISOString().split('T')[0] + : new Date(work.date).toISOString().split('T')[0] + + if (!acc[dateKey]) { + acc[dateKey] = [] + } + acc[dateKey].push(work) + return acc + }, {} as Record), + [filteredWorks] + ) return ( @@ -79,18 +79,31 @@ export default function WorksListScreen() { - { - router.push({ - pathname: '/generationRecord' as any, - params: { id: id.toString() }, - }) - }} - /> + {loading ? ( + + ) : error ? ( + + ) : ( + { + router.push({ + pathname: '/generationRecord' as any, + params: { id: id.toString() }, + }) + }} + refreshControl={ + + } + onEndReached={loadMore} + ListFooterComponent={ + hasMore ? : undefined + } + /> + )} ) } diff --git a/components/WorksGallery.tsx b/components/WorksGallery.tsx index 4e2e54d..cc5b72b 100644 --- a/components/WorksGallery.tsx +++ b/components/WorksGallery.tsx @@ -32,6 +32,9 @@ interface WorksGalleryProps { onCategoryChange: (category: Category) => void groupedWorks: Record onWorkPress: (id: number) => void + refreshControl?: React.ReactElement + onEndReached?: () => void + ListFooterComponent?: React.ReactElement } export default function WorksGallery({ @@ -40,6 +43,9 @@ export default function WorksGallery({ onCategoryChange, groupedWorks, onWorkPress, + refreshControl, + onEndReached, + ListFooterComponent, }: WorksGalleryProps) { const { i18n } = useTranslation() @@ -112,6 +118,15 @@ export default function WorksGallery({ style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} + refreshControl={refreshControl} + onScroll={(e) => { + const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent + const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 100 + if (isCloseToBottom && onEndReached) { + onEndReached() + } + }} + scrollEventThrottle={400} > {Object.entries(groupedWorks).map(([dateKey, works]) => { // 从第一个作品获取日期对象 @@ -151,6 +166,7 @@ export default function WorksGallery({ ) })} + {ListFooterComponent} ) diff --git a/hooks/useWorksList.test.ts b/hooks/useWorksList.test.ts new file mode 100644 index 0000000..ec3afda --- /dev/null +++ b/hooks/useWorksList.test.ts @@ -0,0 +1,61 @@ +import { renderHook, waitFor } from '@testing-library/react-native' +import { useWorksList } from './useWorksList' + +describe('useWorksList', () => { + it('should load initial works data', async () => { + const { result } = renderHook(() => useWorksList()) + + expect(result.current.loading).toBe(true) + expect(result.current.works).toEqual([]) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.works.length).toBeGreaterThan(0) + expect(result.current.error).toBeNull() + }) + + it('should handle refresh', async () => { + const { result } = renderHook(() => useWorksList()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + const initialWorks = result.current.works + + await result.current.refresh() + + expect(result.current.refreshing).toBe(false) + expect(result.current.works).toEqual(initialWorks) + }) + + it('should load more works', async () => { + const { result } = renderHook(() => useWorksList()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + const initialCount = result.current.works.length + + await result.current.loadMore() + + expect(result.current.works.length).toBeGreaterThan(initialCount) + }) + + it('should set hasMore to false when no more data', async () => { + const { result } = renderHook(() => useWorksList()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + await result.current.loadMore() + await result.current.loadMore() + await result.current.loadMore() + + expect(result.current.hasMore).toBe(false) + }) +}) diff --git a/hooks/useWorksList.ts b/hooks/useWorksList.ts new file mode 100644 index 0000000..5816428 --- /dev/null +++ b/hooks/useWorksList.ts @@ -0,0 +1,95 @@ +import { useState, useCallback } from 'react' +import type { WorkItem } from '@/components/WorksGallery' + +interface UseWorksListResult { + works: WorkItem[] + loading: boolean + error: string | null + refreshing: boolean + hasMore: boolean + loadMore: () => Promise + refresh: () => Promise +} + +const PAGE_SIZE = 20 + +// TODO: Replace with actual API call when backend is ready +const mockFetchWorks = async (page: number): Promise => { + await new Promise(resolve => setTimeout(resolve, 800)) + + if (page > 2) return [] + + const baseData: WorkItem[] = [ + { id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' }, + { id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' }, + { id: 3, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' }, + { id: 4, date: new Date(2025, 10, 27), duration: '00:05', category: '合拍' }, + { id: 5, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' }, + { id: 6, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' }, + { id: 7, date: new Date(2025, 10, 26), duration: '00:05', category: '合拍' }, + { id: 8, date: new Date(2025, 10, 26), duration: '00:05', category: '写真' }, + ] + + return baseData.map(item => ({ + ...item, + id: item.id + (page * PAGE_SIZE), + })) +} + +export function useWorksList(): UseWorksListResult { + const [works, setWorks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [page, setPage] = useState(0) + const [hasMore, setHasMore] = useState(true) + + const loadWorks = useCallback(async (pageNum: number, isRefresh = false) => { + try { + const data = await mockFetchWorks(pageNum) + + if (isRefresh) { + setWorks(data) + } else { + setWorks(prev => [...prev, ...data]) + } + + setHasMore(data.length === PAGE_SIZE) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load works') + } + }, []) + + const loadMore = useCallback(async () => { + if (!hasMore || loading) return + + const nextPage = page + 1 + setPage(nextPage) + await loadWorks(nextPage) + }, [hasMore, loading, page, loadWorks]) + + const refresh = useCallback(async () => { + setRefreshing(true) + setPage(0) + await loadWorks(0, true) + setRefreshing(false) + }, [loadWorks]) + + // Initial load + const [initialized, setInitialized] = useState(false) + if (!initialized) { + setInitialized(true) + loadWorks(0).finally(() => setLoading(false)) + } + + return { + works, + loading, + error, + refreshing, + hasMore, + loadMore, + refresh, + } +}