feat(challenges): 新增挑战模块与详情页,优化标签栏布局
- 新增挑战列表页 `app/(tabs)/challenges.tsx`,展示热门挑战卡片 - 新增挑战详情页 `app/challenges/[id].tsx`,支持排行榜、分享与参与 - 在标签栏中新增“挑战”入口,替换原有“发现”与“AI”页 - 调整标签栏间距与圆角,适配新布局 - 新增挑战相关路由常量 `TAB_CHALLENGES` - 迁移 `coach.tsx` 与 `explore.tsx` 至根目录,保持结构清晰
This commit is contained in:
@@ -21,8 +21,8 @@ type TabConfig = {
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
||||
goals: { icon: 'flag.fill', title: '习惯' },
|
||||
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||
personal: { icon: 'person.fill', title: '个人' },
|
||||
};
|
||||
|
||||
@@ -35,9 +35,10 @@ export default function TabLayout() {
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
explore: ROUTES.TAB_EXPLORE,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
statistics: ROUTES.TAB_STATISTICS,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
challenges: ROUTES.TAB_CHALLENGES,
|
||||
personal: ROUTES.TAB_PERSONAL,
|
||||
};
|
||||
|
||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||
@@ -69,11 +70,11 @@ export default function TabLayout() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 6,
|
||||
marginHorizontal: 2,
|
||||
marginVertical: 10,
|
||||
borderRadius: 25,
|
||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||
paddingHorizontal: isSelected ? 16 : 10,
|
||||
paddingHorizontal: isSelected ? 8 : 4,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
@@ -91,7 +92,7 @@ export default function TabLayout() {
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
numberOfLines={0 as any}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tabConfig.title}
|
||||
</Text>
|
||||
@@ -148,12 +149,12 @@ export default function TabLayout() {
|
||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginHorizontal: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
marginHorizontal: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
alignSelf: 'center',
|
||||
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||
@@ -177,7 +178,11 @@ export default function TabLayout() {
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="goals">
|
||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
||||
<Label>目标</Label>
|
||||
<Label>习惯</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="challenges">
|
||||
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
|
||||
<Label>挑战</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="personal">
|
||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
||||
@@ -193,9 +198,8 @@ export default function TabLayout() {
|
||||
>
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
|
||||
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
|
||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
297
app/(tabs)/challenges.tsx
Normal file
297
app/(tabs)/challenges.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
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 CARD_IMAGE_WIDTH = 132;
|
||||
const CARD_IMAGE_HEIGHT = 96;
|
||||
|
||||
export default function ChallengesScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const router = useRouter();
|
||||
|
||||
const gradientColors =
|
||||
theme === 'dark'
|
||||
? ['#1f2230', '#10131e']
|
||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
||||
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||
</View>
|
||||
<TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.giftButton}
|
||||
>
|
||||
<IconSymbol name="gift.fill" size={22} color={colorTokens.onPrimary} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardsContainer}>
|
||||
{CHALLENGES.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ChallengeCardProps = {
|
||||
challenge: Challenge;
|
||||
surfaceColor: string;
|
||||
textColor: string;
|
||||
mutedColor: string;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.92}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: surfaceColor,
|
||||
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: challenge.image }}
|
||||
style={styles.cardImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
|
||||
{challenge.title}
|
||||
</Text>
|
||||
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
||||
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</Text>
|
||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
type AvatarStackProps = {
|
||||
avatars: string[];
|
||||
borderColor: string;
|
||||
};
|
||||
|
||||
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
||||
return (
|
||||
<View style={styles.avatarRow}>
|
||||
{avatars.map((avatar, index) => (
|
||||
<Image
|
||||
key={`${avatar}-${index}`}
|
||||
source={{ uri: avatar }}
|
||||
style={[
|
||||
styles.avatar,
|
||||
{ borderColor },
|
||||
index === 0 ? null : styles.avatarOffset,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
marginBottom: 26,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
},
|
||||
giftShadow: {
|
||||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
borderRadius: 26,
|
||||
},
|
||||
giftButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardsContainer: {
|
||||
gap: 18,
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 28,
|
||||
padding: 18,
|
||||
alignItems: 'center',
|
||||
shadowOffset: { width: 0, height: 16 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 24,
|
||||
elevation: 6,
|
||||
},
|
||||
cardImage: {
|
||||
width: CARD_IMAGE_WIDTH,
|
||||
height: CARD_IMAGE_HEIGHT,
|
||||
borderRadius: 22,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardDate: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardParticipants: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
avatarRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
borderWidth: 2,
|
||||
},
|
||||
avatarOffset: {
|
||||
marginLeft: -12,
|
||||
},
|
||||
});
|
||||
2350
app/(tabs)/coach.tsx
2350
app/(tabs)/coach.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,524 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user