feat: 支持活动挑战页面
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
169
store/challengesSlice.ts
Normal 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;
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user