feat(challenges): 新增挑战模块与详情页,优化标签栏布局

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

524
app/explore.tsx Normal file
View File

@@ -0,0 +1,524 @@
import { ArticleCard } from '@/components/ArticleCard';
import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
import { loadPlans } from '@/store/trainingPlanSlice';
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { QUERY_PARAMS, ROUTE_PARAMS, ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { TrainingPlan } from '@/services/trainingPlanApi';
import { useRouter } from 'expo-router';
import React from 'react';
import { Animated, Image, PanResponder, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
export default function HomeScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// 训练计划状态
const { plans } = useAppSelector((s) => s.trainingPlan);
const [activePlan, setActivePlan] = React.useState<TrainingPlan | null>(null);
// Draggable coach badge state
const pan = React.useRef(new Animated.ValueXY()).current;
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
const hasInitPos = React.useRef(false);
const startRef = React.useRef({ x: 0, y: 0 });
const dragState = React.useRef({ moved: false });
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const panResponder = React.useMemo(() => PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: (_evt, gesture) => Math.abs(gesture.dx) + Math.abs(gesture.dy) > 2,
onPanResponderGrant: () => {
dragState.current.moved = false;
// @ts-ignore access current value
const currentX = (pan.x as any)._value ?? 0;
// @ts-ignore access current value
const currentY = (pan.y as any)._value ?? 0;
startRef.current = { x: currentX, y: currentY };
},
onPanResponderMove: (_evt, gesture) => {
if (!dragState.current.moved && (Math.abs(gesture.dx) + Math.abs(gesture.dy) > 4)) {
dragState.current.moved = true;
}
const nextX = startRef.current.x + gesture.dx;
const nextY = startRef.current.y + gesture.dy;
pan.setValue({ x: nextX, y: nextY });
},
onPanResponderRelease: (_evt, gesture) => {
const minX = 8;
const minY = insets.top + 2;
const maxX = Math.max(minX, windowWidth - coachSize.width - 8);
const maxY = Math.max(minY, windowHeight - coachSize.height - (insets.bottom + 8));
const rawX = startRef.current.x + gesture.dx;
const rawY = startRef.current.y + gesture.dy;
const clampedX = clamp(rawX, minX, maxX);
const clampedY = clamp(rawY, minY, maxY);
// Snap horizontally to nearest side (left/right only)
const distLeft = Math.abs(clampedX - minX);
const distRight = Math.abs(maxX - clampedX);
const snapX = distLeft <= distRight ? minX : maxX;
Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => {
if (!dragState.current.moved) {
// 切换到教练 tab并传递name参数
router.push(`${ROUTES.TAB_COACH}?${QUERY_PARAMS.COACH_NAME}=Iris` as any);
}
});
},
}), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]);
// 推荐项类型(本地 UI 使用)
type RecommendItem =
| {
type: 'plan';
key: string;
image: string;
title: string;
subtitle: string;
level?: '初学者' | '中级' | '高级';
onPress?: () => void;
}
| {
type: 'article';
key: string;
id: string;
title: string;
coverImage: string;
publishedAt: string;
readCount: number;
};
const [items, setItems] = React.useState<RecommendItem[]>();
// 加载训练计划数据
React.useEffect(() => {
if (isLoggedIn) {
dispatch(loadPlans());
}
}, [isLoggedIn, dispatch]);
// 获取激活的训练计划
React.useEffect(() => {
if (isLoggedIn && plans.length > 0) {
const currentPlan = plans.find(p => p.isActive);
setActivePlan(currentPlan || null);
} else {
setActivePlan(null);
}
}, [isLoggedIn, plans]);
// 拉取推荐接口(已登录时)
React.useEffect(() => {
let canceled = false;
async function load() {
try {
const cards = await fetchRecommendations();
if (canceled) return;
const mapped: RecommendItem[] = [];
for (const c of cards || []) {
if (c.type === RecommendationType.Article) {
const publishedAt = (c.extra && (c.extra.publishedDate || c.extra.published_at)) || new Date().toISOString();
const readCount = (c.extra && (c.extra.readCount ?? c.extra.read_count)) || 0;
mapped.push({
type: 'article',
key: c.id,
id: c.articleId || c.id,
title: c.title || '',
coverImage: c.coverUrl,
publishedAt,
readCount,
});
} else if (c.type === RecommendationType.Checkin) {
mapped.push({
type: 'plan',
key: c.id || 'checkin',
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
title: c.title || '今日训练',
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY),
});
}
}
// 若接口返回空,也回退到打底
setItems(mapped.length > 0 ? mapped : []);
} catch (e) {
console.error('fetchRecommendations error', e);
setItems([]);
}
}
load();
return () => { canceled = true; };
}, [isLoggedIn, pushIfAuthedElseLogin]);
// 处理点击训练计划卡片跳转到锻炼tab
const handlePlanCardPress = () => {
if (activePlan) {
// 跳转到训练计划页面的锻炼tab并传递planId参数
router.push(`${ROUTES.TRAINING_PLAN}?${ROUTE_PARAMS.TRAINING_PLAN_ID}=${activePlan.id}&${ROUTE_PARAMS.TRAINING_PLAN_TAB}=${QUERY_PARAMS.TRAINING_PLAN_TAB_SCHEDULE}` as any);
}
};
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
{/* Floating Coach Badge */}
<View pointerEvents="box-none" style={styles.coachOverlayWrap}>
<Animated.View
{...panResponder.panHandlers}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
if (width !== coachSize.width || height !== coachSize.height) {
setCoachSize({ width, height });
}
if (!hasInitPos.current && width > 0 && windowWidth > 0) {
const initX = windowWidth - width - 14;
const initY = insets.top + 2; // 默认更靠上,避免遮挡搜索框
pan.setValue({ x: initX, y: initY });
hasInitPos.current = true;
}
}}
style={[
styles.coachBadge,
{
transform: [{ translateX: pan.x }, { translateY: pan.y }],
backgroundColor: colorTokens.heroSurfaceTint,
borderColor: 'rgba(187,242,70,0.35)',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
position: 'absolute',
left: 0,
top: 0,
},
]}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg' }}
style={styles.coachAvatar}
/>
<View style={styles.coachMeta}>
<ThemedText style={styles.coachName}>Iris</ThemedText>
<View style={styles.coachStatusRow}>
<View style={styles.statusDot} />
<ThemedText style={styles.coachStatusText}>线</ThemedText>
</View>
</View>
</Animated.View>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */}
{/* <View style={styles.header}>
<ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
<ThemedText style={styles.userName}></ThemedText>
</View> */}
{/* Search Box */}
<SearchBox placeholder="搜索" />
{/* Hot Features Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.featureGrid}>
<Pressable
style={[styles.featureCard, styles.featureCardQuinary]}
onPress={() => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY)}
>
<View style={styles.featureIconWrapper}>
<View style={styles.featureIconPlaceholder}>
<Image
source={require('@/assets/images/icons/iconWorkout.png')}
style={styles.featureIconImage}
/>
</View>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardPrimary]}
onPress={() => pushIfAuthedElseLogin(ROUTES.AI_POSTURE_ASSESSMENT)}
>
<View style={styles.featureIconWrapper}>
<Image
source={require('@/assets/images/demo/imageBody.jpeg')}
style={styles.featureIconImage}
/>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardQuaternary]}
onPress={() => pushIfAuthedElseLogin(ROUTES.TRAINING_PLAN)}
>
<View style={styles.featureIconWrapper}>
<View style={styles.featureIconPlaceholder}>
<Image
source={require('@/assets/images/icons/iconPlan.png')}
style={styles.featureIconImage}
/>
</View>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
</View>
</View>
{/* My Plan Section - 显示激活的训练计划 */}
{/* {activePlan && (
<MyPlanCard
plan={activePlan}
onPress={handlePlanCardPress}
/>
)} */}
{/* Today Plan Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.planList}>
{items?.map((item) => {
if (item.type === 'article') {
return (
<ArticleCard
key={item.key}
id={item.id}
title={item.title}
coverImage={item.coverImage}
publishedAt={item.publishedAt}
readCount={item.readCount}
/>
);
}
const card = (
<PlanCard
image={item.image}
title={item.title}
subtitle={item.subtitle}
level={item.level}
/>
);
return item.onPress ? (
<Pressable key={item.key} onPress={item.onPress}>
{card}
</Pressable>
) : (
<View key={item.key}>{card}</View>
);
})}
</View>
</View>
{/* Add some spacing at the bottom */}
<View style={styles.bottomSpacing} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F7F8FA',
},
container: {
flex: 1,
backgroundColor: '#F7F8FA',
},
header: {
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 8,
},
coachOverlayWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
},
greeting: {
fontSize: 16,
color: '#8A8A8E',
fontWeight: '400',
marginBottom: 6,
},
userName: {
fontSize: 30,
fontWeight: 'bold',
color: '#1A1A1A',
lineHeight: 36,
},
coachBadge: {
flexDirection: 'row',
alignItems: 'center',
// RN 不完全支持 gap这里用 margin 实现
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
backgroundColor: '#FFFFFF00',
},
coachAvatar: {
width: 26,
height: 26,
borderRadius: 13,
},
coachMeta: {
marginLeft: 8,
},
coachName: {
fontSize: 13,
fontWeight: '700',
color: '#192126',
},
coachStatusRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#22C55E',
marginRight: 4,
},
coachStatusText: {
fontSize: 11,
color: '#6B7280',
},
sectionContainer: {
marginTop: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1A1A1A',
paddingHorizontal: 24,
marginBottom: 18,
},
featureGrid: {
paddingHorizontal: 24,
flexDirection: 'row',
gap: 12,
},
featureCard: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#FFFFFF',
// 精致的阴影效果
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
// 渐变边框效果
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
// 添加微妙的内阴影效果
position: 'relative',
minHeight: 48,
},
featureCardPrimary: {
// 由于RN不支持CSS渐变使用渐变色背景
backgroundColor: '#667eea',
},
featureCardSecondary: {
backgroundColor: '#4facfe',
},
featureCardTertiary: {
backgroundColor: '#43e97b',
},
featureCardQuaternary: {
backgroundColor: '#fa709a',
},
featureCardQuinary: {
backgroundColor: '#f59e0b',
},
featureIconWrapper: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
// 图标容器的阴影
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 1 },
elevation: 2,
},
featureIconImage: {
width: 20,
height: 20,
borderRadius: 10,
resizeMode: 'cover',
},
featureIconPlaceholder: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
alignItems: 'center',
justifyContent: 'center',
},
featureIconText: {
fontSize: 12,
},
featureTitle: {
fontSize: 14,
fontWeight: '700',
color: '#FFFFFF',
textAlign: 'left',
letterSpacing: 0.2,
flex: 1,
},
featureSubtitle: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.85)',
lineHeight: 16,
textAlign: 'center',
fontWeight: '500',
},
planList: {
paddingHorizontal: 24,
},
// 移除旧的滑动样式
bottomSpacing: {
height: 120,
},
});