- 在教练页面中新增饮食方案卡片,展示用户的饮食计划和相关数据 - 实现BMI计算、每日推荐摄入热量计算及营养素分配功能 - 优化体重历史记录卡片,增加动画效果提升用户体验 - 更新统计页面样式,增加体重卡片样式和按钮功能 - 修复获取HRV值的逻辑,确保数据准确性
565 lines
16 KiB
TypeScript
565 lines
16 KiB
TypeScript
import { Colors } from '@/constants/Colors';
|
||
import { ROUTES } from '@/constants/Routes';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||
import { fetchWeightHistory } from '@/store/userSlice';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import dayjs from 'dayjs';
|
||
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
Dimensions,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import Animated, {
|
||
Extrapolation,
|
||
interpolate,
|
||
runOnJS,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withTiming,
|
||
} from 'react-native-reanimated';
|
||
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
|
||
|
||
const { width: screenWidth } = Dimensions.get('window');
|
||
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
|
||
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
|
||
const CHART_HEIGHT = 100;
|
||
const PADDING = 10;
|
||
|
||
|
||
export function WeightHistoryCard() {
|
||
const dispatch = useAppDispatch();
|
||
const userProfile = useAppSelector((s) => s.user.profile);
|
||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [showChart, setShowChart] = useState(false);
|
||
|
||
// 动画相关状态
|
||
const animationProgress = useSharedValue(0);
|
||
const [isAnimating, setIsAnimating] = useState(false);
|
||
|
||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||
|
||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||
|
||
useEffect(() => {
|
||
if (hasWeight) {
|
||
loadWeightHistory();
|
||
}
|
||
}, [hasWeight]);
|
||
|
||
const loadWeightHistory = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
await dispatch(fetchWeightHistory() as any);
|
||
} catch (error) {
|
||
console.error('加载体重历史失败:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const navigateToCoach = () => {
|
||
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
|
||
};
|
||
|
||
// 切换图表显示状态的动画函数
|
||
const toggleChart = () => {
|
||
if (isAnimating) return; // 防止动画期间重复触发
|
||
|
||
setIsAnimating(true);
|
||
const newShowChart = !showChart;
|
||
setShowChart(newShowChart);
|
||
|
||
animationProgress.value = withTiming(
|
||
newShowChart ? 1 : 0,
|
||
{
|
||
duration: 350,
|
||
},
|
||
(finished) => {
|
||
if (finished) {
|
||
runOnJS(setIsAnimating)(false);
|
||
}
|
||
}
|
||
);
|
||
};
|
||
|
||
// 动画容器的高度动画
|
||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
||
// 只有在展开状态时才应用固定高度
|
||
if (animationProgress.value === 0) {
|
||
return {};
|
||
}
|
||
|
||
const height = interpolate(
|
||
animationProgress.value,
|
||
[0, 1],
|
||
[40, 200], // 从摘要高度到图表高度
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
return {
|
||
height,
|
||
};
|
||
});
|
||
|
||
// 摘要信息的动画样式
|
||
const summaryAnimatedStyle = useAnimatedStyle(() => {
|
||
const opacity = interpolate(
|
||
animationProgress.value,
|
||
[0, 0.4, 1],
|
||
[1, 0.2, 0],
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
const scale = interpolate(
|
||
animationProgress.value,
|
||
[0, 1],
|
||
[1, 0.9],
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
const translateY = interpolate(
|
||
animationProgress.value,
|
||
[0, 1],
|
||
[0, -20],
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
return {
|
||
opacity,
|
||
transform: [{ scale }, { translateY }],
|
||
};
|
||
});
|
||
|
||
// 图表容器的动画样式
|
||
const chartAnimatedStyle = useAnimatedStyle(() => {
|
||
const opacity = interpolate(
|
||
animationProgress.value,
|
||
[0, 0.6, 1],
|
||
[0, 0.2, 1],
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
const scale = interpolate(
|
||
animationProgress.value,
|
||
[0, 1],
|
||
[0.9, 1],
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
const translateY = interpolate(
|
||
animationProgress.value,
|
||
[0, 1],
|
||
[20, 0],
|
||
Extrapolation.CLAMP
|
||
);
|
||
|
||
return {
|
||
opacity,
|
||
transform: [{ scale }, { translateY }],
|
||
};
|
||
});
|
||
|
||
// 如果正在加载,显示加载状态
|
||
if (isLoading) {
|
||
return (
|
||
<View style={styles.card}>
|
||
<View style={styles.cardHeader}>
|
||
<View style={styles.iconSquare}>
|
||
<Ionicons name="scale-outline" size={18} color="#192126" />
|
||
</View>
|
||
<Text style={styles.cardTitle}>体重记录</Text>
|
||
</View>
|
||
<View style={styles.emptyContent}>
|
||
<Text style={styles.emptyDescription}>加载中...</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// 如果没有体重数据,显示引导卡片
|
||
if (!hasWeight) {
|
||
return (
|
||
<View style={styles.card}>
|
||
<View style={styles.cardHeader}>
|
||
<View style={styles.iconSquare}>
|
||
<Ionicons name="scale-outline" size={18} color="#192126" />
|
||
</View>
|
||
<Text style={styles.cardTitle}>体重记录</Text>
|
||
</View>
|
||
|
||
<View style={styles.emptyContent}>
|
||
<Text style={styles.emptyTitle}>开始记录你的体重变化</Text>
|
||
<Text style={styles.emptyDescription}>
|
||
记录体重变化,追踪你的健康进展
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={styles.recordButton}
|
||
onPress={navigateToCoach}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Ionicons name="add" size={18} color="#192126" />
|
||
<Text style={styles.recordButtonText}>记录</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// 处理体重历史数据
|
||
const sortedHistory = [...weightHistory]
|
||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||
.slice(-7); // 只显示最近7条记录
|
||
|
||
if (sortedHistory.length === 0) {
|
||
return (
|
||
<View style={styles.card}>
|
||
<View style={styles.cardHeader}>
|
||
<View style={styles.iconSquare}>
|
||
<Ionicons name="scale-outline" size={18} color="#192126" />
|
||
</View>
|
||
<Text style={styles.cardTitle}>体重记录</Text>
|
||
</View>
|
||
|
||
<View style={styles.emptyContent}>
|
||
<Text style={styles.emptyDescription}>
|
||
暂无体重记录,点击下方按钮开始记录
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={styles.recordButton}
|
||
onPress={navigateToCoach}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
||
<Text style={styles.recordButtonText}>记录体重</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// 生成图表数据
|
||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||
const minWeight = Math.min(...weights);
|
||
const maxWeight = Math.max(...weights);
|
||
const weightRange = maxWeight - minWeight || 1;
|
||
|
||
const points = sortedHistory.map((item, index) => {
|
||
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
||
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
||
// 减少顶部边距,压缩留白
|
||
const y = PADDING + 15 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 30);
|
||
return { x, y, weight: item.weight, date: item.createdAt };
|
||
});
|
||
|
||
// 生成路径
|
||
const pathData = points.map((point, index) => {
|
||
if (index === 0) return `M ${point.x} ${point.y}`;
|
||
return `L ${point.x} ${point.y}`;
|
||
}).join(' ');
|
||
|
||
// 如果只有一个数据点,显示为水平线
|
||
const singlePointPath = points.length === 1 ?
|
||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||
pathData;
|
||
|
||
return (
|
||
<View style={styles.card}>
|
||
<View style={styles.cardHeader}>
|
||
<View style={styles.iconSquare}>
|
||
<Ionicons name="scale-outline" size={18} color="#192126" />
|
||
</View>
|
||
<Text style={styles.cardTitle}>体重记录</Text>
|
||
<View style={styles.headerButtons}>
|
||
<TouchableOpacity
|
||
style={styles.chartToggleButton}
|
||
onPress={toggleChart}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Ionicons
|
||
name={showChart ? "chevron-up" : "chevron-down"}
|
||
size={16}
|
||
color={Colors.light.primary}
|
||
/>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={styles.addButton}
|
||
onPress={navigateToCoach}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Ionicons name="add" size={16} color={Colors.light.primary} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 动画容器 */}
|
||
{sortedHistory.length > 0 && (
|
||
<Animated.View style={[styles.animationContainer, containerAnimatedStyle]}>
|
||
{/* 默认信息显示 - 带动画 */}
|
||
<Animated.View style={[styles.summaryInfo, summaryAnimatedStyle]}>
|
||
<View style={styles.summaryRow}>
|
||
<View style={styles.summaryItem}>
|
||
<Text style={styles.summaryLabel}>当前体重</Text>
|
||
<Text style={styles.summaryValue}>{userProfile.weight}kg</Text>
|
||
</View>
|
||
<View style={styles.summaryItem}>
|
||
<Text style={styles.summaryLabel}>记录天数</Text>
|
||
<Text style={styles.summaryValue}>{sortedHistory.length}天</Text>
|
||
</View>
|
||
<View style={styles.summaryItem}>
|
||
<Text style={styles.summaryLabel}>变化范围</Text>
|
||
<Text style={styles.summaryValue}>
|
||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</Animated.View>
|
||
|
||
{/* 图表容器 - 带动画 */}
|
||
<Animated.View style={[styles.chartContainer, chartAnimatedStyle]}>
|
||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||
{/* 背景网格线 */}
|
||
{[0, 1, 2, 3, 4].map(i => (
|
||
<Line
|
||
key={`grid-${i}`}
|
||
x1={PADDING}
|
||
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||
x2={CHART_WIDTH - PADDING}
|
||
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||
stroke="#F0F0F0"
|
||
strokeWidth={1}
|
||
/>
|
||
))}
|
||
|
||
{/* 折线 */}
|
||
<Path
|
||
d={singlePointPath}
|
||
stroke={Colors.light.accentGreen}
|
||
strokeWidth={3}
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
|
||
{/* 数据点和标签 */}
|
||
{points.map((point, index) => {
|
||
const isLastPoint = index === points.length - 1;
|
||
const isFirstPoint = index === 0;
|
||
const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1;
|
||
|
||
return (
|
||
<React.Fragment key={index}>
|
||
<Circle
|
||
cx={point.x}
|
||
cy={point.y}
|
||
r={isLastPoint ? 6 : 4}
|
||
fill={Colors.light.accentGreen}
|
||
stroke="#FFFFFF"
|
||
strokeWidth={2}
|
||
/>
|
||
{/* 体重标签 - 只在关键点显示 */}
|
||
{showLabel && (
|
||
<>
|
||
<Circle
|
||
cx={point.x}
|
||
cy={point.y - 15}
|
||
r={10}
|
||
fill="rgba(255,255,255,0.9)"
|
||
stroke={Colors.light.accentGreen}
|
||
strokeWidth={1}
|
||
/>
|
||
<SvgText
|
||
x={point.x}
|
||
y={point.y - 12}
|
||
fontSize="9"
|
||
fill="#192126"
|
||
textAnchor="middle"
|
||
>
|
||
{point.weight}
|
||
</SvgText>
|
||
</>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
|
||
|
||
</Svg>
|
||
|
||
{/* 图表底部信息 */}
|
||
<View style={styles.chartInfo}>
|
||
<View style={styles.infoItem}>
|
||
<Text style={styles.infoLabel}>当前体重</Text>
|
||
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
|
||
</View>
|
||
<View style={styles.infoItem}>
|
||
<Text style={styles.infoLabel}>记录天数</Text>
|
||
<Text style={styles.infoValue}>{sortedHistory.length}天</Text>
|
||
</View>
|
||
<View style={styles.infoItem}>
|
||
<Text style={styles.infoLabel}>变化范围</Text>
|
||
<Text style={styles.infoValue}>
|
||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 最近记录时间 */}
|
||
{sortedHistory.length > 0 && (
|
||
<Text style={styles.lastRecordText}>
|
||
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||
</Text>
|
||
)}
|
||
</Animated.View>
|
||
</Animated.View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
card: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 22,
|
||
padding: 18,
|
||
marginBottom: 8,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 8,
|
||
elevation: 3,
|
||
},
|
||
cardHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
iconSquare: {
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 8,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 10,
|
||
},
|
||
cardTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '800',
|
||
color: '#192126',
|
||
flex: 1,
|
||
},
|
||
headerButtons: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
},
|
||
chartToggleButton: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
addButton: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
emptyContent: {
|
||
alignItems: 'center',
|
||
},
|
||
emptyTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
color: '#192126',
|
||
marginBottom: 6,
|
||
},
|
||
emptyDescription: {
|
||
fontSize: 14,
|
||
color: '#687076',
|
||
textAlign: 'center',
|
||
marginBottom: 16,
|
||
lineHeight: 20,
|
||
},
|
||
recordButton: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: Colors.light.accentGreen,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 10,
|
||
borderRadius: 20,
|
||
gap: 6,
|
||
},
|
||
recordButtonText: {
|
||
color: '#192126',
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
},
|
||
animationContainer: {
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
minHeight: 40,
|
||
},
|
||
summaryInfo: {
|
||
position: 'absolute',
|
||
width: '100%',
|
||
},
|
||
chartContainer: {
|
||
position: 'absolute',
|
||
width: '100%',
|
||
alignItems: 'center',
|
||
minHeight: 100,
|
||
},
|
||
chartInfo: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-around',
|
||
width: '100%',
|
||
paddingTop: 16,
|
||
borderTopWidth: 1,
|
||
borderTopColor: '#F0F0F0',
|
||
},
|
||
infoItem: {
|
||
alignItems: 'center',
|
||
},
|
||
infoLabel: {
|
||
fontSize: 12,
|
||
color: '#687076',
|
||
marginBottom: 4,
|
||
},
|
||
infoValue: {
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
color: '#192126',
|
||
},
|
||
lastRecordText: {
|
||
fontSize: 12,
|
||
color: '#687076',
|
||
textAlign: 'center',
|
||
marginTop: 4,
|
||
},
|
||
summaryRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-around',
|
||
marginBottom: 0,
|
||
},
|
||
summaryItem: {
|
||
alignItems: 'center',
|
||
},
|
||
summaryLabel: {
|
||
fontSize: 12,
|
||
color: '#687076',
|
||
marginBottom: 4,
|
||
},
|
||
summaryValue: {
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
color: '#192126',
|
||
},
|
||
});
|