feat: 优化营养记录和卡路里环图组件,增加毛玻璃背景和动画效果
This commit is contained in:
@@ -6,9 +6,11 @@ import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { RadarCategory, RadarChart } from './RadarChart';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
|
||||
export type NutritionRadarCardProps = {
|
||||
@@ -28,15 +30,71 @@ export type NutritionRadarCardProps = {
|
||||
onMealPress?: (mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void;
|
||||
};
|
||||
|
||||
// 营养维度定义
|
||||
const NUTRITION_DIMENSIONS: RadarCategory[] = [
|
||||
{ key: 'calories', label: '热量' },
|
||||
{ key: 'protein', label: '蛋白质' },
|
||||
{ key: 'carbohydrate', label: '碳水' },
|
||||
{ key: 'fat', label: '脂肪' },
|
||||
{ key: 'fiber', label: '纤维' },
|
||||
{ key: 'sodium', label: '钠' },
|
||||
];
|
||||
// 简化的圆环进度组件
|
||||
const SimpleRingProgress = ({
|
||||
remainingCalories,
|
||||
totalAvailable
|
||||
}: {
|
||||
remainingCalories: number;
|
||||
totalAvailable: number;
|
||||
}) => {
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
const radius = 32;
|
||||
const strokeWidth = 8; // 增加圆环厚度
|
||||
const center = radius + strokeWidth;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
// 计算进度:已消耗 / 总可用,进度越高表示剩余越少
|
||||
const consumedAmount = totalAvailable - remainingCalories;
|
||||
const calorieProgress = totalAvailable > 0 ? Math.min((consumedAmount / totalAvailable) * 100, 100) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(animatedProgress, {
|
||||
toValue: calorieProgress,
|
||||
duration: 600,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [calorieProgress]);
|
||||
|
||||
const strokeDashoffset = animatedProgress.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: [circumference, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<Svg width={center * 2} height={center * 2}>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke="#F0F0F0"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
<AnimatedCircle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke={calorieProgress > 80 ? "#FF6B6B" : "#4ECDC4"}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={`${circumference}`}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
<View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}>
|
||||
{Math.round(remainingCalories)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>还能吃</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export function NutritionRadarCard({
|
||||
nutritionSummary,
|
||||
@@ -48,38 +106,11 @@ export function NutritionRadarCard({
|
||||
resetToken,
|
||||
onMealPress
|
||||
}: NutritionRadarCardProps) {
|
||||
const [currentMealType, setCurrentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
||||
const radarValues = useMemo(() => {
|
||||
// 基于动态计算的营养目标或默认推荐值
|
||||
const recommendations = {
|
||||
calories: nutritionGoals?.calories ?? 2000, // 卡路里
|
||||
protein: nutritionGoals?.proteinGoal ?? 50, // 蛋白质(g)
|
||||
carbohydrate: nutritionGoals?.carbsGoal ?? 300, // 碳水化合物(g)
|
||||
fat: nutritionGoals?.fatGoal ?? 65, // 脂肪(g)
|
||||
fiber: nutritionGoals?.fiberGoal ?? 25, // 膳食纤维(g)
|
||||
sodium: nutritionGoals?.sodiumGoal ?? 2300, // 钠(mg)
|
||||
};
|
||||
|
||||
if (!nutritionSummary) return [0, 0, 0, 0, 0, 0];
|
||||
|
||||
// 检查每个营养素是否有实际值,没有则返回0
|
||||
const calories = nutritionSummary.totalCalories || 0;
|
||||
const protein = nutritionSummary.totalProtein || 0;
|
||||
const carbohydrate = nutritionSummary.totalCarbohydrate || 0;
|
||||
const fat = nutritionSummary.totalFat || 0;
|
||||
const fiber = nutritionSummary.totalFiber || 0;
|
||||
const sodium = nutritionSummary.totalSodium || 0;
|
||||
|
||||
return [
|
||||
calories > 0 ? Math.min(5, (calories / recommendations.calories) * 5) : 0,
|
||||
protein > 0 ? Math.min(5, (protein / recommendations.protein) * 5) : 0,
|
||||
carbohydrate > 0 ? Math.min(5, (carbohydrate / recommendations.carbohydrate) * 5) : 0,
|
||||
fat > 0 ? Math.min(5, (fat / recommendations.fat) * 5) : 0,
|
||||
fiber > 0 ? Math.min(5, (fiber / recommendations.fiber) * 5) : 0,
|
||||
sodium > 0 ? Math.min(5, Math.max(0, 5 - (sodium / recommendations.sodium) * 5)) : 0, // 钠含量越低越好
|
||||
];
|
||||
}, [nutritionSummary, nutritionGoals]);
|
||||
// 计算营养目标
|
||||
const calorieGoal = nutritionGoals?.calories ?? 2000;
|
||||
const proteinGoal = nutritionGoals?.proteinGoal ?? 50;
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
@@ -127,21 +158,21 @@ export function NutritionRadarCard({
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.radarContainer}>
|
||||
<RadarChart
|
||||
categories={NUTRITION_DIMENSIONS}
|
||||
values={radarValues}
|
||||
size="small"
|
||||
maxValue={5}
|
||||
<SimpleRingProgress
|
||||
remainingCalories={remainingCalories}
|
||||
totalAvailable={effectiveBasalMetabolism + effectiveActiveCalories}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
{nutritionStats.map((stat, index) => (
|
||||
<View key={stat.label} style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>{stat.label}</Text>
|
||||
<Text style={styles.statValue}>{stat.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.statsBackground}>
|
||||
{nutritionStats.map((stat) => (
|
||||
<View key={stat.label} style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>{stat.label}</Text>
|
||||
<Text style={styles.statValue}>{stat.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -246,20 +277,40 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
radarContainer: {
|
||||
alignItems: 'center',
|
||||
marginRight: 6,
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 78, // Fixed width to match ring chart size
|
||||
height: 78, // Fixed height to match ring chart size
|
||||
},
|
||||
statsContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 4
|
||||
},
|
||||
statsBackground: {
|
||||
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginLeft: 4
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
// 添加边框增强毛玻璃效果
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '48%',
|
||||
marginBottom: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statDot: {
|
||||
width: 8,
|
||||
@@ -279,6 +330,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
// 卡路里相关样式
|
||||
calorieSection: {
|
||||
marginTop: 6,
|
||||
},
|
||||
|
||||
calorieTitleContainer: {
|
||||
|
||||
Reference in New Issue
Block a user