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

618 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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,
},
});