feat: 新增饮食方案卡片及相关计算功能
- 在教练页面中新增饮食方案卡片,展示用户的饮食计划和相关数据 - 实现BMI计算、每日推荐摄入热量计算及营养素分配功能 - 优化体重历史记录卡片,增加动画效果提升用户体验 - 更新统计页面样式,增加体重卡片样式和按钮功能 - 修复获取HRV值的逻辑,确保数据准确性
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user