feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
joinChallenge,
|
||||
leaveChallenge,
|
||||
reportChallengeProgress,
|
||||
fetchChallengeRankings,
|
||||
selectChallengeById,
|
||||
selectChallengeDetailError,
|
||||
selectChallengeDetailStatus,
|
||||
@@ -16,7 +18,8 @@ import {
|
||||
selectJoinStatus,
|
||||
selectLeaveError,
|
||||
selectLeaveStatus,
|
||||
selectProgressStatus
|
||||
selectProgressStatus,
|
||||
selectChallengeRankingList
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -107,6 +110,9 @@ export default function ChallengeDetailScreen() {
|
||||
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||||
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
||||
|
||||
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
|
||||
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async (id: string) => {
|
||||
try {
|
||||
@@ -121,6 +127,12 @@ export default function ChallengeDetailScreen() {
|
||||
|
||||
}, [dispatch, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && !rankingList) {
|
||||
void dispatch(fetchChallengeRankings({ id }));
|
||||
}
|
||||
}, [dispatch, id, rankingList]);
|
||||
|
||||
|
||||
const [showCelebration, setShowCelebration] = useState(false);
|
||||
|
||||
@@ -139,13 +151,23 @@ export default function ChallengeDetailScreen() {
|
||||
|
||||
const progress = challenge?.progress;
|
||||
|
||||
const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
|
||||
const rankingData = useMemo(() => {
|
||||
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
||||
return source.slice(0, 10);
|
||||
}, [challenge?.rankings, rankingList?.items]);
|
||||
|
||||
const participantAvatars = useMemo(
|
||||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||
[rankingData],
|
||||
);
|
||||
|
||||
const handleViewAllRanking = () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
|
||||
};
|
||||
|
||||
const dateRangeLabel = useMemo(
|
||||
() =>
|
||||
buildDateRangeLabel({
|
||||
@@ -439,7 +461,7 @@ export default function ChallengeDetailScreen() {
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>排行榜</Text>
|
||||
<TouchableOpacity>
|
||||
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||||
<Text style={styles.sectionAction}>查看全部</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -451,23 +473,7 @@ export default function ChallengeDetailScreen() {
|
||||
<View style={styles.rankingCard}>
|
||||
{rankingData.length ? (
|
||||
rankingData.map((item, index) => (
|
||||
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
|
||||
<View style={styles.rankingOrderCircle}>
|
||||
<Text style={styles.rankingOrder}>{index + 1}</Text>
|
||||
</View>
|
||||
{item.avatar ? (
|
||||
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} cachePolicy={'memory-disk'} />
|
||||
) : (
|
||||
<View style={styles.rankingAvatarPlaceholder}>
|
||||
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.rankingInfo}>
|
||||
<Text style={styles.rankingName}>{item.name}</Text>
|
||||
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
||||
</View>
|
||||
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
|
||||
</View>
|
||||
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyRanking}>
|
||||
@@ -718,63 +724,6 @@ const styles = StyleSheet.create({
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 6,
|
||||
},
|
||||
rankingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
rankingRowDivider: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: '#E5E7FF',
|
||||
},
|
||||
rankingOrderCircle: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#EEF0FF',
|
||||
marginRight: 12,
|
||||
},
|
||||
rankingOrder: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#4F5BD5',
|
||||
},
|
||||
rankingAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
marginRight: 14,
|
||||
},
|
||||
rankingAvatarPlaceholder: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
marginRight: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#EEF0FF',
|
||||
},
|
||||
rankingInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
rankingName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
rankingMetric: {
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
color: '#6f7ba7',
|
||||
},
|
||||
rankingBadge: {
|
||||
fontSize: 12,
|
||||
color: '#A67CFF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
emptyRanking: {
|
||||
paddingVertical: 40,
|
||||
alignItems: 'center',
|
||||
292
app/challenges/[id]/leaderboard.tsx
Normal file
292
app/challenges/[id]/leaderboard.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user