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]);