feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
@@ -12,17 +14,19 @@ import {
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
FlatList,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
|
useWindowDimensions
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const AVATAR_SIZE = 36;
|
const AVATAR_SIZE = 36;
|
||||||
const CARD_IMAGE_WIDTH = 132;
|
const CARD_IMAGE_WIDTH = 132;
|
||||||
@@ -33,21 +37,37 @@ const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
|||||||
expired: '已结束',
|
expired: '已结束',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CAROUSEL_ITEM_SPACING = 16;
|
||||||
|
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
||||||
|
const DOT_BASE_SIZE = 6;
|
||||||
|
|
||||||
export default function ChallengesScreen() {
|
export default function ChallengesScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const challenges = useAppSelector(selectChallengeCards);
|
const challenges = useAppSelector(selectChallengeCards);
|
||||||
const listStatus = useAppSelector(selectChallengesListStatus);
|
const listStatus = useAppSelector(selectChallengesListStatus);
|
||||||
const listError = useAppSelector(selectChallengesListError);
|
const listError = useAppSelector(selectChallengesListError);
|
||||||
const ongoingChallenge = useMemo(
|
const ongoingChallenges = useMemo(() => {
|
||||||
() =>
|
const now = dayjs();
|
||||||
challenges.find(
|
return challenges.filter((challenge) => {
|
||||||
(challenge) => challenge.status === 'ongoing' && challenge.isJoined && challenge.progress
|
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
||||||
),
|
return false;
|
||||||
[challenges]
|
}
|
||||||
);
|
|
||||||
|
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 progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
||||||
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
||||||
|
|
||||||
@@ -113,11 +133,11 @@ export default function ChallengesScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
|
||||||
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: insets.top,
|
||||||
|
}]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
bounces
|
bounces
|
||||||
>
|
>
|
||||||
@@ -138,33 +158,20 @@ export default function ChallengesScreen() {
|
|||||||
</TouchableOpacity> */}
|
</TouchableOpacity> */}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{ongoingChallenge ? (
|
{ongoingChallenges.length ? (
|
||||||
<TouchableOpacity
|
<OngoingChallengesCarousel
|
||||||
activeOpacity={0.92}
|
challenges={ongoingChallenges}
|
||||||
onPress={() =>
|
colorTokens={colorTokens}
|
||||||
router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChallengeProgressCard
|
|
||||||
title={ongoingChallenge.title}
|
|
||||||
endAt={ongoingChallenge.endAt}
|
|
||||||
progress={ongoingChallenge.progress}
|
|
||||||
style={styles.progressCardWrapper}
|
|
||||||
backgroundColors={[colorTokens.card, colorTokens.card]}
|
|
||||||
titleColor={colorTokens.text}
|
|
||||||
subtitleColor={colorTokens.textSecondary}
|
|
||||||
metaColor={colorTokens.primary}
|
|
||||||
metaSuffixColor={colorTokens.textSecondary}
|
|
||||||
accentColor={colorTokens.primary}
|
|
||||||
trackColor={progressTrackColor}
|
trackColor={progressTrackColor}
|
||||||
inactiveColor={progressInactiveColor}
|
inactiveColor={progressInactiveColor}
|
||||||
|
onPress={(challenge) =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,31 +199,210 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.cardMedia}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: challenge.image }}
|
source={{ uri: challenge.image }}
|
||||||
style={styles.cardImage}
|
style={styles.cardImage}
|
||||||
cachePolicy={'memory-disk'}
|
cachePolicy={'memory-disk'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<LinearGradient
|
||||||
|
pointerEvents="none"
|
||||||
|
colors={['rgba(17, 21, 32, 0.05)', 'rgba(13, 17, 28, 0.4)']}
|
||||||
|
style={styles.cardImageOverlay}
|
||||||
|
/>
|
||||||
|
<View style={styles.expiredBadge}>
|
||||||
|
<Text style={styles.expiredBadgeText}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.cardContent}>
|
<View style={styles.cardContent}>
|
||||||
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.cardTitle, { color: textColor }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{challenge.title}
|
{challenge.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
<Text
|
||||||
<Text style={[styles.cardParticipants, { color: mutedColor }]}>
|
style={[styles.cardDate, { color: mutedColor }]}
|
||||||
|
>
|
||||||
|
{challenge.dateRange}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.cardParticipants, { color: mutedColor }]}
|
||||||
|
>
|
||||||
{challenge.participantsLabel}
|
{challenge.participantsLabel}
|
||||||
{' · '}
|
|
||||||
{statusLabel}
|
|
||||||
{challenge.isJoined ? ' · 已加入' : ''}
|
{challenge.isJoined ? ' · 已加入' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
{challenge.avatars.length ? (
|
{challenge.avatars.length ? (
|
||||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<FlatList<ChallengeCardViewModel> | 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 (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.carouselCard,
|
||||||
|
{
|
||||||
|
width: cardWidth,
|
||||||
|
transform: [{ scale }, { translateY }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.92}
|
||||||
|
style={styles.carouselTouchable}
|
||||||
|
onPress={() => onPress(item)}
|
||||||
|
>
|
||||||
|
<ChallengeProgressCard
|
||||||
|
title={item.title}
|
||||||
|
endAt={item.endAt}
|
||||||
|
progress={item.progress}
|
||||||
|
style={styles.carouselProgressCard}
|
||||||
|
backgroundColors={[colorTokens.card, colorTokens.card]}
|
||||||
|
titleColor={colorTokens.text}
|
||||||
|
subtitleColor={colorTokens.textSecondary}
|
||||||
|
metaColor={colorTokens.primary}
|
||||||
|
metaSuffixColor={colorTokens.textSecondary}
|
||||||
|
accentColor={colorTokens.primary}
|
||||||
|
trackColor={trackColor}
|
||||||
|
inactiveColor={inactiveColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.carouselContainer}>
|
||||||
|
<Animated.FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={challenges}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
bounces
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToAlignment="start"
|
||||||
|
snapToInterval={snapInterval}
|
||||||
|
|
||||||
|
ItemSeparatorComponent={() => <View style={{ width: CAROUSEL_ITEM_SPACING }} />}
|
||||||
|
onScroll={onScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
overScrollMode="never"
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{challenges.length > 1 ? (
|
||||||
|
<View style={styles.carouselIndicators}>
|
||||||
|
{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 (
|
||||||
|
<Animated.View
|
||||||
|
key={challenge.id}
|
||||||
|
style={[
|
||||||
|
styles.carouselDot,
|
||||||
|
{
|
||||||
|
opacity: dotOpacity,
|
||||||
|
backgroundColor: colorTokens.primary,
|
||||||
|
transform: [{ scaleX }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type AvatarStackProps = {
|
type AvatarStackProps = {
|
||||||
avatars: string[];
|
avatars: string[];
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
@@ -251,7 +437,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 32,
|
paddingBottom: 120,
|
||||||
},
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -289,9 +475,31 @@ const styles = StyleSheet.create({
|
|||||||
cardsContainer: {
|
cardsContainer: {
|
||||||
gap: 18,
|
gap: 18,
|
||||||
},
|
},
|
||||||
progressCardWrapper: {
|
carouselContainer: {
|
||||||
marginBottom: 24,
|
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: {
|
stateContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -316,20 +524,29 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
flexDirection: 'row',
|
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
padding: 18,
|
padding: 18,
|
||||||
alignItems: 'center',
|
|
||||||
shadowOffset: { width: 0, height: 16 },
|
shadowOffset: { width: 0, height: 16 },
|
||||||
shadowOpacity: 0.18,
|
shadowOpacity: 0.18,
|
||||||
shadowRadius: 24,
|
shadowRadius: 24,
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardInner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
cardImage: {
|
cardImage: {
|
||||||
width: CARD_IMAGE_WIDTH,
|
width: CARD_IMAGE_WIDTH,
|
||||||
height: CARD_IMAGE_HEIGHT,
|
height: CARD_IMAGE_HEIGHT,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
},
|
},
|
||||||
|
cardMedia: {
|
||||||
|
borderRadius: 22,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
cardContent: {
|
cardContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: 16,
|
marginLeft: 16,
|
||||||
@@ -339,6 +556,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
cardDate: {
|
cardDate: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -348,6 +566,35 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
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: {
|
cardProgress: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
|
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
joinChallenge,
|
joinChallenge,
|
||||||
leaveChallenge,
|
leaveChallenge,
|
||||||
reportChallengeProgress,
|
reportChallengeProgress,
|
||||||
|
fetchChallengeRankings,
|
||||||
selectChallengeById,
|
selectChallengeById,
|
||||||
selectChallengeDetailError,
|
selectChallengeDetailError,
|
||||||
selectChallengeDetailStatus,
|
selectChallengeDetailStatus,
|
||||||
@@ -16,7 +18,8 @@ import {
|
|||||||
selectJoinStatus,
|
selectJoinStatus,
|
||||||
selectLeaveError,
|
selectLeaveError,
|
||||||
selectLeaveStatus,
|
selectLeaveStatus,
|
||||||
selectProgressStatus
|
selectProgressStatus,
|
||||||
|
selectChallengeRankingList
|
||||||
} from '@/store/challengesSlice';
|
} from '@/store/challengesSlice';
|
||||||
import { Toast } from '@/utils/toast.utils';
|
import { Toast } from '@/utils/toast.utils';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -107,6 +110,9 @@ export default function ChallengeDetailScreen() {
|
|||||||
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||||||
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
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(() => {
|
useEffect(() => {
|
||||||
const getData = async (id: string) => {
|
const getData = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -121,6 +127,12 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id && !rankingList) {
|
||||||
|
void dispatch(fetchChallengeRankings({ id }));
|
||||||
|
}
|
||||||
|
}, [dispatch, id, rankingList]);
|
||||||
|
|
||||||
|
|
||||||
const [showCelebration, setShowCelebration] = useState(false);
|
const [showCelebration, setShowCelebration] = useState(false);
|
||||||
|
|
||||||
@@ -139,13 +151,23 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
const progress = challenge?.progress;
|
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(
|
const participantAvatars = useMemo(
|
||||||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||||
[rankingData],
|
[rankingData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleViewAllRanking = () => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
|
||||||
|
};
|
||||||
|
|
||||||
const dateRangeLabel = useMemo(
|
const dateRangeLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildDateRangeLabel({
|
buildDateRangeLabel({
|
||||||
@@ -439,7 +461,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>排行榜</Text>
|
<Text style={styles.sectionTitle}>排行榜</Text>
|
||||||
<TouchableOpacity>
|
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||||||
<Text style={styles.sectionAction}>查看全部</Text>
|
<Text style={styles.sectionAction}>查看全部</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -451,23 +473,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
<View style={styles.rankingCard}>
|
<View style={styles.rankingCard}>
|
||||||
{rankingData.length ? (
|
{rankingData.length ? (
|
||||||
rankingData.map((item, index) => (
|
rankingData.map((item, index) => (
|
||||||
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
|
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
|
||||||
<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>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyRanking}>
|
<View style={styles.emptyRanking}>
|
||||||
@@ -718,63 +724,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowOffset: { width: 0, height: 10 },
|
shadowOffset: { width: 0, height: 10 },
|
||||||
elevation: 6,
|
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: {
|
emptyRanking: {
|
||||||
paddingVertical: 40,
|
paddingVertical: 40,
|
||||||
alignItems: 'center',
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -20,6 +20,11 @@ type ChallengeProgressCardProps = {
|
|||||||
inactiveColor?: string;
|
inactiveColor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RemainingTime = {
|
||||||
|
value: number;
|
||||||
|
unit: '天' | '小时';
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff'];
|
const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff'];
|
||||||
const DEFAULT_TITLE_COLOR = '#1c1f3a';
|
const DEFAULT_TITLE_COLOR = '#1c1f3a';
|
||||||
const DEFAULT_SUBTITLE_COLOR = '#707baf';
|
const DEFAULT_SUBTITLE_COLOR = '#707baf';
|
||||||
@@ -38,11 +43,22 @@ const clampSegments = (target: number, completed: number) => {
|
|||||||
return { segmentsCount, completedSegments };
|
return { segmentsCount, completedSegments };
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateRemainingDays = (endAt?: string) => {
|
const calculateRemainingTime = (endAt?: string): RemainingTime => {
|
||||||
if (!endAt) return 0;
|
if (!endAt) return { value: 0, unit: '天' };
|
||||||
const endDate = dayjs(endAt);
|
const endDate = dayjs(endAt);
|
||||||
if (!endDate.isValid()) return 0;
|
if (!endDate.isValid()) return { value: 0, unit: '天' };
|
||||||
return Math.max(0, endDate.diff(dayjs(), 'd'));
|
|
||||||
|
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<ChallengeProgressCardProps> = ({
|
export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
|
||||||
@@ -96,7 +112,7 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
|
|||||||
});
|
});
|
||||||
}, [segments?.completedSegments, segments?.segmentsCount]);
|
}, [segments?.completedSegments, segments?.segmentsCount]);
|
||||||
|
|
||||||
const remainingDays = useMemo(() => calculateRemainingDays(endAt), [endAt]);
|
const remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]);
|
||||||
|
|
||||||
if (!hasValidProgress || !progress || !segments) {
|
if (!hasValidProgress || !progress || !segments) {
|
||||||
return null;
|
return null;
|
||||||
@@ -111,7 +127,9 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.remaining, { color: subtitleColor }]}>挑战剩余 {remainingDays} 天</Text>
|
<Text style={[styles.remaining, { color: subtitleColor }]}>
|
||||||
|
挑战剩余 {remainingTime.value} {remainingTime.unit}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
|
|||||||
96
components/challenges/ChallengeRankingItem.tsx
Normal file
96
components/challenges/ChallengeRankingItem.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[styles.rankingRow, showDivider && 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} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
||||||
|
</View>
|
||||||
|
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -148,8 +148,8 @@ export const Colors = {
|
|||||||
ornamentAccent: palette.success[100],
|
ornamentAccent: palette.success[100],
|
||||||
|
|
||||||
// 背景渐变色
|
// 背景渐变色
|
||||||
backgroundGradientStart: palette.purple[25],
|
backgroundGradientStart: palette.purple[100],
|
||||||
backgroundGradientEnd: palette.base.white,
|
backgroundGradientEnd: palette.purple[25],
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
// 基础文本/背景
|
// 基础文本/背景
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type RankingItemDto = {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
metric: string;
|
metric: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
|
todayReportedValue?: number;
|
||||||
|
todayTargetValue?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ChallengeType {
|
export enum ChallengeType {
|
||||||
@@ -55,6 +57,13 @@ export type ChallengeDetailDto = ChallengeListItemDto & {
|
|||||||
userRank?: number;
|
userRank?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChallengeRankingsDto = {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
items: RankingItemDto[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function listChallenges(): Promise<ChallengeListItemDto[]> {
|
export async function listChallenges(): Promise<ChallengeListItemDto[]> {
|
||||||
return api.get<ChallengeListItemDto[]>('/challenges');
|
return api.get<ChallengeListItemDto[]>('/challenges');
|
||||||
}
|
}
|
||||||
@@ -75,3 +84,19 @@ export async function reportChallengeProgress(id: string, value?: number): Promi
|
|||||||
const body = value != null ? { value } : undefined;
|
const body = value != null ? { value } : undefined;
|
||||||
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
|
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getChallengeRankings(
|
||||||
|
id: string,
|
||||||
|
params?: { page?: number; pageSize?: number }
|
||||||
|
): Promise<ChallengeRankingsDto> {
|
||||||
|
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<ChallengeRankingsDto>(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type ChallengeStatus,
|
type ChallengeStatus,
|
||||||
type RankingItemDto,
|
type RankingItemDto,
|
||||||
getChallengeDetail,
|
getChallengeDetail,
|
||||||
|
getChallengeRankings,
|
||||||
joinChallenge as joinChallengeApi,
|
joinChallenge as joinChallengeApi,
|
||||||
leaveChallenge as leaveChallengeApi,
|
leaveChallenge as leaveChallengeApi,
|
||||||
listChallenges,
|
listChallenges,
|
||||||
@@ -26,6 +27,14 @@ export type ChallengeEntity = ChallengeSummary & {
|
|||||||
userRank?: number;
|
userRank?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChallengeRankingList = {
|
||||||
|
items: RankingItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type ChallengesState = {
|
type ChallengesState = {
|
||||||
entities: Record<string, ChallengeEntity>;
|
entities: Record<string, ChallengeEntity>;
|
||||||
order: string[];
|
order: string[];
|
||||||
@@ -39,6 +48,10 @@ type ChallengesState = {
|
|||||||
leaveError: Record<string, string | undefined>;
|
leaveError: Record<string, string | undefined>;
|
||||||
progressStatus: Record<string, AsyncStatus>;
|
progressStatus: Record<string, AsyncStatus>;
|
||||||
progressError: Record<string, string | undefined>;
|
progressError: Record<string, string | undefined>;
|
||||||
|
rankingList: Record<string, ChallengeRankingList | undefined>;
|
||||||
|
rankingStatus: Record<string, AsyncStatus>;
|
||||||
|
rankingLoadMoreStatus: Record<string, AsyncStatus>;
|
||||||
|
rankingError: Record<string, string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: ChallengesState = {
|
const initialState: ChallengesState = {
|
||||||
@@ -54,6 +67,10 @@ const initialState: ChallengesState = {
|
|||||||
leaveError: {},
|
leaveError: {},
|
||||||
progressStatus: {},
|
progressStatus: {},
|
||||||
progressError: {},
|
progressError: {},
|
||||||
|
rankingList: {},
|
||||||
|
rankingStatus: {},
|
||||||
|
rankingLoadMoreStatus: {},
|
||||||
|
rankingError: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const toErrorMessage = (error: unknown): string => {
|
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({
|
const challengesSlice = createSlice({
|
||||||
name: 'challenges',
|
name: 'challenges',
|
||||||
initialState,
|
initialState,
|
||||||
@@ -249,6 +279,49 @@ const challengesSlice = createSlice({
|
|||||||
const id = action.meta.arg.id;
|
const id = action.meta.arg.id;
|
||||||
state.progressStatus[id] = 'failed';
|
state.progressStatus[id] = 'failed';
|
||||||
state.progressError[id] = action.payload ?? toErrorMessage(action.error);
|
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) =>
|
export const selectProgressError = (id: string) =>
|
||||||
createSelector([selectChallengesState], (state) => state.progressError[id]);
|
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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user