feat: 支持活动挑战页面

This commit is contained in:
richarjiang
2025-09-28 08:29:10 +08:00
parent e2597c1bc4
commit 2b86ac17a6
4 changed files with 638 additions and 367 deletions

View File

@@ -1,82 +1,14 @@
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { selectChallengeCards, type ChallengeViewModel } from '@/store/challengesSlice';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React from 'react'; import React from 'react';
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132; const CARD_IMAGE_WIDTH = 132;
const CARD_IMAGE_HEIGHT = 96; const CARD_IMAGE_HEIGHT = 96;
@@ -85,8 +17,9 @@ export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const router = useRouter(); const router = useRouter();
const challenges = useAppSelector(selectChallengeCards);
const gradientColors = const gradientColors: [string, string] =
theme === 'dark' theme === 'dark'
? ['#1f2230', '#10131e'] ? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]; : [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
@@ -119,7 +52,7 @@ export default function ChallengesScreen() {
</View> </View>
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
{CHALLENGES.map((challenge) => ( {challenges.map((challenge) => (
<ChallengeCard <ChallengeCard
key={challenge.id} key={challenge.id}
challenge={challenge} challenge={challenge}
@@ -139,7 +72,7 @@ export default function ChallengesScreen() {
} }
type ChallengeCardProps = { type ChallengeCardProps = {
challenge: Challenge; challenge: ChallengeViewModel;
surfaceColor: string; surfaceColor: string;
textColor: string; textColor: string;
mutedColor: string; mutedColor: string;

View File

@@ -1,11 +1,14 @@
import { CHALLENGES, type Challenge } from '@/app/(tabs)/challenges';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { selectChallengeViewById } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useState } from 'react'; import LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useState } from 'react';
import { import {
Dimensions, Dimensions,
Image, Image,
@@ -22,20 +25,28 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.86; const HERO_HEIGHT = width * 0.86;
const BADGE_SIZE = 120;
type ChallengeProgress = {
completedDays: number;
totalDays: number;
remainingDays: number;
badge: string;
subtitle?: string;
};
type ChallengeDetail = { type ChallengeDetail = {
badgeImage: string; image: string;
periodLabel: string; periodLabel: string;
durationLabel: string; durationLabel: string;
requirementLabel: string; requirementLabel: string;
summary?: string; summary?: string;
participantsCount: number; participantsCount: number;
rankingDescription?: string; rankingDescription?: string;
rankings: Record<string, RankingItem[]>; rankings: RankingItem[];
highlightTitle: string; highlightTitle: string;
highlightSubtitle: string; highlightSubtitle: string;
ctaLabel: string; ctaLabel: string;
progress?: ChallengeProgress;
}; };
type RankingItem = { type RankingItem = {
@@ -48,7 +59,7 @@ type RankingItem = {
const DETAIL_PRESETS: Record<string, ChallengeDetail> = { const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
'hydration-hippo': { 'hydration-hippo': {
badgeImage: image:
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80', 'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
periodLabel: '9月01日 - 9月30日 · 剩余 4 天', periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
durationLabel: '30 天', durationLabel: '30 天',
@@ -56,65 +67,66 @@ const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。', summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
participantsCount: 9009, participantsCount: 9009,
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。', rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
rankings: { rankings: [
all: [ {
{ id: 'all-1',
id: 'all-1', name: '湖光暮色',
name: '湖光暮色', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80', metric: '平均 3,200 ml',
metric: '平均 3,200 ml', badge: '金冠冠军',
badge: '金冠冠军', },
}, {
{ id: 'all-2',
id: 'all-2', name: '温柔潮汐',
name: '温柔潮汐', avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80', metric: '平均 2,980 ml',
metric: '平均 2,980 ml', },
}, {
{ id: 'all-3',
id: 'all-3', name: '晨雾河岸',
name: '晨雾河岸', avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80',
avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80', metric: '平均 2,860 ml',
metric: '平均 2,860 ml', },
}, {
], id: 'male-1',
male: [ name: '北岸微风',
{ avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80',
id: 'male-1', metric: '平均 3,120 ml',
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',
id: 'male-2', metric: '平均 2,940 ml',
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',
female: [ metric: '平均 3,060 ml',
{ },
id: 'female-1', {
name: '露珠初晓', id: 'female-2',
avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80', name: '桔梗水语',
metric: '平均 3,060 ml', avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80',
}, metric: '平均 2,880 ml',
{ },
id: 'female-2', ],
name: '桔梗水语', highlightTitle: '加入挑战',
avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80', highlightSubtitle: '畅饮打卡越多,专属奖励越丰厚',
metric: '平均 2,880 ml', 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 = { 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: '本周进行中', periodLabel: '本周进行中',
durationLabel: '30 天', durationLabel: '30 天',
requirementLabel: '保持专注完成每日任务', requirementLabel: '保持专注完成每日任务',
@@ -122,19 +134,16 @@ const DEFAULT_DETAIL: ChallengeDetail = {
highlightTitle: '立即参加,点燃动力', highlightTitle: '立即参加,点燃动力',
highlightSubtitle: '邀请好友一起坚持,更容易收获成果', highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
ctaLabel: '立即加入挑战', ctaLabel: '立即加入挑战',
rankings: { rankings: [],
all: [], 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() { export default function ChallengeDetailScreen() {
const { id } = useLocalSearchParams<{ id?: string }>(); const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter(); const router = useRouter();
@@ -142,10 +151,8 @@ export default function ChallengeDetailScreen() {
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const challenge = useMemo<Challenge | undefined>(() => { const challengeSelector = useMemo(() => (id ? selectChallengeViewById(id) : undefined), [id]);
if (!id) return undefined; const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
return CHALLENGES.find((item) => item.id === id);
}, [id]);
const detail = useMemo<ChallengeDetail>(() => { const detail = useMemo<ChallengeDetail>(() => {
if (!id) return DEFAULT_DETAIL; if (!id) return DEFAULT_DETAIL;
@@ -156,9 +163,39 @@ export default function ChallengeDetailScreen() {
}; };
}, [challenge?.dateRange, challenge?.title, id]); }, [challenge?.dateRange, challenge?.title, id]);
const [segment, setSegment] = useState<SegmentKey>('all'); const [hasJoined, setHasJoined] = useState(false);
const [progress, setProgress] = useState<ChallengeProgress | undefined>(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 () => { const handleShare = async () => {
if (!challenge) { if (!challenge) {
@@ -177,8 +214,13 @@ export default function ChallengeDetailScreen() {
}; };
const handleJoin = () => { const handleJoin = () => {
// 当前没有具体业务流程,先回退到挑战列表 if (hasJoined) {
router.back(); return;
}
setHasJoined(true);
setProgress(detail.progress);
setShowCelebration(true);
}; };
if (!challenge) { if (!challenge) {
@@ -193,169 +235,228 @@ export default function ChallengeDetailScreen() {
} }
return ( return (
<SafeAreaView style={styles.safeArea} edges={['bottom']} > <View style={styles.safeArea}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View <View style={styles.container}>
pointerEvents="box-none" <View
style={[styles.headerOverlay, { paddingTop: insets.top }]} pointerEvents="box-none"
> style={[styles.headerOverlay, { paddingTop: insets.top }]}
<HeaderBar >
title="" <HeaderBar
tone="light" title=""
transparent backColor="white"
withSafeTop={false} tone="light"
right={ transparent
<TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}> withSafeTop={false}
<Ionicons name="share-social-outline" size={20} color="#ffffff" /> right={
</TouchableOpacity> <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
} <Ionicons name="share-social-outline" size={20} color="#ffffff" />
/> </TouchableOpacity>
</View> }
<ScrollView
style={styles.scrollView}
bounces
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent]}
>
<View style={styles.heroContainer}>
<Image source={{ uri: challenge.image }} style={styles.heroImage} resizeMode="cover" />
<LinearGradient
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
style={StyleSheet.absoluteFillObject}
/> />
</View> </View>
<View style={styles.badgeWrapper}> <ScrollView
<View style={styles.badgeShadow}> style={styles.scrollView}
<Image source={{ uri: detail.badgeImage }} style={styles.badgeImage} resizeMode="cover" /> bounces
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 160) + insets.bottom },
]}
>
<View style={styles.heroContainer}>
<Image source={{ uri: challenge.image }} style={styles.heroImage} resizeMode="cover" />
<LinearGradient
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
style={StyleSheet.absoluteFillObject}
/>
</View> </View>
</View>
<View style={styles.headerTextBlock}> <View style={styles.headerTextBlock}>
<Text style={styles.periodLabel}>{detail.periodLabel}</Text> <Text style={styles.periodLabel}>{detail.periodLabel}</Text>
<Text style={styles.title}>{challenge.title}</Text> <Text style={styles.title}>{challenge.title}</Text>
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null} {detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
</View> </View>
<View style={styles.detailCard}> {progress && progressSegments ? (
<View style={styles.detailRow}> <View>
<View style={styles.detailIconWrapper}> <View style={styles.progressCardShadow}>
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" /> <LinearGradient
colors={['#ffffff', '#ffffff']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.progressCard}
>
<View style={styles.progressHeaderRow}>
<View style={styles.progressBadgeRing}>
<Image source={{ uri: progress.badge }} style={styles.progressBadge} />
</View>
<View style={styles.progressHeadline}>
<Text style={styles.progressTitle}>{challenge.title}</Text>
{progress.subtitle ? (
<Text style={styles.progressSubtitle}>{progress.subtitle}</Text>
) : null}
</View>
<Text style={styles.progressRemaining}> {progress.remainingDays} </Text>
</View>
<View style={styles.progressMetaRow}>
<Text style={styles.progressMetaValue}>
{progress.completedDays} / {progress.totalDays}
<Text style={styles.progressMetaSuffix}> </Text>
</Text>
</View>
<View style={styles.progressBarTrack}>
{Array.from({ length: progressSegments.segmentsCount }).map((_, index) => {
const isComplete = index < progressSegments.completedSegments;
const isFirst = index === 0;
const isLast = index === progressSegments.segmentsCount - 1;
return (
<View
key={`progress-segment-${index}`}
style={[
styles.progressBarSegment,
isComplete && styles.progressBarSegmentActive,
isFirst && styles.progressBarSegmentFirst,
isLast && styles.progressBarSegmentLast,
]}
/>
);
})}
</View>
</LinearGradient>
</View>
</View> </View>
<View style={styles.detailTextWrapper}> ) : null}
<Text style={styles.detailLabel}>{challenge.dateRange}</Text>
<Text style={styles.detailMeta}>{detail.durationLabel}</Text> <View style={styles.detailCard}>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.dateRange}</Text>
<Text style={styles.detailMeta}>{detail.durationLabel}</Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{detail.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
</View>
<View style={[styles.detailTextWrapper, { flex: 1 }]}
>
<Text style={styles.detailLabel}>{detail.participantsCount.toLocaleString('zh-CN')} </Text>
<View style={styles.avatarRow}>
{challenge.avatars.slice(0, 6).map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[styles.avatar, index > 0 && styles.avatarOffset]}
/>
))}
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
</View>
</View>
</View> </View>
</View> </View>
<View style={styles.detailRow}> <View style={styles.sectionHeader}>
<View style={styles.detailIconWrapper}> <Text style={styles.sectionTitle}></Text>
<Ionicons name="flag-outline" size={20} color="#4F5BD5" /> <TouchableOpacity>
</View> <Text style={styles.sectionAction}></Text>
<View style={styles.detailTextWrapper}> </TouchableOpacity>
<Text style={styles.detailLabel}>{detail.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
</View>
</View> </View>
<View style={styles.detailRow}> {detail.rankingDescription ? (
<View style={styles.detailIconWrapper}> <Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
<Ionicons name="people-outline" size={20} color="#4F5BD5" /> ) : null}
</View>
<View style={[styles.detailTextWrapper, { flex: 1 }]} <View style={styles.rankingCard}>
> {rankingData.length ? (
<Text style={styles.detailLabel}>{detail.participantsCount.toLocaleString('zh-CN')} </Text> rankingData.map((item, index) => (
<View style={styles.avatarRow}> <View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
{challenge.avatars.slice(0, 6).map((avatar, index) => ( <View style={styles.rankingOrderCircle}>
<Image <Text style={styles.rankingOrder}>{index + 1}</Text>
key={`${avatar}-${index}`} </View>
source={{ uri: avatar }} <Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
style={[styles.avatar, index > 0 && styles.avatarOffset]} <View style={styles.rankingInfo}>
/> <Text style={styles.rankingName}>{item.name}</Text>
))} <Text style={styles.rankingMetric}>{item.metric}</Text>
<TouchableOpacity style={styles.moreAvatarButton}> </View>
<Text style={styles.moreAvatarText}></Text> {item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
</View>
</ScrollView>
{!hasJoined && (
<View
pointerEvents="box-none"
style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}
>
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{detail.highlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
</View>
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={handleJoin}
>
<LinearGradient
colors={ctaGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </BlurView>
</View> </View>
</View> )}
</View>
<View style={styles.sectionHeader}> {showCelebration && (
<Text style={styles.sectionTitle}></Text> <View pointerEvents="none" style={styles.celebrationOverlay}>
<TouchableOpacity> <LottieView
<Text style={styles.sectionAction}></Text> autoPlay
</TouchableOpacity> loop={false}
</View> source={require('@/assets/lottie/Confetti.json')}
style={styles.celebrationAnimation}
{detail.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
) : null}
<View style={styles.segmentedControl}>
{SEGMENTS.map(({ key, label }) => {
const isActive = segment === key;
const disabled = !(detail.rankings[key] && detail.rankings[key].length);
return (
<TouchableOpacity
key={key}
style={[styles.segmentButton, isActive && styles.segmentButtonActive, disabled && styles.segmentDisabled]}
activeOpacity={disabled ? 1 : 0.8}
onPress={() => {
if (disabled) return;
setSegment(key);
}}
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive, disabled && styles.segmentLabelDisabled]}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.rankingCard}>
{rankingData.length ? (
rankingData.map((item, index) => (
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
<View style={styles.rankingInfo}>
<Text style={styles.rankingName}>{item.name}</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
</View>
<View style={styles.highlightCard}>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFillObject}
/> />
<Text style={styles.highlightTitle}>{detail.highlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
<TouchableOpacity style={styles.highlightButton} activeOpacity={0.9} onPress={handleJoin}>
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
</TouchableOpacity>
</View> </View>
</ScrollView> )}
</SafeAreaView> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: { safeArea: {
flex: 1, flex: 1,
backgroundColor: '#f3f4fb', backgroundColor: '#f3f4fb',
@@ -384,25 +485,125 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }), paddingBottom: Platform.select({ ios: 40, default: 28 }),
}, },
badgeWrapper: { progressCardShadow: {
alignItems: 'center', marginTop: 20,
marginTop: -BADGE_SIZE / 2, marginHorizontal: 24,
}, shadowColor: 'rgba(104, 119, 255, 0.25)',
badgeShadow: { shadowOffset: { width: 0, height: 16 },
width: BADGE_SIZE, shadowOpacity: 0.24,
height: BADGE_SIZE, shadowRadius: 28,
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 },
elevation: 12, 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, 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: { headerTextBlock: {
paddingHorizontal: 24, paddingHorizontal: 24,
@@ -517,43 +718,6 @@ const styles = StyleSheet.create({
color: '#6f7ba7', color: '#6f7ba7',
lineHeight: 18, 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: { rankingCard: {
marginTop: 20, marginTop: 20,
marginHorizontal: 24, marginHorizontal: 24,
@@ -622,36 +786,30 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#6f7ba7', color: '#6f7ba7',
}, },
highlightCard: {
marginTop: 32,
marginHorizontal: 24,
borderRadius: 28,
paddingVertical: 28,
paddingHorizontal: 24,
overflow: 'hidden',
},
highlightTitle: { highlightTitle: {
fontSize: 18, fontSize: 16,
fontWeight: '800', fontWeight: '700',
color: '#ffffff', color: '#1c1f3a',
}, },
highlightSubtitle: { highlightSubtitle: {
marginTop: 10, marginTop: 4,
fontSize: 14, fontSize: 12,
color: 'rgba(255,255,255,0.85)', color: '#5f6a97',
lineHeight: 20, lineHeight: 18,
}, },
highlightButton: { highlightButton: {
marginTop: 22,
backgroundColor: 'rgba(255,255,255,0.18)',
paddingVertical: 12,
borderRadius: 22, borderRadius: 22,
overflow: 'hidden',
},
highlightButtonBackground: {
borderRadius: 22,
paddingVertical: 10,
paddingHorizontal: 18,
alignItems: 'center', alignItems: 'center',
borderWidth: 1, justifyContent: 'center',
borderColor: 'rgba(247,248,255,0.5)',
}, },
highlightButtonLabel: { highlightButtonLabel: {
fontSize: 15, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#ffffff', color: '#ffffff',
}, },
@@ -680,4 +838,14 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
textAlign: 'center', textAlign: 'center',
}, },
celebrationOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
},
celebrationAnimation: {
width: width * 1.3,
height: width * 1.3,
},
}); });

169
store/challengesSlice.ts Normal file
View File

@@ -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<string, ChallengeDefinition>;
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<Record<string, ChallengeDefinition>>((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;
});

View File

@@ -1,5 +1,6 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import challengeReducer from './challengeSlice'; import challengeReducer from './challengeSlice';
import challengesReducer from './challengesSlice';
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
import circumferenceReducer from './circumferenceSlice'; import circumferenceReducer from './circumferenceSlice';
import exerciseLibraryReducer from './exerciseLibrarySlice'; import exerciseLibraryReducer from './exerciseLibrarySlice';
@@ -48,6 +49,7 @@ export const store = configureStore({
reducer: { reducer: {
user: userReducer, user: userReducer,
challenge: challengeReducer, challenge: challengeReducer,
challenges: challengesReducer,
checkin: checkinReducer, checkin: checkinReducer,
circumference: circumferenceReducer, circumference: circumferenceReducer,
goals: goalsReducer, goals: goalsReducer,
@@ -70,4 +72,3 @@ export const store = configureStore({
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;