import dayjs from 'dayjs'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; 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 { isLoggedIn } = useAuthGuard() 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 ( 加载挑战中… ); } if (listStatus === 'failed' && challenges.length === 0) { return ( {listError ?? '加载挑战失败,请稍后重试'} dispatch(fetchChallenges())} > 重新加载 ); } if (challenges.length === 0) { return ( 暂无挑战,稍后再来探索。 ); } return challenges.map((challenge) => ( router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) } /> )); }; return ( 挑战 参与精选活动,保持每日动力 {/* */} {ongoingChallenges.length ? ( router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) } /> ) : null} {renderChallenges()} ); } 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 ( <> {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} /> {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} ); } type AvatarStackProps = { avatars: string[]; borderColor: string; }; function AvatarStack({ avatars, borderColor }: AvatarStackProps) { return ( {avatars .filter(Boolean) .map((avatar, index) => ( ))} ); } 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, }, });