From 7259bd7a2cc7ecc6d7a44ecab9ce7b96fe98107e Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 28 Sep 2025 14:16:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=8E=A5=E5=8F=A3=E5=B9=B6=E5=AE=8C=E5=96=84=E6=8C=91?= =?UTF-8?q?=E6=88=98=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6=E6=83=85=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口 - 重构 challengesSlice,使用 createAsyncThunk 管理异步状态 - 列表页支持加载、空态、错误重试及状态标签 - 详情页支持进度展示、打卡、退出及错误提示 - 统一卡片与详情数据模型,支持动态状态更新 --- app/(tabs)/challenges.tsx | 173 +++++++++--- app/challenges/[id].tsx | 556 +++++++++++++++++++++++++------------- services/challengesApi.ts | 65 +++++ store/challengesSlice.ts | 459 ++++++++++++++++++++++--------- 4 files changed, 898 insertions(+), 355 deletions(-) create mode 100644 services/challengesApi.ts diff --git a/app/(tabs)/challenges.tsx b/app/(tabs)/challenges.tsx index eb2de8c..8378651 100644 --- a/app/(tabs)/challenges.tsx +++ b/app/(tabs)/challenges.tsx @@ -1,29 +1,107 @@ import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { selectChallengeCards, type ChallengeViewModel } from '@/store/challengesSlice'; +import { + fetchChallenges, + selectChallengeCards, + selectChallengesListError, + selectChallengesListStatus, + type ChallengeCardViewModel, +} from '@/store/challengesSlice'; import { LinearGradient } from 'expo-linear-gradient'; import { useRouter } from 'expo-router'; -import React from 'react'; -import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React, { useEffect } from 'react'; +import { + ActivityIndicator, + Image, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import { SafeAreaView } 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: '已结束', +}; export default function ChallengesScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const router = useRouter(); + const dispatch = useAppDispatch(); const challenges = useAppSelector(selectChallengeCards); + const listStatus = useAppSelector(selectChallengesListStatus); + const listError = useAppSelector(selectChallengesListError); + + 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 ( @@ -51,20 +129,7 @@ export default function ChallengesScreen() { - - {challenges.map((challenge) => ( - - router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) - } - /> - ))} - + {renderChallenges()} @@ -72,7 +137,7 @@ export default function ChallengesScreen() { } type ChallengeCardProps = { - challenge: ChallengeViewModel; + challenge: ChallengeCardViewModel; surfaceColor: string; textColor: string; mutedColor: string; @@ -80,6 +145,8 @@ type ChallengeCardProps = { }; function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) { + const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status; + return ( {challenge.dateRange} - {challenge.participantsLabel} - + + {challenge.participantsLabel} + {' · '} + {statusLabel} + {challenge.isJoined ? ' · 已加入' : ''} + + {challenge.progress?.badge ? ( + + {challenge.progress.badge} + + ) : null} + {challenge.avatars.length ? ( + + ) : null} ); @@ -118,17 +197,19 @@ type AvatarStackProps = { function AvatarStack({ avatars, borderColor }: AvatarStackProps) { return ( - {avatars.map((avatar, index) => ( - - ))} + {avatars + .filter(Boolean) + .map((avatar, index) => ( + + ))} ); } @@ -180,6 +261,29 @@ const styles = StyleSheet.create({ cardsContainer: { gap: 18, }, + 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: { flexDirection: 'row', borderRadius: 28, @@ -213,6 +317,11 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '500', }, + cardProgress: { + marginTop: 8, + fontSize: 13, + fontWeight: '600', + }, avatarRow: { flexDirection: 'row', marginTop: 16, diff --git a/app/challenges/[id].tsx b/app/challenges/[id].tsx index 54c0d31..a261c7e 100644 --- a/app/challenges/[id].tsx +++ b/app/challenges/[id].tsx @@ -1,8 +1,22 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { selectChallengeViewById } from '@/store/challengesSlice'; +import { + fetchChallengeDetail, + joinChallenge, + leaveChallenge, + reportChallengeProgress, + selectChallengeById, + selectChallengeDetailError, + selectChallengeDetailStatus, + selectJoinError, + selectJoinStatus, + selectLeaveError, + selectLeaveStatus, + selectProgressError, + selectProgressStatus, +} from '@/store/challengesSlice'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; import { LinearGradient } from 'expo-linear-gradient'; @@ -10,6 +24,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import LottieView from 'lottie-react-native'; import React, { useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, Dimensions, Image, Platform, @@ -25,183 +40,134 @@ 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']; -type ChallengeProgress = { - completedDays: number; - totalDays: number; - remainingDays: number; - badge: string; - subtitle?: string; +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()}日`; }; -type ChallengeDetail = { - image: string; - periodLabel: string; - durationLabel: string; - requirementLabel: string; - summary?: string; - participantsCount: number; - rankingDescription?: string; - rankings: RankingItem[]; - highlightTitle: string; - highlightSubtitle: string; - ctaLabel: string; - progress?: ChallengeProgress; +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 ?? ''; }; -type RankingItem = { - id: string; - name: string; - avatar: string; - metric: string; - badge?: string; -}; - -const DETAIL_PRESETS: Record = { - 'hydration-hippo': { - image: - 'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80', - periodLabel: '9月01日 - 9月30日 · 剩余 4 天', - durationLabel: '30 天', - requirementLabel: '喝水 1500ml 15 天以上', - summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。', - participantsCount: 9009, - rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。', - rankings: [ - { - id: 'all-1', - name: '湖光暮色', - avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80', - metric: '平均 3,200 ml', - badge: '金冠冠军', - }, - { - id: 'all-2', - name: '温柔潮汐', - avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80', - metric: '平均 2,980 ml', - }, - { - id: 'all-3', - name: '晨雾河岸', - avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80', - metric: '平均 2,860 ml', - }, - { - id: 'male-1', - name: '北岸微风', - avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80', - metric: '平均 3,120 ml', - }, - { - id: 'male-2', - name: '静水晚霞', - avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80', - metric: '平均 2,940 ml', - }, - { - id: 'female-1', - name: '露珠初晓', - avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80', - metric: '平均 3,060 ml', - }, - { - id: 'female-2', - name: '桔梗水语', - avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80', - metric: '平均 2,880 ml', - }, - ], - highlightTitle: '加入挑战', - highlightSubtitle: '畅饮打卡越多,专属奖励越丰厚', - ctaLabel: '立即加入挑战', - progress: { - completedDays: 12, - totalDays: 15, - remainingDays: 3, - badge: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?auto=format&fit=crop&w=160&q=80', - subtitle: '学河马饮,做补水人', - }, - }, -}; - -const DEFAULT_DETAIL: ChallengeDetail = { - image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80', - periodLabel: '本周进行中', - durationLabel: '30 天', - requirementLabel: '保持专注完成每日任务', - participantsCount: 3200, - highlightTitle: '立即参加,点燃动力', - highlightSubtitle: '邀请好友一起坚持,更容易收获成果', - ctaLabel: '立即加入挑战', - rankings: [], - progress: { - completedDays: 4, - totalDays: 21, - remainingDays: 5, - badge: 'https://images.unsplash.com/photo-1529257414771-1960d69cc2b3?auto=format&fit=crop&w=160&q=80', - subtitle: '坚持让好习惯生根发芽', - }, +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 ? selectChallengeViewById(id) : undefined), [id]); + const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]); const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); - const detail = useMemo(() => { - if (!id) return DEFAULT_DETAIL; - return DETAIL_PRESETS[id] ?? { - ...DEFAULT_DETAIL, - periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel, - highlightTitle: `加入 ${challenge?.title ?? '挑战'}`, - }; - }, [challenge?.dateRange, challenge?.title, id]); + 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 [hasJoined, setHasJoined] = useState(false); - const [progress, setProgress] = useState(undefined); - const [showCelebration, setShowCelebration] = useState(false); - const rankingData = detail.rankings ?? []; - const ctaGradientColors: [string, string] = ['#5E8BFF', '#6B6CFF']; - const progressSegments = useMemo(() => { - if (!progress) return undefined; - const segmentsCount = Math.max(1, Math.min(progress.totalDays, 18)); - const completedSegments = Math.min( - segmentsCount, - Math.round((progress.completedDays / Math.max(progress.totalDays, 1)) * segmentsCount), - ); - return { segmentsCount, completedSegments }; - }, [progress]); + 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(() => { - setHasJoined(false); - setProgress(undefined); + if (id) { + dispatch(fetchChallengeDetail(id)); + } + }, [dispatch, id]); + + const [showCelebration, setShowCelebration] = useState(false); + + useEffect(() => { + setShowCelebration(false); }, [id]); + useEffect(() => { + if (joinStatus === 'succeeded') { + setShowCelebration(true); + } + }, [joinStatus]); + 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, @@ -214,16 +180,30 @@ export default function ChallengeDetailScreen() { }; const handleJoin = () => { - if (hasJoined) { + if (!id || joinStatus === 'loading') { return; } - - setHasJoined(true); - setProgress(detail.progress); - setShowCelebration(true); + dispatch(joinChallenge(id)); }; - if (!challenge) { + 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} /> @@ -234,14 +214,52 @@ export default function ChallengeDetailScreen() { ); } + 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 ( - + - {detail.periodLabel} + {challenge.periodLabel ?? dateRangeLabel} {challenge.title} - {detail.summary ? {detail.summary} : null} + {challenge.summary ? {challenge.summary} : null} + {inlineErrorMessage ? ( + + + {inlineErrorMessage} + + ) : null} {progress && progressSegments ? ( @@ -290,20 +314,30 @@ export default function ChallengeDetailScreen() { > - + {progress.badge ? ( + isHttpUrl(progress.badge) ? ( + + ) : ( + + {progress.badge} + + ) + ) : ( + + 打卡中 + + )} {challenge.title} - {progress.subtitle ? ( - {progress.subtitle} - ) : null} + {progress.subtitle ? {progress.subtitle} : null} - 剩余 {progress.remainingDays} 天 + 剩余 {progress.remaining} 天 - {progress.completedDays} / {progress.totalDays} + {progress.completed} / {progress.target} @@ -326,6 +360,42 @@ export default function ChallengeDetailScreen() { ); })} + + {isJoined ? ( + <> + + + + {progressStatus === 'loading' ? '打卡中…' : '打卡 +1'} + + + + + {leaveStatus === 'loading' ? '处理中…' : '退出挑战'} + + + + {progressActionError ? ( + {progressActionError} + ) : null} + + ) : null} @@ -337,8 +407,8 @@ export default function ChallengeDetailScreen() { - {challenge.dateRange} - {detail.durationLabel} + {dateRangeLabel} + {challenge.durationLabel} @@ -347,7 +417,7 @@ export default function ChallengeDetailScreen() { - {detail.requirementLabel} + {challenge.requirementLabel} 按日打卡自动累计 @@ -356,21 +426,24 @@ export default function ChallengeDetailScreen() { - - {detail.participantsCount.toLocaleString('zh-CN')} 人正在参与 - - {challenge.avatars.slice(0, 6).map((avatar, index) => ( - 0 && styles.avatarOffset]} - /> - ))} - - 更多 - - + + {participantsLabel} + {participantAvatars.length ? ( + + {participantAvatars.map((avatar, index) => ( + 0 && styles.avatarOffset]} + /> + ))} + {challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? ( + + 更多 + + ) : null} + + ) : null} @@ -382,8 +455,8 @@ export default function ChallengeDetailScreen() { - {detail.rankingDescription ? ( - {detail.rankingDescription} + {challenge.rankingDescription ? ( + {challenge.rankingDescription} ) : null} @@ -393,7 +466,13 @@ export default function ChallengeDetailScreen() { {index + 1} - + {item.avatar ? ( + + ) : ( + + + + )} {item.name} {item.metric} @@ -407,31 +486,30 @@ export default function ChallengeDetailScreen() { )} - - {!hasJoined && ( - + + {!isJoined && ( + - {detail.highlightTitle} - {detail.highlightSubtitle} + {highlightTitle} + {highlightSubtitle} + {joinError ? {joinError} : null} - {detail.ctaLabel} + {ctaLabel} @@ -523,6 +601,21 @@ const styles = StyleSheet.create({ 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, }, @@ -581,6 +674,47 @@ const styles = StyleSheet.create({ 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, @@ -629,6 +763,21 @@ const styles = StyleSheet.create({ 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, @@ -760,6 +909,15 @@ const styles = StyleSheet.create({ borderRadius: 22, marginRight: 14, }, + rankingAvatarPlaceholder: { + width: 44, + height: 44, + borderRadius: 22, + marginRight: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#EEF0FF', + }, rankingInfo: { flex: 1, }, @@ -797,6 +955,11 @@ const styles = StyleSheet.create({ color: '#5f6a97', lineHeight: 18, }, + ctaErrorText: { + marginTop: 8, + fontSize: 12, + color: '#FF6B6B', + }, highlightButton: { borderRadius: 22, overflow: 'hidden', @@ -838,6 +1001,17 @@ const styles = StyleSheet.create({ 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', diff --git a/services/challengesApi.ts b/services/challengesApi.ts new file mode 100644 index 0000000..745f5f2 --- /dev/null +++ b/services/challengesApi.ts @@ -0,0 +1,65 @@ +import { api } from './api'; + +export type ChallengeStatus = 'upcoming' | 'ongoing' | 'expired'; + +export type ChallengeProgressDto = { + completed: number; + target: number; + remaining: number; + badge: string; + subtitle?: string; +}; + +export type RankingItemDto = { + id: string; + name: string; + avatar: string | null; + metric: string; + badge?: string; +}; + +export type ChallengeListItemDto = { + id: string; + title: string; + image: string; + periodLabel?: string; + durationLabel: string; + requirementLabel: string; + status: ChallengeStatus; + participantsCount: number; + rankingDescription?: string; + highlightTitle: string; + highlightSubtitle: string; + ctaLabel: string; + progress?: ChallengeProgressDto; + isJoined: boolean; + startAt?: string; + endAt?: string; +}; + +export type ChallengeDetailDto = ChallengeListItemDto & { + summary?: string; + rankings: RankingItemDto[]; + userRank?: number; +}; + +export async function listChallenges(): Promise { + return api.get('/challenges'); +} + +export async function getChallengeDetail(id: string): Promise { + return api.get(`/challenges/${encodeURIComponent(id)}`); +} + +export async function joinChallenge(id: string): Promise { + return api.post(`/challenges/${encodeURIComponent(id)}/join`); +} + +export async function leaveChallenge(id: string): Promise { + return api.post(`/challenges/${encodeURIComponent(id)}/leave`); +} + +export async function reportChallengeProgress(id: string, increment?: number): Promise { + const body = increment != null ? { increment } : undefined; + return api.post(`/challenges/${encodeURIComponent(id)}/progress`, body); +} diff --git a/store/challengesSlice.ts b/store/challengesSlice.ts index 83d90a5..3fe80a7 100644 --- a/store/challengesSlice.ts +++ b/store/challengesSlice.ts @@ -1,169 +1,364 @@ -import { createSelector, createSlice } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit'; +import { + type ChallengeDetailDto, + type ChallengeListItemDto, + type ChallengeProgressDto, + type ChallengeStatus, + type RankingItemDto, + getChallengeDetail, + joinChallenge as joinChallengeApi, + leaveChallenge as leaveChallengeApi, + listChallenges, + reportChallengeProgress as reportChallengeProgressApi, +} from '@/services/challengesApi'; import type { RootState } from './index'; -export type ChallengeDefinition = { - id: string; - title: string; - startDate: string; // YYYY-MM-DD - endDate: string; // YYYY-MM-DD - participantsCount: number; - participantsUnit: string; - image: string; - avatars: string[]; -}; +type AsyncStatus = 'idle' | 'loading' | 'succeeded' | 'failed'; -export type ChallengeViewModel = ChallengeDefinition & { - dateRange: string; - participantsLabel: string; +export type ChallengeProgress = ChallengeProgressDto; +export type RankingItem = RankingItemDto; +export type ChallengeSummary = ChallengeListItemDto; +export type ChallengeDetail = ChallengeDetailDto; +export type { ChallengeStatus }; +export type ChallengeEntity = ChallengeSummary & { + summary?: string; + rankings?: RankingItem[]; + userRank?: number; }; type ChallengesState = { - entities: Record; + entities: Record; order: string[]; + listStatus: AsyncStatus; + listError?: string; + detailStatus: Record; + detailError: Record; + joinStatus: Record; + joinError: Record; + leaveStatus: Record; + leaveError: Record; + progressStatus: Record; + progressError: Record; }; -const initialChallenges: ChallengeDefinition[] = [ - { - id: 'joyful-dog-run', - title: '遛狗跑步,欢乐一路', - startDate: '2024-09-01', - endDate: '2024-09-30', - participantsCount: 6364, - participantsUnit: '跑者', - image: - 'https://images.unsplash.com/photo-1525253086316-d0c936c814f8?auto=format&fit=crop&w=1200&q=80', - avatars: [ - 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1544723795-3fbce826f51f?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1502823403499-6ccfcf4fb453?auto=format&fit=crop&w=200&q=80', - ], - }, - { - id: 'penguin-swim', - title: '企鹅宝宝的游泳预备班', - startDate: '2024-09-01', - endDate: '2024-09-30', - participantsCount: 3334, - participantsUnit: '游泳者', - image: - 'https://images.unsplash.com/photo-1531297484001-80022131f5a1?auto=format&fit=crop&w=1200&q=80', - avatars: [ - 'https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1530268729831-4b0b9e170218?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=200&q=80', - ], - }, - { - id: 'hydration-hippo', - title: '学河马饮,做补水人', - startDate: '2024-09-01', - endDate: '2024-09-30', - participantsCount: 9009, - participantsUnit: '饮水者', - image: - 'https://images.unsplash.com/photo-1481931098730-318b6f776db0?auto=format&fit=crop&w=1200&q=80', - avatars: [ - 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1544723660-4bfa6584218e?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1544723795-3fbfb7c6a9f1?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=200&q=80', - ], - }, - { - id: 'autumn-cycling', - title: '炎夏渐散,踏板骑秋', - startDate: '2024-09-01', - endDate: '2024-09-30', - participantsCount: 4617, - participantsUnit: '骑行者', - image: - 'https://images.unsplash.com/photo-1509395176047-4a66953fd231?auto=format&fit=crop&w=1200&q=80', - avatars: [ - 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80', - ], - }, - { - id: 'falcon-core', - title: '燃卡加练甄秋腰', - startDate: '2024-09-01', - endDate: '2024-09-30', - participantsCount: 11995, - participantsUnit: '健身爱好者', - image: - 'https://images.unsplash.com/photo-1494871262121-6adf66e90adf?auto=format&fit=crop&w=1200&q=80', - avatars: [ - 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=200&q=80', - 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80', - ], - }, -]; - const initialState: ChallengesState = { - entities: initialChallenges.reduce>((acc, challenge) => { - acc[challenge.id] = challenge; - return acc; - }, {}), - order: initialChallenges.map((challenge) => challenge.id), + entities: {}, + order: [], + listStatus: 'idle', + listError: undefined, + detailStatus: {}, + detailError: {}, + joinStatus: {}, + joinError: {}, + leaveStatus: {}, + leaveError: {}, + progressStatus: {}, + progressError: {}, }; +const toErrorMessage = (error: unknown): string => { + if (typeof error === 'string') { + return error; + } + if (error instanceof Error && error.message) { + return error.message; + } + return '请求失败,请稍后再试'; +}; + +export const fetchChallenges = createAsyncThunk( + 'challenges/fetchAll', + async (_, { rejectWithValue }) => { + try { + return await listChallenges(); + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } + } +); + +export const fetchChallengeDetail = createAsyncThunk( + 'challenges/fetchDetail', + async (id, { rejectWithValue }) => { + try { + return await getChallengeDetail(id); + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } + } +); + +export const joinChallenge = createAsyncThunk<{ id: string; progress: ChallengeProgress }, string, { rejectValue: string }>( + 'challenges/join', + async (id, { rejectWithValue }) => { + try { + const progress = await joinChallengeApi(id); + return { id, progress }; + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } + } +); + +export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectValue: string }>( + 'challenges/leave', + async (id, { rejectWithValue }) => { + try { + await leaveChallengeApi(id); + return { id }; + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } + } +); + +export const reportChallengeProgress = createAsyncThunk< + { id: string; progress: ChallengeProgress }, + { id: string; increment?: number }, + { rejectValue: string } +>('challenges/reportProgress', async ({ id, increment }, { rejectWithValue }) => { + try { + const progress = await reportChallengeProgressApi(id, increment); + return { id, progress }; + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } +}); + const challengesSlice = createSlice({ name: 'challenges', initialState, reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchChallenges.pending, (state) => { + state.listStatus = 'loading'; + state.listError = undefined; + }) + .addCase(fetchChallenges.fulfilled, (state, action) => { + state.listStatus = 'succeeded'; + state.listError = undefined; + const ids = new Set(); + action.payload.forEach((challenge) => { + ids.add(challenge.id); + const existing = state.entities[challenge.id]; + if (existing) { + Object.assign(existing, challenge); + } else { + state.entities[challenge.id] = { ...challenge }; + } + }); + Object.keys(state.entities).forEach((id) => { + if (!ids.has(id)) { + delete state.entities[id]; + delete state.detailStatus[id]; + delete state.detailError[id]; + delete state.joinStatus[id]; + delete state.joinError[id]; + delete state.leaveStatus[id]; + delete state.leaveError[id]; + delete state.progressStatus[id]; + delete state.progressError[id]; + } + }); + state.order = action.payload.map((item) => item.id); + }) + .addCase(fetchChallenges.rejected, (state, action) => { + state.listStatus = 'failed'; + state.listError = action.payload ?? toErrorMessage(action.error); + }) + .addCase(fetchChallengeDetail.pending, (state, action) => { + const id = action.meta.arg; + state.detailStatus[id] = 'loading'; + state.detailError[id] = undefined; + }) + .addCase(fetchChallengeDetail.fulfilled, (state, action) => { + const detail = action.payload; + state.detailStatus[detail.id] = 'succeeded'; + state.detailError[detail.id] = undefined; + const existing = state.entities[detail.id]; + if (existing) { + Object.assign(existing, detail); + } else { + state.entities[detail.id] = { ...detail }; + } + }) + .addCase(fetchChallengeDetail.rejected, (state, action) => { + const id = action.meta.arg; + state.detailStatus[id] = 'failed'; + state.detailError[id] = action.payload ?? toErrorMessage(action.error); + }) + .addCase(joinChallenge.pending, (state, action) => { + const id = action.meta.arg; + state.joinStatus[id] = 'loading'; + state.joinError[id] = undefined; + }) + .addCase(joinChallenge.fulfilled, (state, action) => { + const { id, progress } = action.payload; + state.joinStatus[id] = 'succeeded'; + state.joinError[id] = undefined; + const entity = state.entities[id]; + if (entity) { + entity.isJoined = true; + entity.progress = progress; + } + }) + .addCase(joinChallenge.rejected, (state, action) => { + const id = action.meta.arg; + state.joinStatus[id] = 'failed'; + state.joinError[id] = action.payload ?? toErrorMessage(action.error); + }) + .addCase(leaveChallenge.pending, (state, action) => { + const id = action.meta.arg; + state.leaveStatus[id] = 'loading'; + state.leaveError[id] = undefined; + }) + .addCase(leaveChallenge.fulfilled, (state, action) => { + const { id } = action.payload; + state.leaveStatus[id] = 'succeeded'; + state.leaveError[id] = undefined; + const entity = state.entities[id]; + if (entity) { + entity.isJoined = false; + delete entity.progress; + } + }) + .addCase(leaveChallenge.rejected, (state, action) => { + const id = action.meta.arg; + state.leaveStatus[id] = 'failed'; + state.leaveError[id] = action.payload ?? toErrorMessage(action.error); + }) + .addCase(reportChallengeProgress.pending, (state, action) => { + const id = action.meta.arg.id; + state.progressStatus[id] = 'loading'; + state.progressError[id] = undefined; + }) + .addCase(reportChallengeProgress.fulfilled, (state, action) => { + const { id, progress } = action.payload; + state.progressStatus[id] = 'succeeded'; + state.progressError[id] = undefined; + const entity = state.entities[id]; + if (entity) { + entity.progress = progress; + } + }) + .addCase(reportChallengeProgress.rejected, (state, action) => { + const id = action.meta.arg.id; + state.progressStatus[id] = 'failed'; + state.progressError[id] = action.payload ?? toErrorMessage(action.error); + }); + }, }); export default challengesSlice.reducer; const selectChallengesState = (state: RootState) => state.challenges; -export const selectChallengeEntities = createSelector([selectChallengesState], (state) => state.entities); +export const selectChallengesListStatus = createSelector( + [selectChallengesState], + (state) => state.listStatus +); -export const selectChallengeOrder = createSelector([selectChallengesState], (state) => state.order); +export const selectChallengesListError = createSelector( + [selectChallengesState], + (state) => state.listError +); -const formatNumberWithSeparator = (value: number): string => value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +export const selectChallengeEntities = createSelector( + [selectChallengesState], + (state) => state.entities +); -const formatDateLabel = (value: string): string => { - const [year, month, day] = value.split('-'); - if (!month || !day) { - return value; - } - const monthNumber = parseInt(month, 10); - const dayNumber = parseInt(day, 10); - const paddedDay = Number.isNaN(dayNumber) ? day : dayNumber.toString().padStart(2, '0'); - if (Number.isNaN(monthNumber)) { - return value; - } - return `${monthNumber}月${paddedDay}日`; -}; - -const toViewModel = (challenge: ChallengeDefinition): ChallengeViewModel => ({ - ...challenge, - dateRange: `${formatDateLabel(challenge.startDate)} - ${formatDateLabel(challenge.endDate)}`, - participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} ${challenge.participantsUnit}`, -}); +export const selectChallengeOrder = createSelector( + [selectChallengesState], + (state) => state.order +); export const selectChallengeList = createSelector( [selectChallengeEntities, selectChallengeOrder], - (entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeDefinition[], + (entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[] ); +const formatNumberWithSeparator = (value: number): string => + value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + +const formatMonthDay = (input: string | undefined): string | undefined => { + if (!input) return undefined; + const date = new Date(input); + if (Number.isNaN(date.getTime())) return undefined; + return `${date.getMonth() + 1}月${date.getDate()}日`; +}; + +const buildDateRangeLabel = (challenge: ChallengeEntity): string => { + const startLabel = formatMonthDay(challenge.startAt); + const endLabel = formatMonthDay(challenge.endAt); + if (startLabel && endLabel) { + return `${startLabel} - ${endLabel}`; + } + return challenge.periodLabel ?? challenge.durationLabel; +}; + +export type ChallengeCardViewModel = { + id: string; + title: string; + image: string; + dateRange: string; + participantsLabel: string; + status: ChallengeStatus; + isJoined: boolean; + periodLabel?: string; + durationLabel: string; + requirementLabel: string; + highlightTitle: string; + highlightSubtitle: string; + ctaLabel: string; + progress?: ChallengeProgress; + avatars: string[]; +}; + export const selectChallengeCards = createSelector([selectChallengeList], (challenges) => - challenges.map((challenge) => toViewModel(challenge)) + challenges.map((challenge) => ({ + id: challenge.id, + title: challenge.title, + image: challenge.image, + dateRange: buildDateRangeLabel(challenge), + participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`, + status: challenge.status, + isJoined: challenge.isJoined, + periodLabel: challenge.periodLabel, + durationLabel: challenge.durationLabel, + requirementLabel: challenge.requirementLabel, + highlightTitle: challenge.highlightTitle, + highlightSubtitle: challenge.highlightSubtitle, + ctaLabel: challenge.ctaLabel, + progress: challenge.progress, + avatars: [], + })) ); export const selectChallengeById = (id: string) => createSelector([selectChallengeEntities], (entities) => entities[id]); -export const selectChallengeViewById = (id: string) => - createSelector([selectChallengeEntities], (entities) => { - const challenge = entities[id]; - return challenge ? toViewModel(challenge) : undefined; - }); +export const selectChallengeDetailStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.detailStatus[id] ?? 'idle'); +export const selectChallengeDetailError = (id: string) => + createSelector([selectChallengesState], (state) => state.detailError[id]); + +export const selectJoinStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.joinStatus[id] ?? 'idle'); + +export const selectJoinError = (id: string) => + createSelector([selectChallengesState], (state) => state.joinError[id]); + +export const selectLeaveStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.leaveStatus[id] ?? 'idle'); + +export const selectLeaveError = (id: string) => + createSelector([selectChallengesState], (state) => state.leaveError[id]); + +export const selectProgressStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.progressStatus[id] ?? 'idle'); + +export const selectProgressError = (id: string) => + createSelector([selectChallengesState], (state) => state.progressError[id]);