From 2b86ac17a64c0a2a6cfb14067d5b32e354299281 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 28 Sep 2025 08:29:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=8C=91=E6=88=98=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/challenges.tsx | 79 +--- app/challenges/[id].tsx | 754 +++++++++++++++++++++++--------------- store/challengesSlice.ts | 169 +++++++++ store/index.ts | 3 +- 4 files changed, 638 insertions(+), 367 deletions(-) create mode 100644 store/challengesSlice.ts diff --git a/app/(tabs)/challenges.tsx b/app/(tabs)/challenges.tsx index 1e9b095..eb2de8c 100644 --- a/app/(tabs)/challenges.tsx +++ b/app/(tabs)/challenges.tsx @@ -1,82 +1,14 @@ import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; +import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { selectChallengeCards, type ChallengeViewModel } 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 { SafeAreaView } from 'react-native-safe-area-context'; -export const CHALLENGES = [ - { - id: 'joyful-dog-run', - title: '遛狗跑步,欢乐一路', - dateRange: '9月01日 - 9月30日', - participantsLabel: '6,364 跑者', - 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: '企鹅宝宝的游泳预备班', - dateRange: '9月01日 - 9月30日', - participantsLabel: '3,334 游泳者', - 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: '学河马饮,做补水人', - dateRange: '9月01日 - 9月30日', - participantsLabel: '9,009 饮水者', - 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: '炎夏渐散,踏板骑秋', - dateRange: '9月01日 - 9月30日', - participantsLabel: '4,617 骑行者', - 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: '燃卡加练甄秋腰', - dateRange: '9月01日 - 9月30日', - participantsLabel: '11,995 健身爱好者', - 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', - ], - }, -] as const; - -export type Challenge = (typeof CHALLENGES)[number]; - const AVATAR_SIZE = 36; const CARD_IMAGE_WIDTH = 132; const CARD_IMAGE_HEIGHT = 96; @@ -85,8 +17,9 @@ export default function ChallengesScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const router = useRouter(); + const challenges = useAppSelector(selectChallengeCards); - const gradientColors = + const gradientColors: [string, string] = theme === 'dark' ? ['#1f2230', '#10131e'] : [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]; @@ -119,7 +52,7 @@ export default function ChallengesScreen() { - {CHALLENGES.map((challenge) => ( + {challenges.map((challenge) => ( ; + rankings: RankingItem[]; highlightTitle: string; highlightSubtitle: string; ctaLabel: string; + progress?: ChallengeProgress; }; type RankingItem = { @@ -48,7 +59,7 @@ type RankingItem = { const DETAIL_PRESETS: Record = { 'hydration-hippo': { - badgeImage: + image: 'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80', periodLabel: '9月01日 - 9月30日 · 剩余 4 天', durationLabel: '30 天', @@ -56,65 +67,66 @@ const DETAIL_PRESETS: Record = { summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。', participantsCount: 9009, rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。', - rankings: { - all: [ - { - 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', - }, - ], - male: [ - { - 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', - }, - ], - female: [ - { - 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', - }, - ], + 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: '学河马饮,做补水人', }, - highlightTitle: '分享一次,免费参与', - highlightSubtitle: '解锁高级会员,无限加入挑战', - ctaLabel: '马上分享激励好友', }, }; const DEFAULT_DETAIL: ChallengeDetail = { - badgeImage: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80', + image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80', periodLabel: '本周进行中', durationLabel: '30 天', requirementLabel: '保持专注完成每日任务', @@ -122,19 +134,16 @@ const DEFAULT_DETAIL: ChallengeDetail = { highlightTitle: '立即参加,点燃动力', highlightSubtitle: '邀请好友一起坚持,更容易收获成果', ctaLabel: '立即加入挑战', - rankings: { - all: [], + 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 SEGMENTS = [ - { key: 'all', label: '全部' }, - { key: 'male', label: '男生' }, - { key: 'female', label: '女生' }, -] as const; - -type SegmentKey = (typeof SEGMENTS)[number]['key']; - export default function ChallengeDetailScreen() { const { id } = useLocalSearchParams<{ id?: string }>(); const router = useRouter(); @@ -142,10 +151,8 @@ export default function ChallengeDetailScreen() { const colorTokens = Colors[theme]; const insets = useSafeAreaInsets(); - const challenge = useMemo(() => { - if (!id) return undefined; - return CHALLENGES.find((item) => item.id === id); - }, [id]); + const challengeSelector = useMemo(() => (id ? selectChallengeViewById(id) : undefined), [id]); + const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); const detail = useMemo(() => { if (!id) return DEFAULT_DETAIL; @@ -156,9 +163,39 @@ export default function ChallengeDetailScreen() { }; }, [challenge?.dateRange, challenge?.title, id]); - const [segment, setSegment] = useState('all'); + 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 rankingData = detail.rankings[segment] ?? detail.rankings.all ?? []; + useEffect(() => { + setHasJoined(false); + setProgress(undefined); + }, [id]); + + useEffect(() => { + if (!showCelebration) { + return; + } + + const timer = setTimeout(() => { + setShowCelebration(false); + }, 2400); + + return () => { + clearTimeout(timer); + }; + }, [showCelebration]); const handleShare = async () => { if (!challenge) { @@ -177,8 +214,13 @@ export default function ChallengeDetailScreen() { }; const handleJoin = () => { - // 当前没有具体业务流程,先回退到挑战列表 - router.back(); + if (hasJoined) { + return; + } + + setHasJoined(true); + setProgress(detail.progress); + setShowCelebration(true); }; if (!challenge) { @@ -193,169 +235,228 @@ export default function ChallengeDetailScreen() { } return ( - + - - - - - } - /> - - - - - - + + + + + } /> - - - + + + + - - - {detail.periodLabel} - {challenge.title} - {detail.summary ? {detail.summary} : null} - + + {detail.periodLabel} + {challenge.title} + {detail.summary ? {detail.summary} : null} + - - - - + {progress && progressSegments ? ( + + + + + + + + + {challenge.title} + {progress.subtitle ? ( + {progress.subtitle} + ) : null} + + 剩余 {progress.remainingDays} 天 + + + + + {progress.completedDays} / {progress.totalDays} + + + + + + {Array.from({ length: progressSegments.segmentsCount }).map((_, index) => { + const isComplete = index < progressSegments.completedSegments; + const isFirst = index === 0; + const isLast = index === progressSegments.segmentsCount - 1; + return ( + + ); + })} + + + - - {challenge.dateRange} - {detail.durationLabel} + ) : null} + + + + + + + + {challenge.dateRange} + {detail.durationLabel} + + + + + + + + + {detail.requirementLabel} + 按日打卡自动累计 + + + + + + + + + {detail.participantsCount.toLocaleString('zh-CN')} 人正在参与 + + {challenge.avatars.slice(0, 6).map((avatar, index) => ( + 0 && styles.avatarOffset]} + /> + ))} + + 更多 + + + - - - - - - {detail.requirementLabel} - 按日打卡自动累计 - + + 排行榜 + + 查看全部 + - - - - - - {detail.participantsCount.toLocaleString('zh-CN')} 人正在参与 - - {challenge.avatars.slice(0, 6).map((avatar, index) => ( - 0 && styles.avatarOffset]} - /> - ))} - - 更多 + {detail.rankingDescription ? ( + {detail.rankingDescription} + ) : null} + + + {rankingData.length ? ( + rankingData.map((item, index) => ( + 0 && styles.rankingRowDivider]}> + + {index + 1} + + + + {item.name} + {item.metric} + + {item.badge ? {item.badge} : null} + + )) + ) : ( + + 榜单即将开启,快来抢占席位。 + + )} + + + + {!hasJoined && ( + + + + + {detail.highlightTitle} + {detail.highlightSubtitle} + + + + {detail.ctaLabel} + - + - - - - 排行榜 - - 查看全部 - - - - {detail.rankingDescription ? ( - {detail.rankingDescription} - ) : null} - - - {SEGMENTS.map(({ key, label }) => { - const isActive = segment === key; - const disabled = !(detail.rankings[key] && detail.rankings[key].length); - return ( - { - if (disabled) return; - setSegment(key); - }} - > - - {label} - - - ); - })} - - - - {rankingData.length ? ( - rankingData.map((item, index) => ( - 0 && styles.rankingRowDivider]}> - - {index + 1} - - - - {item.name} - {item.metric} - - {item.badge ? {item.badge} : null} - - )) - ) : ( - - 榜单即将开启,快来抢占席位。 - - )} - - - - + {showCelebration && ( + + - {detail.highlightTitle} - {detail.highlightSubtitle} - - {detail.ctaLabel} - - - + )} + ); } const styles = StyleSheet.create({ + container: { + flex: 1, + }, safeArea: { flex: 1, backgroundColor: '#f3f4fb', @@ -384,25 +485,125 @@ const styles = StyleSheet.create({ scrollContent: { paddingBottom: Platform.select({ ios: 40, default: 28 }), }, - badgeWrapper: { - alignItems: 'center', - marginTop: -BADGE_SIZE / 2, - }, - badgeShadow: { - width: BADGE_SIZE, - height: BADGE_SIZE, - borderRadius: BADGE_SIZE / 2, - backgroundColor: '#fff', - padding: 12, - shadowColor: 'rgba(17, 24, 39, 0.2)', - shadowOpacity: 0.25, - shadowRadius: 18, - shadowOffset: { width: 0, height: 10 }, + 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, }, - badgeImage: { + 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, + }, + progressHeadline: { flex: 1, - borderRadius: BADGE_SIZE / 2, + }, + 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, + }, + 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, @@ -517,43 +718,6 @@ const styles = StyleSheet.create({ color: '#6f7ba7', lineHeight: 18, }, - segmentedControl: { - marginTop: 20, - marginHorizontal: 24, - borderRadius: 20, - backgroundColor: '#EAECFB', - padding: 4, - flexDirection: 'row', - }, - segmentButton: { - flex: 1, - paddingVertical: 8, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - }, - segmentButtonActive: { - backgroundColor: '#fff', - shadowColor: 'rgba(79, 91, 213, 0.25)', - shadowOpacity: 0.3, - shadowRadius: 10, - shadowOffset: { width: 0, height: 6 }, - elevation: 4, - }, - segmentDisabled: { - opacity: 0.5, - }, - segmentLabel: { - fontSize: 13, - fontWeight: '600', - color: '#6372C6', - }, - segmentLabelActive: { - color: '#4F5BD5', - }, - segmentLabelDisabled: { - color: '#9AA3CF', - }, rankingCard: { marginTop: 20, marginHorizontal: 24, @@ -622,36 +786,30 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#6f7ba7', }, - highlightCard: { - marginTop: 32, - marginHorizontal: 24, - borderRadius: 28, - paddingVertical: 28, - paddingHorizontal: 24, - overflow: 'hidden', - }, highlightTitle: { - fontSize: 18, - fontWeight: '800', - color: '#ffffff', + fontSize: 16, + fontWeight: '700', + color: '#1c1f3a', }, highlightSubtitle: { - marginTop: 10, - fontSize: 14, - color: 'rgba(255,255,255,0.85)', - lineHeight: 20, + marginTop: 4, + fontSize: 12, + color: '#5f6a97', + lineHeight: 18, }, highlightButton: { - marginTop: 22, - backgroundColor: 'rgba(255,255,255,0.18)', - paddingVertical: 12, borderRadius: 22, + overflow: 'hidden', + }, + highlightButtonBackground: { + borderRadius: 22, + paddingVertical: 10, + paddingHorizontal: 18, alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(247,248,255,0.5)', + justifyContent: 'center', }, highlightButtonLabel: { - fontSize: 15, + fontSize: 14, fontWeight: '700', color: '#ffffff', }, @@ -680,4 +838,14 @@ const styles = StyleSheet.create({ fontSize: 16, textAlign: 'center', }, + celebrationOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + zIndex: 40, + }, + celebrationAnimation: { + width: width * 1.3, + height: width * 1.3, + }, }); diff --git a/store/challengesSlice.ts b/store/challengesSlice.ts new file mode 100644 index 0000000..83d90a5 --- /dev/null +++ b/store/challengesSlice.ts @@ -0,0 +1,169 @@ +import { createSelector, createSlice } from '@reduxjs/toolkit'; +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[]; +}; + +export type ChallengeViewModel = ChallengeDefinition & { + dateRange: string; + participantsLabel: string; +}; + +type ChallengesState = { + entities: Record; + order: string[]; +}; + +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), +}; + +const challengesSlice = createSlice({ + name: 'challenges', + initialState, + reducers: {}, +}); + +export default challengesSlice.reducer; + +const selectChallengesState = (state: RootState) => state.challenges; + +export const selectChallengeEntities = createSelector([selectChallengesState], (state) => state.entities); + +export const selectChallengeOrder = createSelector([selectChallengesState], (state) => state.order); + +const formatNumberWithSeparator = (value: number): string => value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + +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 selectChallengeList = createSelector( + [selectChallengeEntities, selectChallengeOrder], + (entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeDefinition[], +); + +export const selectChallengeCards = createSelector([selectChallengeList], (challenges) => + challenges.map((challenge) => toViewModel(challenge)) +); + +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; + }); + diff --git a/store/index.ts b/store/index.ts index 94f4ed2..1178b5e 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,5 +1,6 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import challengeReducer from './challengeSlice'; +import challengesReducer from './challengesSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import circumferenceReducer from './circumferenceSlice'; import exerciseLibraryReducer from './exerciseLibrarySlice'; @@ -48,6 +49,7 @@ export const store = configureStore({ reducer: { user: userReducer, challenge: challengeReducer, + challenges: challengesReducer, checkin: checkinReducer, circumference: circumferenceReducer, goals: goalsReducer, @@ -70,4 +72,3 @@ export const store = configureStore({ export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; -