Files
digital-pilates/app/challenges/[id].tsx
richarjiang e2597c1bc4 feat(challenges): 新增挑战模块与详情页,优化标签栏布局
- 新增挑战列表页 `app/(tabs)/challenges.tsx`,展示热门挑战卡片
- 新增挑战详情页 `app/challenges/[id].tsx`,支持排行榜、分享与参与
- 在标签栏中新增“挑战”入口,替换原有“发现”与“AI”页
- 调整标签栏间距与圆角,适配新布局
- 新增挑战相关路由常量 `TAB_CHALLENGES`
- 迁移 `coach.tsx` 与 `explore.tsx` 至根目录,保持结构清晰
2025-09-26 17:29:00 +08:00

684 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CHALLENGES, type Challenge } from '@/app/(tabs)/challenges';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useState } from 'react';
import {
Dimensions,
Image,
Platform,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.86;
const BADGE_SIZE = 120;
type ChallengeDetail = {
badgeImage: string;
periodLabel: string;
durationLabel: string;
requirementLabel: string;
summary?: string;
participantsCount: number;
rankingDescription?: string;
rankings: Record<string, RankingItem[]>;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
};
type RankingItem = {
id: string;
name: string;
avatar: string;
metric: string;
badge?: string;
};
const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
'hydration-hippo': {
badgeImage:
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
durationLabel: '30 天',
requirementLabel: '喝水 1500ml 15 天以上',
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
participantsCount: 9009,
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
rankings: {
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',
},
],
},
highlightTitle: '分享一次,免费参与',
highlightSubtitle: '解锁高级会员,无限加入挑战',
ctaLabel: '马上分享激励好友',
},
};
const DEFAULT_DETAIL: ChallengeDetail = {
badgeImage: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80',
periodLabel: '本周进行中',
durationLabel: '30 天',
requirementLabel: '保持专注完成每日任务',
participantsCount: 3200,
highlightTitle: '立即参加,点燃动力',
highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
ctaLabel: '立即加入挑战',
rankings: {
all: [],
},
};
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();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const challenge = useMemo<Challenge | undefined>(() => {
if (!id) return undefined;
return CHALLENGES.find((item) => item.id === id);
}, [id]);
const detail = useMemo<ChallengeDetail>(() => {
if (!id) return DEFAULT_DETAIL;
return DETAIL_PRESETS[id] ?? {
...DEFAULT_DETAIL,
periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel,
highlightTitle: `加入 ${challenge?.title ?? '挑战'}`,
};
}, [challenge?.dateRange, challenge?.title, id]);
const [segment, setSegment] = useState<SegmentKey>('all');
const rankingData = detail.rankings[segment] ?? detail.rankings.all ?? [];
const handleShare = async () => {
if (!challenge) {
return;
}
try {
await Share.share({
title: challenge.title,
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
url: challenge.image,
});
} catch (error) {
console.warn('分享失败', error);
}
};
const handleJoin = () => {
// 当前没有具体业务流程,先回退到挑战列表
router.back();
};
if (!challenge) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']} >
<StatusBar barStyle="light-content" />
<View
pointerEvents="box-none"
style={[styles.headerOverlay, { paddingTop: insets.top }]}
>
<HeaderBar
title=""
tone="light"
transparent
withSafeTop={false}
right={
<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 style={styles.badgeWrapper}>
<View style={styles.badgeShadow}>
<Image source={{ uri: detail.badgeImage }} style={styles.badgeImage} resizeMode="cover" />
</View>
</View>
<View style={styles.headerTextBlock}>
<Text style={styles.periodLabel}>{detail.periodLabel}</Text>
<Text style={styles.title}>{challenge.title}</Text>
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
</View>
<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 style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity>
<Text style={styles.sectionAction}></Text>
</TouchableOpacity>
</View>
{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>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f3f4fb',
},
headerOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 20,
},
heroContainer: {
height: HERO_HEIGHT,
width: '100%',
overflow: 'hidden',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
heroImage: {
width: '100%',
height: '100%',
},
scrollView: {
flex: 1,
},
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 },
elevation: 12,
},
badgeImage: {
flex: 1,
borderRadius: BADGE_SIZE / 2,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: 24,
alignItems: 'center',
},
periodLabel: {
fontSize: 14,
color: '#596095',
letterSpacing: 0.2,
},
title: {
marginTop: 10,
fontSize: 24,
fontWeight: '800',
color: '#1c1f3a',
textAlign: 'center',
},
summary: {
marginTop: 12,
fontSize: 14,
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
},
detailCard: {
marginTop: 28,
marginHorizontal: 20,
padding: 20,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 12 },
elevation: 8,
gap: 20,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
},
detailIconWrapper: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: '#EFF1FF',
alignItems: 'center',
justifyContent: 'center',
},
detailTextWrapper: {
marginLeft: 14,
},
detailLabel: {
fontSize: 15,
fontWeight: '600',
color: '#1c1f3a',
},
detailMeta: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 2,
borderColor: '#fff',
},
avatarOffset: {
marginLeft: -12,
},
moreAvatarButton: {
marginLeft: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#EEF0FF',
},
moreAvatarText: {
fontSize: 12,
color: '#4F5BD5',
fontWeight: '600',
},
sectionHeader: {
marginTop: 36,
marginHorizontal: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
},
sectionSubtitle: {
marginTop: 8,
marginHorizontal: 24,
fontSize: 13,
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,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 10,
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
},
rankingRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
},
rankingRowDivider: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#E5E7FF',
},
rankingOrderCircle: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
marginRight: 12,
},
rankingOrder: {
fontSize: 15,
fontWeight: '700',
color: '#4F5BD5',
},
rankingAvatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 14,
},
rankingInfo: {
flex: 1,
},
rankingName: {
fontSize: 15,
fontWeight: '700',
color: '#1c1f3a',
},
rankingMetric: {
marginTop: 4,
fontSize: 13,
color: '#6f7ba7',
},
rankingBadge: {
fontSize: 12,
color: '#A67CFF',
fontWeight: '700',
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
highlightCard: {
marginTop: 32,
marginHorizontal: 24,
borderRadius: 28,
paddingVertical: 28,
paddingHorizontal: 24,
overflow: 'hidden',
},
highlightTitle: {
fontSize: 18,
fontWeight: '800',
color: '#ffffff',
},
highlightSubtitle: {
marginTop: 10,
fontSize: 14,
color: 'rgba(255,255,255,0.85)',
lineHeight: 20,
},
highlightButton: {
marginTop: 22,
backgroundColor: 'rgba(255,255,255,0.18)',
paddingVertical: 12,
borderRadius: 22,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(247,248,255,0.5)',
},
highlightButtonLabel: {
fontSize: 15,
fontWeight: '700',
color: '#ffffff',
},
circularButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.24)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.45)',
},
shareIcon: {
fontSize: 18,
color: '#ffffff',
fontWeight: '700',
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 16,
textAlign: 'center',
},
});