Files
digital-pilates/app/challenges/[id]/leaderboard.tsx
richarjiang b0602b0a99 feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战
- 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard
- ChallengeProgressCard 支持小时级剩余时间显示
- 新增 ChallengeRankingItem 组件展示榜单项
- 排行榜支持分页加载、下拉刷新与错误重试
- 挑战卡片新增已结束角标与渐变遮罩
- 加入/退出挑战时展示庆祝动画与错误提示
- 统一背景渐变色与卡片阴影细节
2025-09-30 11:33:24 +08:00

293 lines
9.8 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 ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchChallengeDetail,
fetchChallengeRankings,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectChallengeRankingError,
selectChallengeRankingList,
selectChallengeRankingLoadMoreStatus,
selectChallengeRankingStatus,
} from '@/store/challengesSlice';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import {
ActivityIndicator,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function ChallengeLeaderboardScreen() {
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const dispatch = useAppDispatch();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
const rankingStatusSelector = useMemo(() => (id ? selectChallengeRankingStatus(id) : undefined), [id]);
const rankingStatus = useAppSelector((state) => (rankingStatusSelector ? rankingStatusSelector(state) : 'idle'));
const rankingLoadMoreStatusSelector = useMemo(
() => (id ? selectChallengeRankingLoadMoreStatus(id) : undefined),
[id]
);
const rankingLoadMoreStatus = useAppSelector((state) =>
rankingLoadMoreStatusSelector ? rankingLoadMoreStatusSelector(state) : 'idle'
);
const rankingErrorSelector = useMemo(() => (id ? selectChallengeRankingError(id) : undefined), [id]);
const rankingError = useAppSelector((state) => (rankingErrorSelector ? rankingErrorSelector(state) : undefined));
useEffect(() => {
if (id) {
void dispatch(fetchChallengeDetail(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && !rankingList) {
void dispatch(fetchChallengeRankings({ id }));
}
}, [dispatch, id, rankingList]);
if (!id) {
return (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
if (detailStatus === 'loading' && !challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
const hasMore = rankingList?.hasMore ?? false;
const isRefreshing = rankingStatus === 'loading';
const isLoadingMore = rankingLoadMoreStatus === 'loading';
const defaultPageSize = rankingList?.pageSize ?? 20;
const showInitialRankingLoading = isRefreshing && (!rankingList || rankingList.items.length === 0);
const handleRefresh = () => {
if (!id) {
return;
}
void dispatch(fetchChallengeRankings({ id, page: 1, pageSize: defaultPageSize }));
};
const handleLoadMore = () => {
if (!id || !rankingList || !hasMore || isLoadingMore || rankingStatus === 'loading') {
return;
}
void dispatch(
fetchChallengeRankings({ id, page: rankingList.page + 1, pageSize: rankingList.pageSize })
);
};
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 160;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
};
if (!challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
</Text>
</View>
</View>
);
}
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
const subtitle = challenge.rankingDescription ?? challenge.summary;
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colorTokens.primary}
/>
}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View style={styles.pageHeader}>
<Text style={styles.challengeTitle}>{challenge.title}</Text>
{subtitle ? <Text style={styles.challengeSubtitle}>{subtitle}</Text> : null}
{challenge.progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={challenge.progress}
style={styles.progressCardWrapper}
/>
) : null}
</View>
<View style={styles.rankingCard}>
{showInitialRankingLoading ? (
<View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
) : rankingData.length ? (
rankingData.map((item, index) => (
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
))
) : rankingError ? (
<View style={styles.emptyRanking}>
<Text style={styles.rankingErrorText}>{rankingError}</Text>
</View>
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
{isLoadingMore ? (
<View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text>
</View>
) : null}
{rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text>
</View>
) : null}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: 'transparent',
},
pageHeader: {
paddingHorizontal: 24,
paddingTop: 24,
},
challengeTitle: {
fontSize: 22,
fontWeight: '800',
color: '#1c1f3a',
},
challengeSubtitle: {
marginTop: 8,
fontSize: 14,
color: '#6f7ba7',
lineHeight: 20,
},
progressCardWrapper: {
marginTop: 20,
},
rankingCard: {
marginTop: 24,
marginHorizontal: 24,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 10,
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
justifyContent: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
rankingLoading: {
paddingVertical: 32,
alignItems: 'center',
justifyContent: 'center',
},
rankingErrorText: {
fontSize: 14,
color: '#eb5757',
},
loadMoreIndicator: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
loadMoreErrorText: {
fontSize: 13,
color: '#eb5757',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 14,
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 14,
textAlign: 'center',
},
});