import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { fetchChallengeDetail, joinChallenge, leaveChallenge, reportChallengeProgress, selectChallengeById, selectChallengeDetailError, selectChallengeDetailStatus, selectJoinError, selectJoinStatus, selectLeaveError, selectLeaveStatus, selectProgressError, selectProgressStatus, } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; 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, Dimensions, Image, 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.86; const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF']; 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 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 progressErrorSelector = useMemo(() => (id ? selectProgressError(id) : undefined), [id]); const progressError = useAppSelector((state) => (progressErrorSelector ? progressErrorSelector(state) : undefined)); useEffect(() => { const getData = async (id: string) => { try { await dispatch(fetchChallengeDetail(id)).unwrap; } catch (error) { } } if (id) { getData(id); } }, [dispatch, id]); const [showCelebration, setShowCelebration] = useState(false); useEffect(() => { if (!showCelebration) { return; } const timer = setTimeout(() => { setShowCelebration(false); }, 2400); return () => { clearTimeout(timer); }; }, [showCelebration]); const progress = challenge?.progress; const hasProgress = Boolean(progress); const progressTarget = progress?.target ?? 0; const progressCompleted = progress?.completed ?? 0; const progressSegments = useMemo(() => { if (!hasProgress || progressTarget <= 0) return undefined; const segmentsCount = Math.max(1, Math.min(progressTarget, 18)); const completedSegments = Math.min( segmentsCount, Math.round((progressCompleted / Math.max(progressTarget, 1)) * segmentsCount), ); return { segmentsCount, completedSegments }; }, [hasProgress, progressCompleted, progressTarget]); const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]); const participantAvatars = useMemo( () => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6), [rankingData], ); 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; } try { await dispatch(joinChallenge(id)); setShowCelebration(true) } catch (error) { Toast.error('加入挑战失败') } }; const handleLeave = () => { if (!id || leaveStatus === 'loading') { return; } dispatch(leaveChallenge(id)); }; 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 ctaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战'; const participantsLabel = formatParticipantsLabel(challenge.participantsCount); const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined; const progressActionError = (progressStatus !== 'loading' && progressError) || (leaveStatus !== 'loading' && leaveError) || undefined; return ( } /> {challenge.periodLabel ?? dateRangeLabel} {challenge.title} {challenge.summary ? {challenge.summary} : null} {inlineErrorMessage ? ( {inlineErrorMessage} ) : null} {progress && progressSegments ? ( {progress.badge ? ( isHttpUrl(progress.badge) ? ( ) : ( {progress.badge} ) ) : ( 打卡中 )} {challenge.title} {progress.subtitle ? {progress.subtitle} : null} 剩余 {progress.remaining} 天 {progress.completed} / {progress.target} {Array.from({ length: progressSegments.segmentsCount }).map((_, index) => { const isComplete = index < progressSegments.completedSegments; const isFirst = index === 0; const isLast = index === progressSegments.segmentsCount - 1; return ( ); })} {isJoined ? ( <> {progressStatus === 'loading' ? '打卡中…' : '打卡 +1'} {leaveStatus === 'loading' ? '处理中…' : '退出挑战'} {progressActionError ? ( {progressActionError} ) : null} ) : null} ) : null} {dateRangeLabel} {challenge.durationLabel} {challenge.requirementLabel} 按日打卡自动累计 {participantsLabel} {participantAvatars.length ? ( {participantAvatars.map((avatar, index) => ( 0 && styles.avatarOffset]} /> ))} {challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? ( 更多 ) : null} ) : null} 排行榜 查看全部 {challenge.rankingDescription ? ( {challenge.rankingDescription} ) : null} {rankingData.length ? ( rankingData.map((item, index) => ( 0 && styles.rankingRowDivider]}> {index + 1} {item.avatar ? ( ) : ( )} {item.name} {item.metric} {item.badge ? {item.badge} : null} )) ) : ( 榜单即将开启,快来抢占席位。 )} {!isJoined && ( {highlightTitle} {highlightSubtitle} {joinError ? {joinError} : null} {ctaLabel} )} {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', borderBottomLeftRadius: 36, borderBottomRightRadius: 36, }, heroImage: { width: '100%', height: '100%', }, scrollView: { flex: 1, }, scrollContent: { paddingBottom: Platform.select({ ios: 40, default: 28 }), }, progressCardShadow: { marginTop: 20, marginHorizontal: 24, shadowColor: 'rgba(104, 119, 255, 0.25)', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.24, shadowRadius: 28, elevation: 12, borderRadius: 28, }, progressCard: { borderRadius: 28, paddingVertical: 24, paddingHorizontal: 22, backgroundColor: '#ffffff', }, progressHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', }, progressBadgeRing: { width: 68, height: 68, borderRadius: 34, backgroundColor: '#ffffff', padding: 6, shadowColor: 'rgba(67, 82, 186, 0.16)', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.4, shadowRadius: 12, elevation: 6, marginRight: 16, }, progressBadge: { width: '100%', height: '100%', borderRadius: 28, }, progressBadgeFallback: { flex: 1, height: '100%', borderRadius: 22, alignItems: 'center', justifyContent: 'center', backgroundColor: '#EEF0FF', paddingHorizontal: 6, }, progressBadgeText: { fontSize: 12, fontWeight: '700', color: '#4F5BD5', textAlign: 'center', }, progressHeadline: { flex: 1, }, progressTitle: { fontSize: 18, fontWeight: '700', color: '#1c1f3a', }, progressSubtitle: { marginTop: 6, fontSize: 13, color: '#5f6a97', }, progressRemaining: { fontSize: 13, fontWeight: '600', color: '#707baf', marginLeft: 16, alignSelf: 'flex-start', }, progressMetaRow: { marginTop: 18, }, progressMetaValue: { fontSize: 16, fontWeight: '700', color: '#4F5BD5', }, progressMetaSuffix: { fontSize: 13, fontWeight: '500', color: '#7a86bb', }, progressBarTrack: { marginTop: 16, flexDirection: 'row', alignItems: 'center', backgroundColor: '#eceffa', borderRadius: 12, paddingHorizontal: 6, paddingVertical: 4, }, progressBarSegment: { flex: 1, height: 8, borderRadius: 4, backgroundColor: '#dfe4f6', marginHorizontal: 3, }, progressBarSegmentActive: { backgroundColor: '#5E8BFF', }, progressBarSegmentFirst: { marginLeft: 0, }, progressBarSegmentLast: { marginRight: 0, }, progressActionsRow: { flexDirection: 'row', marginTop: 20, }, progressPrimaryAction: { flex: 1, paddingVertical: 12, borderRadius: 18, backgroundColor: '#5E8BFF', alignItems: 'center', justifyContent: 'center', marginRight: 12, }, progressSecondaryAction: { flex: 1, paddingVertical: 12, borderRadius: 18, borderWidth: 1, borderColor: '#d6dcff', alignItems: 'center', justifyContent: 'center', }, progressActionDisabled: { opacity: 0.6, }, progressPrimaryActionText: { fontSize: 14, fontWeight: '700', color: '#ffffff', }, progressSecondaryActionText: { fontSize: 14, fontWeight: '700', color: '#4F5BD5', }, progressErrorText: { marginTop: 12, fontSize: 12, color: '#FF6B6B', textAlign: 'center', }, 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: 24, 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, borderRadius: 21, backgroundColor: '#EFF1FF', 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, }, 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: { 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', }, 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, }, });