Files
digital-pilates/components/NutritionRadarCard.tsx
richarjiang e6dfd4d59a feat(health): 重构营养卡片数据获取逻辑,支持基础代谢与运动消耗分离
- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据
- NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新
- BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略
- StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞
- HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit
- 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
2025-09-23 10:01:50 +08:00

531 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AnimatedNumber } from '@/components/AnimatedNumber';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchCompleteNutritionCardData, selectNutritionCardDataByDate } from '@/store/nutritionSlice';
import { triggerLightHaptic } from '@/utils/haptics';
import { calculateRemainingCalories } from '@/utils/nutrition';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export type NutritionRadarCardProps = {
selectedDate?: Date;
style?: object;
/** 动画重置令牌 */
resetToken?: number;
};
// 简化的圆环进度组件
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({
selectedDate,
style,
resetToken,
}: NutritionRadarCardProps) {
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [loading, setLoading] = useState(false);
const { pushIfAuthedElseLogin } = useAuthGuard();
const dispatch = useAppDispatch();
const dateKey = useMemo(() => {
return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
}, [selectedDate]);
const cardData = useAppSelector(selectNutritionCardDataByDate(dateKey));
const { nutritionSummary, healthData, basalMetabolism } = cardData;
// 获取营养和健康数据
useEffect(() => {
const loadNutritionCardData = async () => {
const targetDate = selectedDate || new Date();
try {
setLoading(true);
await dispatch(fetchCompleteNutritionCardData(targetDate)).unwrap();
} catch (error) {
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
} finally {
setLoading(false);
}
};
loadNutritionCardData();
}, [selectedDate, dispatch]);
const nutritionStats = useMemo(() => {
return [
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
{ label: '纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
];
}, [nutritionSummary]);
// 计算还能吃的卡路里
const consumedCalories = nutritionSummary?.totalCalories || 0;
// 使用从HealthKit获取的数据如果没有则使用默认值
const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值
const effectiveActiveCalories = healthData?.activeCalories || 0; // 运动消耗卡路里
const remainingCalories = calculateRemainingCalories({
basalMetabolism: effectiveBasalMetabolism,
activeCalories: effectiveActiveCalories,
consumedCalories,
});
const handleNavigateToRecords = () => {
triggerLightHaptic();
router.push(ROUTES.NUTRITION_RECORDS);
};
return (
<TouchableOpacity style={[styles.card, style]} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Image
source={require('@/assets/images/icons/icon-healthy-diet.png')}
style={styles.titleIcon}
/>
<Text style={styles.cardTitle}></Text>
</View>
<Text style={styles.cardSubtitle}>
{loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`}
</Text>
</View>
<View style={styles.contentContainer}>
<View style={styles.radarContainer}>
<SimpleRingProgress
remainingCalories={loading ? 0 : remainingCalories}
totalAvailable={loading ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
/>
</View>
<View style={styles.statsContainer}>
<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>
{/* 卡路里计算区域 */}
<View style={styles.calorieSection}>
<View style={styles.calorieContent}>
<View style={styles.calculationRow}>
<Text style={styles.calorieSubtitle}></Text>
<View style={styles.remainingCaloriesContainer}>
<AnimatedNumber
value={loading ? 0 : remainingCalories}
resetToken={resetToken}
style={styles.mainValue}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calorieUnit}></Text>
</View>
<Text style={styles.calculationText}> = </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={loading ? 0 : effectiveBasalMetabolism}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calculationText}> + </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={loading ? 0 : effectiveActiveCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calculationText}> - </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={loading ? 0 : consumedCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
</View>
</View>
</View>
{/* 添加食物选项 */}
<View style={styles.foodOptionsContainer}>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-camera.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}>AI识别</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-food.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-broadcast.png')}
style={styles.foodOptionImage}
/>
</View>
<Text style={styles.foodOptionText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
marginTop: 12
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
cardTitle: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
},
cardSubtitle: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '600',
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
},
radarContainer: {
alignItems: 'center',
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',
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: 8,
},
statDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 8,
},
statLabel: {
fontSize: 10,
color: '#9AA3AE',
flex: 1,
},
statValue: {
fontSize: 12,
color: '#192126',
fontWeight: '600',
},
// 卡路里相关样式
calorieSection: {
marginTop: 6,
},
calorieTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
calorieIcon: {
fontSize: 16,
marginRight: 8,
},
calorieTitle: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
calorieContent: {
},
calorieSubtitle: {
fontSize: 10,
color: '#64748B',
fontWeight: '600',
marginRight: 4,
},
calculationRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 4,
},
mainValue: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
calculationText: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
},
calculationItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
calculationLabel: {
fontSize: 9,
color: '#64748B',
fontWeight: '500',
},
calculationValue: {
fontSize: 11,
fontWeight: '700',
color: '#192126',
},
remainingCaloriesContainer: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 2,
},
calorieUnit: {
fontSize: 10,
color: '#64748B',
fontWeight: '500',
},
mealsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
},
mealItem: {
alignItems: 'center',
flex: 1,
},
mealIconContainer: {
position: 'relative',
marginBottom: 6,
},
mealEmoji: {
fontSize: 24,
},
mealName: {
fontSize: 10,
color: '#64748B',
fontWeight: '600',
},
// 食物选项样式
foodOptionsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
gap: 16,
},
foodOptionItem: {
alignItems: 'center',
flex: 1,
},
foodOptionIcon: {
width: 24,
height: 24,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 6,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
foodOptionEmoji: {
fontSize: 14,
},
foodOptionImage: {
width: 20,
height: 20,
resizeMode: 'contain',
},
foodOptionText: {
fontSize: 10,
fontWeight: '500',
color: '#192126',
textAlign: 'center',
},
});