feat: 新增饮食方案卡片及相关计算功能

- 在教练页面中新增饮食方案卡片,展示用户的饮食计划和相关数据
- 实现BMI计算、每日推荐摄入热量计算及营养素分配功能
- 优化体重历史记录卡片,增加动画效果提升用户体验
- 更新统计页面样式,增加体重卡片样式和按钮功能
- 修复获取HRV值的逻辑,确保数据准确性
This commit is contained in:
richarjiang
2025-08-20 19:10:04 +08:00
parent 1c44c3083b
commit 3d47073d2f
4 changed files with 641 additions and 116 deletions

View File

@@ -13,6 +13,14 @@ import {
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');
@@ -29,6 +37,10 @@ export function WeightHistoryCard() {
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;
@@ -54,6 +66,104 @@ export function WeightHistoryCard() {
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 (
@@ -167,7 +277,7 @@ export function WeightHistoryCard() {
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.chartToggleButton}
onPress={() => setShowChart(!showChart)}
onPress={toggleChart}
activeOpacity={0.8}
>
<Ionicons
@@ -186,125 +296,126 @@ export function WeightHistoryCard() {
</View>
</View>
{/* 默认信息显示 */}
{!showChart && sortedHistory.length > 0 && (
<View style={styles.summaryInfo}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{userProfile.weight}kg</Text>
{/* 动画容器 */}
{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>
<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>
</View>
)}
</Animated.View>
{/* 图表容器 - 可折叠 */}
{showChart && (
<View style={styles.chartContainer}>
<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}
{/* 图表容器 - 带动画 */}
<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"
/>
))}
{/* 折线 */}
<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;
{/* 数据点和标签 */}
{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>
);
})}
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>
</Svg>
{/* 图表底部信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}></Text>
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
{/* 图表底部信息 */}
<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>
<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
{/* 最近记录时间 */}
{sortedHistory.length > 0 && (
<Text style={styles.lastRecordText}>
{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
</Text>
</View>
</View>
{/* 最近记录时间 */}
{sortedHistory.length > 0 && (
<Text style={styles.lastRecordText}>
{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
</Text>
)}
</View>
)}
</Animated.View>
</Animated.View>
)}
</View>
);
@@ -315,7 +426,7 @@ const styles = StyleSheet.create({
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
marginBottom: 16,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
@@ -390,7 +501,18 @@ const styles = StyleSheet.create({
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,
},
@@ -419,14 +541,12 @@ const styles = StyleSheet.create({
fontSize: 12,
color: '#687076',
textAlign: 'center',
marginTop: 8,
},
summaryInfo: {
marginTop: 4,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 6,
marginBottom: 0,
},
summaryItem: {
alignItems: 'center',