feat: 优化营养记录和卡路里环图组件,增加毛玻璃背景和动画效果

This commit is contained in:
richarjiang
2025-09-04 11:28:31 +08:00
parent 4ae419754a
commit 5e00cb7788
6 changed files with 302 additions and 254 deletions

View File

@@ -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: {