diff --git a/app/(tabs)/challenges.tsx b/app/(tabs)/challenges.tsx
index f338b2b..6c81f82 100644
--- a/app/(tabs)/challenges.tsx
+++ b/app/(tabs)/challenges.tsx
@@ -1,3 +1,5 @@
+import dayjs from 'dayjs';
+
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -12,17 +14,19 @@ import {
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
-import React, { useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import {
ActivityIndicator,
+ Animated,
+ FlatList,
ScrollView,
- StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
+ useWindowDimensions
} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132;
@@ -33,21 +37,37 @@ const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
expired: '已结束',
};
+const CAROUSEL_ITEM_SPACING = 16;
+const MIN_CAROUSEL_CARD_WIDTH = 280;
+const DOT_BASE_SIZE = 6;
+
export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
+ const insets = useSafeAreaInsets();
+
const colorTokens = Colors[theme];
const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
- const ongoingChallenge = useMemo(
- () =>
- challenges.find(
- (challenge) => challenge.status === 'ongoing' && challenge.isJoined && challenge.progress
- ),
- [challenges]
- );
+ const ongoingChallenges = useMemo(() => {
+ const now = dayjs();
+ return challenges.filter((challenge) => {
+ if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
+ return false;
+ }
+
+ if (challenge.endAt) {
+ const endDate = dayjs(challenge.endAt);
+ if (endDate.isValid() && endDate.isBefore(now)) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }, [challenges]);
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
@@ -113,20 +133,20 @@ export default function ChallengesScreen() {
return (
-
-
-
-
-
- 挑战
- 参与精选活动,保持每日动力
-
- {/*
+
+
+
+ 挑战
+ 参与精选活动,保持每日动力
+
+ {/*
*/}
-
+
- {ongoingChallenge ? (
-
- router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } })
- }
- >
-
-
- ) : null}
+ {ongoingChallenges.length ? (
+
+ router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
+ }
+ />
+ ) : null}
- {renderChallenges()}
-
-
+ {renderChallenges()}
+
);
}
@@ -192,28 +199,207 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
},
]}
>
-
+
+
+
+ <>
+
+
+ {statusLabel}
+
+ >
+
+
+
+
+ {challenge.title}
+
+
+ {challenge.dateRange}
+
+
+ {challenge.participantsLabel}
+ {challenge.isJoined ? ' · 已加入' : ''}
+
+ {challenge.avatars.length ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark'];
+
+type OngoingChallengesCarouselProps = {
+ challenges: ChallengeCardViewModel[];
+ colorTokens: ThemeColorTokens;
+ trackColor: string;
+ inactiveColor: string;
+ onPress: (challenge: ChallengeCardViewModel) => void;
+};
+
+function OngoingChallengesCarousel({
+ challenges,
+ colorTokens,
+ trackColor,
+ inactiveColor,
+ onPress,
+}: OngoingChallengesCarouselProps) {
+ const { width } = useWindowDimensions();
+ const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH);
+ const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING;
+ const scrollX = useRef(new Animated.Value(0)).current;
+ const listRef = useRef | null>(null);
+
+ useEffect(() => {
+ scrollX.setValue(0);
+ listRef.current?.scrollToOffset({ offset: 0, animated: false });
+ }, [scrollX, challenges.length]);
+
+ const onScroll = useMemo(
+ () =>
+ Animated.event(
+ [
+ {
+ nativeEvent: {
+ contentOffset: { x: scrollX },
+ },
+ },
+ ],
+ { useNativeDriver: true }
+ ),
+ [scrollX]
+ );
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: ChallengeCardViewModel; index: number }) => {
+ const inputRange = [
+ (index - 1) * snapInterval,
+ index * snapInterval,
+ (index + 1) * snapInterval,
+ ];
+ const scale = scrollX.interpolate({
+ inputRange,
+ outputRange: [0.94, 1, 0.94],
+ extrapolate: 'clamp',
+ });
+ const translateY = scrollX.interpolate({
+ inputRange,
+ outputRange: [10, 0, 10],
+ extrapolate: 'clamp',
+ });
+
+ return (
+
+ onPress(item)}
+ >
+
+
+
+ );
+ },
+ [cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
+ );
+
+ return (
+
+ item.id}
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ bounces
+ decelerationRate="fast"
+ snapToAlignment="start"
+ snapToInterval={snapInterval}
+
+ ItemSeparatorComponent={() => }
+ onScroll={onScroll}
+ scrollEventThrottle={16}
+ overScrollMode="never"
+ renderItem={renderItem}
/>
-
-
- {challenge.title}
-
- {challenge.dateRange}
-
- {challenge.participantsLabel}
- {' · '}
- {statusLabel}
- {challenge.isJoined ? ' · 已加入' : ''}
-
- {challenge.avatars.length ? (
-
- ) : null}
-
-
+ {challenges.length > 1 ? (
+
+ {challenges.map((challenge, index) => {
+ const inputRange = [
+ (index - 1) * snapInterval,
+ index * snapInterval,
+ (index + 1) * snapInterval,
+ ];
+ const scaleX = scrollX.interpolate({
+ inputRange,
+ outputRange: [1, 2.6, 1],
+ extrapolate: 'clamp',
+ });
+ const dotOpacity = scrollX.interpolate({
+ inputRange,
+ outputRange: [0.35, 1, 0.35],
+ extrapolate: 'clamp',
+ });
+
+ return (
+
+ );
+ })}
+
+ ) : null}
+
);
}
@@ -251,7 +437,7 @@ const styles = StyleSheet.create({
},
scrollContent: {
paddingHorizontal: 20,
- paddingBottom: 32,
+ paddingBottom: 120,
},
headerRow: {
flexDirection: 'row',
@@ -289,9 +475,31 @@ const styles = StyleSheet.create({
cardsContainer: {
gap: 18,
},
- progressCardWrapper: {
+ carouselContainer: {
marginBottom: 24,
},
+ carouselCard: {
+ width: '100%',
+ },
+ carouselTouchable: {
+ flex: 1,
+ },
+ carouselProgressCard: {
+ width: '100%',
+ },
+ carouselIndicators: {
+ marginTop: 18,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ carouselDot: {
+ width: DOT_BASE_SIZE,
+ height: DOT_BASE_SIZE,
+ borderRadius: DOT_BASE_SIZE / 2,
+ marginHorizontal: 4,
+ backgroundColor: 'transparent',
+ },
stateContainer: {
alignItems: 'center',
justifyContent: 'center',
@@ -316,20 +524,29 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
card: {
- flexDirection: 'row',
borderRadius: 28,
padding: 18,
- alignItems: 'center',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 6,
+ position: 'relative',
+ overflow: 'hidden',
+ },
+ cardInner: {
+ flexDirection: 'row',
+ alignItems: 'center',
},
cardImage: {
width: CARD_IMAGE_WIDTH,
height: CARD_IMAGE_HEIGHT,
borderRadius: 22,
},
+ cardMedia: {
+ borderRadius: 22,
+ overflow: 'hidden',
+ position: 'relative',
+ },
cardContent: {
flex: 1,
marginLeft: 16,
@@ -339,6 +556,7 @@ const styles = StyleSheet.create({
fontWeight: '700',
marginBottom: 4,
},
+
cardDate: {
fontSize: 13,
fontWeight: '500',
@@ -348,6 +566,35 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '500',
},
+ cardExpired: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: 'rgba(148, 163, 184, 0.22)',
+ },
+ cardExpiredText: {
+ opacity: 0.7,
+ },
+ cardDimOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ borderRadius: 28,
+ },
+ cardImageOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ expiredBadge: {
+ position: 'absolute',
+ left: 12,
+ bottom: 12,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 12,
+ backgroundColor: 'rgba(12, 16, 28, 0.45)',
+ },
+ expiredBadgeText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#f7f9ff',
+ letterSpacing: 0.3,
+ },
cardProgress: {
marginTop: 8,
fontSize: 13,
diff --git a/app/challenges/[id].tsx b/app/challenges/[id]/index.tsx
similarity index 91%
rename from app/challenges/[id].tsx
rename to app/challenges/[id]/index.tsx
index bbccb07..3006aa9 100644
--- a/app/challenges/[id].tsx
+++ b/app/challenges/[id]/index.tsx
@@ -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() {
排行榜
-
+
查看全部
@@ -451,23 +473,7 @@ export default function ChallengeDetailScreen() {
{rankingData.length ? (
rankingData.map((item, index) => (
- 0 && styles.rankingRowDivider]}>
-
- {index + 1}
-
- {item.avatar ? (
-
- ) : (
-
-
-
- )}
-
- {item.name}
- {item.metric}
-
- {item.badge ? {item.badge} : null}
-
+ 0} />
))
) : (
@@ -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',
diff --git a/app/challenges/[id]/leaderboard.tsx b/app/challenges/[id]/leaderboard.tsx
new file mode 100644
index 0000000..6a22772
--- /dev/null
+++ b/app/challenges/[id]/leaderboard.tsx
@@ -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 (
+
+ router.back()} withSafeTop />
+
+ 未找到该挑战。
+
+
+ );
+ }
+
+ if (detailStatus === 'loading' && !challenge) {
+ return (
+
+ router.back()} withSafeTop />
+
+
+ 加载榜单中…
+
+
+ );
+ }
+
+ 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) => {
+ const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
+ const paddingToBottom = 160;
+ if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
+ handleLoadMore();
+ }
+ };
+
+ if (!challenge) {
+ return (
+
+ router.back()} withSafeTop />
+
+
+ {detailError ?? '暂时无法加载榜单,请稍后再试。'}
+
+
+
+ );
+ }
+
+ const rankingData = rankingList?.items ?? challenge.rankings ?? [];
+ const subtitle = challenge.rankingDescription ?? challenge.summary;
+
+ return (
+
+ router.back()} withSafeTop />
+
+ }
+ onScroll={handleScroll}
+ scrollEventThrottle={16}
+ >
+
+ {challenge.title}
+ {subtitle ? {subtitle} : null}
+ {challenge.progress ? (
+
+ ) : null}
+
+
+
+ {showInitialRankingLoading ? (
+
+
+ 加载榜单中…
+
+ ) : rankingData.length ? (
+ rankingData.map((item, index) => (
+ 0} />
+ ))
+ ) : rankingError ? (
+
+ {rankingError}
+
+ ) : (
+
+ 榜单即将开启,快来抢占席位。
+
+ )}
+ {isLoadingMore ? (
+
+
+ 加载更多…
+
+ ) : null}
+ {rankingLoadMoreStatus === 'failed' ? (
+
+ 加载更多失败,请下拉刷新重试
+
+ ) : null}
+
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/components/challenges/ChallengeProgressCard.tsx b/components/challenges/ChallengeProgressCard.tsx
index 68d69b4..6a6623f 100644
--- a/components/challenges/ChallengeProgressCard.tsx
+++ b/components/challenges/ChallengeProgressCard.tsx
@@ -20,6 +20,11 @@ type ChallengeProgressCardProps = {
inactiveColor?: string;
};
+type RemainingTime = {
+ value: number;
+ unit: '天' | '小时';
+};
+
const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff'];
const DEFAULT_TITLE_COLOR = '#1c1f3a';
const DEFAULT_SUBTITLE_COLOR = '#707baf';
@@ -38,11 +43,22 @@ const clampSegments = (target: number, completed: number) => {
return { segmentsCount, completedSegments };
};
-const calculateRemainingDays = (endAt?: string) => {
- if (!endAt) return 0;
+const calculateRemainingTime = (endAt?: string): RemainingTime => {
+ if (!endAt) return { value: 0, unit: '天' };
const endDate = dayjs(endAt);
- if (!endDate.isValid()) return 0;
- return Math.max(0, endDate.diff(dayjs(), 'd'));
+ if (!endDate.isValid()) return { value: 0, unit: '天' };
+
+ const diffMilliseconds = endDate.diff(dayjs());
+ if (diffMilliseconds <= 0) {
+ return { value: 0, unit: '天' };
+ }
+
+ const diffHours = diffMilliseconds / (60 * 60 * 1000);
+ if (diffHours < 24) {
+ return { value: Math.max(1, Math.floor(diffHours)), unit: '小时' };
+ }
+
+ return { value: Math.floor(diffHours / 24), unit: '天' };
};
export const ChallengeProgressCard: React.FC = ({
@@ -96,7 +112,7 @@ export const ChallengeProgressCard: React.FC = ({
});
}, [segments?.completedSegments, segments?.segmentsCount]);
- const remainingDays = useMemo(() => calculateRemainingDays(endAt), [endAt]);
+ const remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]);
if (!hasValidProgress || !progress || !segments) {
return null;
@@ -111,7 +127,9 @@ export const ChallengeProgressCard: React.FC = ({
{title}
- 挑战剩余 {remainingDays} 天
+
+ 挑战剩余 {remainingTime.value} {remainingTime.unit}
+
diff --git a/components/challenges/ChallengeRankingItem.tsx b/components/challenges/ChallengeRankingItem.tsx
new file mode 100644
index 0000000..58c22ca
--- /dev/null
+++ b/components/challenges/ChallengeRankingItem.tsx
@@ -0,0 +1,96 @@
+import type { RankingItem } from '@/store/challengesSlice';
+import { Ionicons } from '@expo/vector-icons';
+import { Image } from 'expo-image';
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+type ChallengeRankingItemProps = {
+ item: RankingItem;
+ index: number;
+ showDivider?: boolean;
+};
+
+export function ChallengeRankingItem({ item, index, showDivider = false }: ChallengeRankingItemProps) {
+ return (
+
+
+ {index + 1}
+
+ {item.avatar ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {item.name}
+
+ {item.metric}
+
+ {item.badge ? {item.badge} : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ 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,
+ backgroundColor: '#EEF0FF',
+ },
+ 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',
+ },
+});
diff --git a/constants/Colors.ts b/constants/Colors.ts
index 0a409e3..dd53956 100644
--- a/constants/Colors.ts
+++ b/constants/Colors.ts
@@ -148,8 +148,8 @@ export const Colors = {
ornamentAccent: palette.success[100],
// 背景渐变色
- backgroundGradientStart: palette.purple[25],
- backgroundGradientEnd: palette.base.white,
+ backgroundGradientStart: palette.purple[100],
+ backgroundGradientEnd: palette.purple[25],
},
dark: {
// 基础文本/背景
diff --git a/services/challengesApi.ts b/services/challengesApi.ts
index 5be96a7..130c645 100644
--- a/services/challengesApi.ts
+++ b/services/challengesApi.ts
@@ -15,6 +15,8 @@ export type RankingItemDto = {
avatar: string | null;
metric: string;
badge?: string;
+ todayReportedValue?: number;
+ todayTargetValue?: number;
};
export enum ChallengeType {
@@ -55,6 +57,13 @@ export type ChallengeDetailDto = ChallengeListItemDto & {
userRank?: number;
};
+export type ChallengeRankingsDto = {
+ total: number;
+ page: number;
+ pageSize: number;
+ items: RankingItemDto[];
+};
+
export async function listChallenges(): Promise {
return api.get('/challenges');
}
@@ -75,3 +84,19 @@ export async function reportChallengeProgress(id: string, value?: number): Promi
const body = value != null ? { value } : undefined;
return api.post(`/challenges/${encodeURIComponent(id)}/progress`, body);
}
+
+export async function getChallengeRankings(
+ id: string,
+ params?: { page?: number; pageSize?: number }
+): Promise {
+ const searchParams = new URLSearchParams();
+ if (params?.page) {
+ searchParams.append('page', String(params.page));
+ }
+ if (params?.pageSize) {
+ searchParams.append('pageSize', String(params.pageSize));
+ }
+ const query = searchParams.toString();
+ const url = `/challenges/${encodeURIComponent(id)}/rankings${query ? `?${query}` : ''}`;
+ return api.get(url);
+}
diff --git a/store/challengesSlice.ts b/store/challengesSlice.ts
index cc1be6f..29b91d7 100644
--- a/store/challengesSlice.ts
+++ b/store/challengesSlice.ts
@@ -5,6 +5,7 @@ import {
type ChallengeStatus,
type RankingItemDto,
getChallengeDetail,
+ getChallengeRankings,
joinChallenge as joinChallengeApi,
leaveChallenge as leaveChallengeApi,
listChallenges,
@@ -26,6 +27,14 @@ export type ChallengeEntity = ChallengeSummary & {
userRank?: number;
};
+type ChallengeRankingList = {
+ items: RankingItem[];
+ total: number;
+ page: number;
+ pageSize: number;
+ hasMore: boolean;
+};
+
type ChallengesState = {
entities: Record;
order: string[];
@@ -39,6 +48,10 @@ type ChallengesState = {
leaveError: Record;
progressStatus: Record;
progressError: Record;
+ rankingList: Record;
+ rankingStatus: Record;
+ rankingLoadMoreStatus: Record;
+ rankingError: Record;
};
const initialState: ChallengesState = {
@@ -54,6 +67,10 @@ const initialState: ChallengesState = {
leaveError: {},
progressStatus: {},
progressError: {},
+ rankingList: {},
+ rankingStatus: {},
+ rankingLoadMoreStatus: {},
+ rankingError: {},
};
const toErrorMessage = (error: unknown): string => {
@@ -128,6 +145,19 @@ export const reportChallengeProgress = createAsyncThunk<
}
});
+export const fetchChallengeRankings = createAsyncThunk<
+ { id: string; total: number; page: number; pageSize: number; items: RankingItem[] },
+ { id: string; page?: number; pageSize?: number },
+ { rejectValue: string }
+>('challenges/fetchRankings', async ({ id, page = 1, pageSize = 20 }, { rejectWithValue }) => {
+ try {
+ const data = await getChallengeRankings(id, { page, pageSize });
+ return { id, ...data };
+ } catch (error) {
+ return rejectWithValue(toErrorMessage(error));
+ }
+});
+
const challengesSlice = createSlice({
name: 'challenges',
initialState,
@@ -249,6 +279,49 @@ const challengesSlice = createSlice({
const id = action.meta.arg.id;
state.progressStatus[id] = 'failed';
state.progressError[id] = action.payload ?? toErrorMessage(action.error);
+ })
+ .addCase(fetchChallengeRankings.pending, (state, action) => {
+ const { id, page = 1 } = action.meta.arg;
+ if (page <= 1) {
+ state.rankingStatus[id] = 'loading';
+ state.rankingError[id] = undefined;
+ state.rankingLoadMoreStatus[id] = 'idle';
+ } else {
+ state.rankingLoadMoreStatus[id] = 'loading';
+ }
+ })
+ .addCase(fetchChallengeRankings.fulfilled, (state, action) => {
+ const { id, items, page, pageSize, total } = action.payload;
+ const existing = state.rankingList[id];
+ let merged: RankingItem[];
+ if (!existing || page <= 1) {
+ merged = [...items];
+ } else {
+ const map = new Map(existing.items.map((item) => [item.id, item] as const));
+ items.forEach((item) => {
+ map.set(item.id, item);
+ });
+ merged = Array.from(map.values());
+ }
+ const hasMore = merged.length < total;
+ state.rankingList[id] = { items: merged, total, page, pageSize, hasMore };
+ if (page <= 1) {
+ state.rankingStatus[id] = 'succeeded';
+ state.rankingError[id] = undefined;
+ } else {
+ state.rankingLoadMoreStatus[id] = 'succeeded';
+ }
+ })
+ .addCase(fetchChallengeRankings.rejected, (state, action) => {
+ const { id, page = 1 } = action.meta.arg;
+ const message = action.payload ?? toErrorMessage(action.error);
+ if (page <= 1) {
+ state.rankingStatus[id] = 'failed';
+ state.rankingError[id] = message;
+ } else {
+ state.rankingLoadMoreStatus[id] = 'failed';
+ state.rankingError[id] = message;
+ }
});
},
});
@@ -369,3 +442,15 @@ export const selectProgressStatus = (id: string) =>
export const selectProgressError = (id: string) =>
createSelector([selectChallengesState], (state) => state.progressError[id]);
+
+export const selectChallengeRankingList = (id: string) =>
+ createSelector([selectChallengesState], (state) => state.rankingList[id]);
+
+export const selectChallengeRankingStatus = (id: string) =>
+ createSelector([selectChallengesState], (state) => state.rankingStatus[id] ?? 'idle');
+
+export const selectChallengeRankingLoadMoreStatus = (id: string) =>
+ createSelector([selectChallengesState], (state) => state.rankingLoadMoreStatus[id] ?? 'idle');
+
+export const selectChallengeRankingError = (id: string) =>
+ createSelector([selectChallengesState], (state) => state.rankingError[id]);