Files
digital-pilates/app/(tabs)/explore.tsx
richarjiang ee84a801fb feat: 更新多个组件以使用 SafeAreaView
- 在 goals-list、task-list、explore、personal、challenge/day 和 challenge/index 组件中引入 SafeAreaView,确保内容在安全区域内显示
- 移除不必要的 SafeAreaView 导入,优化代码结构
- 更新相关样式,提升用户体验和界面一致性
2025-08-25 09:37:12 +08:00

525 lines
17 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 { 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,
},
});