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 { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { fetchChallengeDetail, joinChallenge, leaveChallenge, reportChallengeProgress, fetchChallengeRankings, selectChallengeById, selectChallengeDetailError, selectChallengeDetailStatus, selectJoinError, selectJoinStatus, selectLeaveError, selectLeaveStatus, selectProgressStatus, selectChallengeRankingList } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useLocalSearchParams, useRouter } from 'expo-router'; import LottieView from 'lottie-react-native'; import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, Dimensions, Platform, ScrollView, Share, StatusBar, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; const { width } = Dimensions.get('window'); const HERO_HEIGHT = width * 0.76; const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF']; const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da']; const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); const formatMonthDay = (value?: string): string | undefined => { if (!value) return undefined; const date = new Date(value); if (Number.isNaN(date.getTime())) return undefined; return `${date.getMonth() + 1}月${date.getDate()}日`; }; const buildDateRangeLabel = (challenge?: { startAt?: string; endAt?: string; periodLabel?: string; durationLabel?: string; }): string => { if (!challenge) return ''; const startLabel = formatMonthDay(challenge.startAt); const endLabel = formatMonthDay(challenge.endAt); if (startLabel && endLabel) { return `${startLabel} - ${endLabel}`; } return challenge.periodLabel ?? challenge.durationLabel ?? ''; }; const formatParticipantsLabel = (count?: number): string => { if (typeof count !== 'number') return '持续更新中'; return `${count.toLocaleString('zh-CN')} 人正在参与`; }; export default function ChallengeDetailScreen() { 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 { ensureLoggedIn } = useAuthGuard(); 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 joinStatusSelector = useMemo(() => (id ? selectJoinStatus(id) : undefined), [id]); const joinStatus = useAppSelector((state) => (joinStatusSelector ? joinStatusSelector(state) : 'idle')); const joinErrorSelector = useMemo(() => (id ? selectJoinError(id) : undefined), [id]); const joinError = useAppSelector((state) => (joinErrorSelector ? joinErrorSelector(state) : undefined)); const leaveStatusSelector = useMemo(() => (id ? selectLeaveStatus(id) : undefined), [id]); const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle')); const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]); const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined)); const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]); const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined)); useEffect(() => { const getData = async (id: string) => { try { await dispatch(fetchChallengeDetail(id)).unwrap; } catch (error) { } } if (id) { getData(id); } }, [dispatch, id]); useEffect(() => { if (id && !rankingList) { void dispatch(fetchChallengeRankings({ id })); } }, [dispatch, id, rankingList]); const [showCelebration, setShowCelebration] = useState(false); useEffect(() => { if (!showCelebration) { return; } const timer = setTimeout(() => { setShowCelebration(false); }, 2400); return () => { clearTimeout(timer); }; }, [showCelebration]); const progress = challenge?.progress; const rankingData = useMemo(() => { const source = rankingList?.items ?? challenge?.rankings ?? []; return source.slice(0, 10); }, [challenge?.rankings, rankingList?.items]); const participantAvatars = useMemo( () => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6), [rankingData], ); const handleViewAllRanking = () => { if (!id) { return; } router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } }); }; const dateRangeLabel = useMemo( () => buildDateRangeLabel({ startAt: challenge?.startAt, endAt: challenge?.endAt, periodLabel: challenge?.periodLabel, durationLabel: challenge?.durationLabel, }), [challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel], ); const handleShare = async () => { if (!challenge) { return; } try { await Share.share({ title: challenge.title, message: `我正在参与「${challenge.title}」,一起坚持吧!`, url: challenge.image, }); } catch (error) { console.warn('分享失败', error); } }; const handleJoin = async () => { if (!id || joinStatus === 'loading') { return; } const isLoggedIn = await ensureLoggedIn(); if (!isLoggedIn) { // 如果未登录,用户会被重定向到登录页面 return; } try { await dispatch(joinChallenge(id)); setShowCelebration(true) } catch (error) { Toast.error('加入挑战失败') } }; const handleLeave = async () => { if (!id || leaveStatus === 'loading') { return; } try { await dispatch(leaveChallenge(id)).unwrap(); await dispatch(fetchChallengeDetail(id)).unwrap(); } catch (error) { Toast.error('退出挑战失败'); } }; const handleLeaveConfirm = () => { if (!id || leaveStatus === 'loading') { return; } Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [ { text: '取消', style: 'cancel' }, { text: '退出挑战', style: 'destructive', onPress: () => { void handleLeave(); }, }, ]); }; const handleProgressReport = () => { if (!id || progressStatus === 'loading') { return; } dispatch(reportChallengeProgress({ id })); }; const isJoined = challenge?.isJoined ?? false; const isLoadingInitial = detailStatus === 'loading' && !challenge; if (!id) { return ( router.back()} withSafeTop transparent={false} /> 未找到该挑战,稍后再试试吧。 ); } if (isLoadingInitial) { return ( router.back()} withSafeTop transparent={false} /> 加载挑战详情中… ); } if (!challenge) { return ( router.back()} withSafeTop transparent={false} /> {detailError ?? '未找到该挑战,稍后再试试吧。'} dispatch(fetchChallengeDetail(id))} > 重新加载 ); } const highlightTitle = challenge.highlightTitle ?? '立即加入挑战'; const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果'; const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战'; const isUpcoming = challenge.status === 'upcoming'; const isExpired = challenge.status === 'expired'; const upcomingStartLabel = formatMonthDay(challenge.startAt); const upcomingHighlightTitle = '挑战即将开始'; const upcomingHighlightSubtitle = upcomingStartLabel ? `${upcomingStartLabel} 开始,敬请期待` : '挑战即将开启,敬请期待'; const upcomingCtaLabel = '挑战即将开始'; const expiredEndLabel = formatMonthDay(challenge.endAt); const expiredHighlightTitle = '挑战已结束'; const expiredHighlightSubtitle = expiredEndLabel ? `${expiredEndLabel} 已截止,期待下一次挑战` : '本轮挑战已结束,期待下一次挑战'; const expiredCtaLabel = '挑战已结束'; const leaveHighlightTitle = '先别急着离开'; const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了'; const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战'; let floatingHighlightTitle = highlightTitle; let floatingHighlightSubtitle = highlightSubtitle; let floatingCtaLabel = joinCtaLabel; let floatingOnPress: (() => void) | undefined = handleJoin; let floatingDisabled = joinStatus === 'loading'; let floatingError = joinError; let isDisabledButtonState = false; if (isJoined) { floatingHighlightTitle = leaveHighlightTitle; floatingHighlightSubtitle = leaveHighlightSubtitle; floatingCtaLabel = leaveCtaLabel; floatingOnPress = handleLeaveConfirm; floatingDisabled = leaveStatus === 'loading'; floatingError = leaveError; } if (isUpcoming) { floatingHighlightTitle = upcomingHighlightTitle; floatingHighlightSubtitle = upcomingHighlightSubtitle; floatingCtaLabel = upcomingCtaLabel; floatingOnPress = undefined; floatingDisabled = true; floatingError = undefined; isDisabledButtonState = true; } if (isExpired) { floatingHighlightTitle = expiredHighlightTitle; floatingHighlightSubtitle = expiredHighlightSubtitle; floatingCtaLabel = expiredCtaLabel; floatingOnPress = undefined; floatingDisabled = true; floatingError = undefined; isDisabledButtonState = true; } const floatingGradientColors = isDisabledButtonState ? CTA_DISABLED_GRADIENT : CTA_GRADIENT; const participantsLabel = formatParticipantsLabel(challenge.participantsCount); const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined; return ( // // // } /> {challenge.title} {challenge.summary ? {challenge.summary} : null} {inlineErrorMessage ? ( {inlineErrorMessage} ) : null} {progress ? ( ) : null} {dateRangeLabel} {challenge.durationLabel} {challenge.requirementLabel} 按日打卡自动累计 {participantsLabel} {participantAvatars.length ? ( {participantAvatars.map((avatar, index) => ( 0 && styles.avatarOffset]} cachePolicy={'memory-disk'} /> ))} {challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? ( 更多 ) : null} ) : null} 排行榜 查看全部 {challenge.rankingDescription ? ( {challenge.rankingDescription} ) : null} {rankingData.length ? ( rankingData.map((item, index) => ( 0} unit={challenge?.unit} /> )) ) : ( 榜单即将开启,快来抢占席位。 )} {floatingHighlightTitle} {floatingHighlightSubtitle} {floatingError ? {floatingError} : null} {floatingCtaLabel} {showCelebration && ( )} ); } const styles = StyleSheet.create({ container: { flex: 1, }, safeArea: { flex: 1, backgroundColor: '#f3f4fb', }, headerOverlay: { position: 'absolute', left: 0, right: 0, top: 0, zIndex: 20, }, heroContainer: { height: HERO_HEIGHT, width: '100%', overflow: 'hidden', position: 'absolute', top: 0 }, heroImage: { width: '100%', height: '100%', }, scrollView: { flex: 1, }, scrollContent: { paddingBottom: Platform.select({ ios: 40, default: 28 }), }, progressCardWrapper: { marginTop: 20, marginHorizontal: 24, }, floatingCTAContainer: { position: 'absolute', left: 0, right: 0, bottom: 0, paddingHorizontal: 20, }, floatingCTABlur: { borderRadius: 24, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(255,255,255,0.6)', backgroundColor: 'rgba(243, 244, 251, 0.85)', }, floatingCTAContent: { flexDirection: 'row', alignItems: 'center', paddingVertical: 16, paddingHorizontal: 20, }, highlightCopy: { flex: 1, marginRight: 16, }, headerTextBlock: { paddingHorizontal: 24, marginTop: HERO_HEIGHT - 60, alignItems: 'center', }, periodLabel: { fontSize: 14, color: '#596095', letterSpacing: 0.2, }, title: { marginTop: 10, fontSize: 24, fontWeight: '800', color: '#1c1f3a', textAlign: 'center', }, summary: { marginTop: 12, fontSize: 14, lineHeight: 20, color: '#7080b4', textAlign: 'center', }, inlineError: { marginTop: 12, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 12, backgroundColor: 'rgba(255, 107, 107, 0.12)', flexDirection: 'row', alignItems: 'center', }, inlineErrorText: { marginLeft: 6, fontSize: 12, color: '#FF6B6B', flexShrink: 1, }, detailCard: { marginTop: 28, marginHorizontal: 20, padding: 20, borderRadius: 28, backgroundColor: '#ffffff', shadowColor: 'rgba(30, 41, 59, 0.18)', shadowOpacity: 0.2, shadowRadius: 20, shadowOffset: { width: 0, height: 12 }, elevation: 8, gap: 20, }, detailRow: { flexDirection: 'row', alignItems: 'center', }, detailIconWrapper: { width: 42, height: 42, alignItems: 'center', justifyContent: 'center', }, detailTextWrapper: { marginLeft: 14, }, detailLabel: { fontSize: 15, fontWeight: '600', color: '#1c1f3a', }, detailMeta: { marginTop: 4, fontSize: 12, color: '#6f7ba7', }, avatarRow: { flexDirection: 'row', alignItems: 'center', marginTop: 12, }, avatar: { width: 36, height: 36, borderRadius: 18, borderWidth: 2, borderColor: '#fff', }, avatarOffset: { marginLeft: -12, }, moreAvatarButton: { marginLeft: 12, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 14, backgroundColor: '#EEF0FF', }, moreAvatarText: { fontSize: 12, color: '#4F5BD5', fontWeight: '600', }, sectionHeader: { marginTop: 36, marginHorizontal: 24, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, sectionTitle: { fontSize: 18, fontWeight: '700', color: '#1c1f3a', }, sectionAction: { fontSize: 13, fontWeight: '600', color: '#5F6BF0', }, sectionSubtitle: { marginTop: 8, marginHorizontal: 24, fontSize: 13, color: '#6f7ba7', lineHeight: 18, }, rankingCard: { marginTop: 20, 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', }, emptyRankingText: { fontSize: 14, color: '#6f7ba7', }, highlightTitle: { fontSize: 16, fontWeight: '700', color: '#1c1f3a', }, highlightSubtitle: { marginTop: 4, fontSize: 12, color: '#5f6a97', lineHeight: 18, }, ctaErrorText: { marginTop: 8, fontSize: 12, color: '#FF6B6B', }, highlightButton: { borderRadius: 22, overflow: 'hidden', }, highlightButtonBackground: { borderRadius: 22, paddingVertical: 10, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center', }, highlightButtonLabel: { fontSize: 14, fontWeight: '700', color: '#ffffff', }, highlightButtonLabelDisabled: { color: '#6f7799', }, circularButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.24)', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: 'rgba(255,255,255,0.45)', }, shareIcon: { fontSize: 18, color: '#ffffff', fontWeight: '700', }, missingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, }, missingText: { fontSize: 16, textAlign: 'center', }, retryButton: { marginTop: 18, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 22, borderWidth: 1, }, retryText: { fontSize: 14, fontWeight: '600', }, celebrationOverlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', zIndex: 40, }, celebrationAnimation: { width: width * 1.3, height: width * 1.3, }, });