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