- 修改标签页的名称和图标,将“探索”改为“统计”,并更新相关逻辑 - 新增统计页面,展示用户健康数据和历史记录 - 优化首页布局,调整组件显示,提升用户体验 - 删除不再使用的代码,简化项目结构
560 lines
18 KiB
TypeScript
560 lines
18 KiB
TypeScript
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 { listRecommendedArticles } from '@/services/articles';
|
||
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||
import { loadPlans } from '@/store/trainingPlanSlice';
|
||
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||
import { TrainingPlan } from '@/services/trainingPlanApi';
|
||
import { useRouter } from 'expo-router';
|
||
import React from 'react';
|
||
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||
import { 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('/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 getFallbackItems = React.useCallback((): RecommendItem[] => {
|
||
return [
|
||
{
|
||
type: 'plan',
|
||
key: 'today-workout',
|
||
image:
|
||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||
title: '今日训练',
|
||
subtitle: '完成一次普拉提训练,记录你的坚持',
|
||
level: '初学者',
|
||
onPress: () => pushIfAuthedElseLogin('/workout/today'),
|
||
},
|
||
{
|
||
type: 'plan',
|
||
key: 'assess',
|
||
image:
|
||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||
title: '体态评估',
|
||
subtitle: '评估你的体态,制定训练计划',
|
||
level: '初学者',
|
||
onPress: () => router.push('/ai-posture-assessment'),
|
||
},
|
||
...listRecommendedArticles().map((a) => ({
|
||
type: 'article' as const,
|
||
key: `article-${a.id}`,
|
||
id: a.id,
|
||
title: a.title,
|
||
coverImage: a.coverImage,
|
||
publishedAt: a.publishedAt,
|
||
readCount: a.readCount,
|
||
})),
|
||
];
|
||
}, [router, pushIfAuthedElseLogin]);
|
||
|
||
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
|
||
|
||
// 加载训练计划数据
|
||
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('/workout/today'),
|
||
});
|
||
}
|
||
}
|
||
// 若接口返回空,也回退到打底
|
||
setItems(mapped.length > 0 ? mapped : getFallbackItems());
|
||
} catch (e) {
|
||
console.error('fetchRecommendations error', e);
|
||
setItems(getFallbackItems());
|
||
}
|
||
}
|
||
load();
|
||
return () => { canceled = true; };
|
||
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
||
|
||
// 处理点击训练计划卡片,跳转到锻炼tab
|
||
const handlePlanCardPress = () => {
|
||
if (activePlan) {
|
||
// 跳转到训练计划页面的锻炼tab,并传递planId参数
|
||
router.push(`/training-plan?planId=${activePlan.id}&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('/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('/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('/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,
|
||
},
|
||
});
|