- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
618 lines
16 KiB
TypeScript
618 lines
16 KiB
TypeScript
import dayjs from 'dayjs';
|
||
|
||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import {
|
||
fetchChallenges,
|
||
selectChallengeCards,
|
||
selectChallengesListError,
|
||
selectChallengesListStatus,
|
||
type ChallengeCardViewModel,
|
||
} from '@/store/challengesSlice';
|
||
import { Image } from 'expo-image';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Animated,
|
||
FlatList,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
useWindowDimensions
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
const AVATAR_SIZE = 36;
|
||
const CARD_IMAGE_WIDTH = 132;
|
||
const CARD_IMAGE_HEIGHT = 96;
|
||
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
||
upcoming: '即将开始',
|
||
ongoing: '进行中',
|
||
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 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';
|
||
|
||
useEffect(() => {
|
||
if (listStatus === 'idle') {
|
||
dispatch(fetchChallenges());
|
||
}
|
||
}, [dispatch, listStatus]);
|
||
|
||
const gradientColors: [string, string] =
|
||
theme === 'dark'
|
||
? ['#1f2230', '#10131e']
|
||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||
|
||
const renderChallenges = () => {
|
||
if (listStatus === 'loading' && challenges.length === 0) {
|
||
return (
|
||
<View style={styles.stateContainer}>
|
||
<ActivityIndicator color={colorTokens.primary} />
|
||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (listStatus === 'failed' && challenges.length === 0) {
|
||
return (
|
||
<View style={styles.stateContainer}>
|
||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||
{listError ?? '加载挑战失败,请稍后重试'}
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||
activeOpacity={0.9}
|
||
onPress={() => dispatch(fetchChallenges())}
|
||
>
|
||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (challenges.length === 0) {
|
||
return (
|
||
<View style={styles.stateContainer}>
|
||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return challenges.map((challenge) => (
|
||
<ChallengeCard
|
||
key={challenge.id}
|
||
challenge={challenge}
|
||
surfaceColor={colorTokens.surface}
|
||
textColor={colorTokens.text}
|
||
mutedColor={colorTokens.textSecondary}
|
||
onPress={() =>
|
||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||
}
|
||
/>
|
||
));
|
||
};
|
||
|
||
return (
|
||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||
<ScrollView
|
||
contentContainerStyle={[styles.scrollContent, {
|
||
paddingTop: insets.top,
|
||
}]}
|
||
showsVerticalScrollIndicator={false}
|
||
bounces
|
||
>
|
||
<View style={styles.headerRow}>
|
||
<View>
|
||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||
</View>
|
||
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||
<LinearGradient
|
||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
style={styles.giftButton}
|
||
>
|
||
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
||
</LinearGradient>
|
||
</TouchableOpacity> */}
|
||
</View>
|
||
|
||
{ongoingChallenges.length ? (
|
||
<OngoingChallengesCarousel
|
||
challenges={ongoingChallenges}
|
||
colorTokens={colorTokens}
|
||
trackColor={progressTrackColor}
|
||
inactiveColor={progressInactiveColor}
|
||
onPress={(challenge) =>
|
||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
type ChallengeCardProps = {
|
||
challenge: ChallengeCardViewModel;
|
||
surfaceColor: string;
|
||
textColor: string;
|
||
mutedColor: string;
|
||
onPress: () => void;
|
||
};
|
||
|
||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
activeOpacity={0.92}
|
||
onPress={onPress}
|
||
style={[
|
||
styles.card,
|
||
{
|
||
backgroundColor: surfaceColor,
|
||
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||
},
|
||
]}
|
||
>
|
||
<View style={styles.cardInner}>
|
||
<View style={styles.cardMedia}>
|
||
<Image
|
||
source={{ uri: challenge.image }}
|
||
style={styles.cardImage}
|
||
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}>
|
||
<Text
|
||
style={[styles.cardTitle, { color: textColor }]}
|
||
numberOfLines={1}
|
||
>
|
||
{challenge.title}
|
||
</Text>
|
||
<Text
|
||
style={[styles.cardDate, { color: mutedColor }]}
|
||
>
|
||
{challenge.dateRange}
|
||
</Text>
|
||
<Text
|
||
style={[styles.cardParticipants, { color: mutedColor }]}
|
||
>
|
||
{challenge.participantsLabel}
|
||
{challenge.isJoined ? ' · 已加入' : ''}
|
||
</Text>
|
||
{challenge.avatars.length ? (
|
||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
|
||
</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 = {
|
||
avatars: string[];
|
||
borderColor: string;
|
||
};
|
||
|
||
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
||
return (
|
||
<View style={styles.avatarRow}>
|
||
{avatars
|
||
.filter(Boolean)
|
||
.map((avatar, index) => (
|
||
<Image
|
||
key={`${avatar}-${index}`}
|
||
source={{ uri: avatar }}
|
||
style={[
|
||
styles.avatar,
|
||
{ borderColor },
|
||
index === 0 ? null : styles.avatarOffset,
|
||
]}
|
||
/>
|
||
))}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: {
|
||
flex: 1,
|
||
},
|
||
safeArea: {
|
||
flex: 1,
|
||
},
|
||
scrollContent: {
|
||
paddingHorizontal: 20,
|
||
paddingBottom: 120,
|
||
},
|
||
headerRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginTop: 8,
|
||
marginBottom: 26,
|
||
},
|
||
title: {
|
||
fontSize: 32,
|
||
fontWeight: '700',
|
||
letterSpacing: 1,
|
||
},
|
||
subtitle: {
|
||
marginTop: 6,
|
||
fontSize: 14,
|
||
fontWeight: '500',
|
||
opacity: 0.8,
|
||
},
|
||
giftShadow: {
|
||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||
shadowOffset: { width: 0, height: 8 },
|
||
shadowOpacity: 0.35,
|
||
shadowRadius: 12,
|
||
elevation: 8,
|
||
borderRadius: 26,
|
||
},
|
||
giftButton: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 26,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
cardsContainer: {
|
||
gap: 18,
|
||
},
|
||
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',
|
||
paddingVertical: 40,
|
||
paddingHorizontal: 20,
|
||
},
|
||
stateText: {
|
||
marginTop: 12,
|
||
fontSize: 14,
|
||
textAlign: 'center',
|
||
lineHeight: 20,
|
||
},
|
||
retryButton: {
|
||
marginTop: 16,
|
||
paddingHorizontal: 18,
|
||
paddingVertical: 8,
|
||
borderRadius: 18,
|
||
borderWidth: StyleSheet.hairlineWidth,
|
||
},
|
||
retryText: {
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
},
|
||
card: {
|
||
borderRadius: 28,
|
||
padding: 18,
|
||
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,
|
||
},
|
||
cardTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
marginBottom: 4,
|
||
},
|
||
|
||
cardDate: {
|
||
fontSize: 13,
|
||
fontWeight: '500',
|
||
marginBottom: 4,
|
||
},
|
||
cardParticipants: {
|
||
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,
|
||
fontWeight: '600',
|
||
},
|
||
avatarRow: {
|
||
flexDirection: 'row',
|
||
marginTop: 16,
|
||
alignItems: 'center',
|
||
},
|
||
avatar: {
|
||
width: AVATAR_SIZE,
|
||
height: AVATAR_SIZE,
|
||
borderRadius: AVATAR_SIZE / 2,
|
||
borderWidth: 2,
|
||
},
|
||
avatarOffset: {
|
||
marginLeft: -12,
|
||
},
|
||
});
|