Files
digital-pilates/components/NutritionRadarCard.tsx
richarjiang 5013464a2c feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息:
- 创建成分表分析页面,支持拍照/选择图片和结果展示
- 集成新的营养成分分析API,支持图片上传和流式分析
- 在营养雷达卡片中添加成分表分析入口
- 更新应用版本至1.0.19
2025-10-16 12:16:08 +08:00

556 lines
16 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 { useActiveCalories } from '@/hooks/useActiveCalories';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } 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 nutritionSummary = useAppSelector(selectNutritionSummaryByDate(dateKey));
const basalMetabolism = useAppSelector(selectBasalMetabolismByDate(dateKey));
// 使用专用的hook获取运动消耗卡路里
const { activeCalories: effectiveActiveCalories, loading: activeCaloriesLoading } = useActiveCalories(selectedDate);
// 获取营养数据和基础代谢数据
useEffect(() => {
const loadNutritionCardData = async () => {
const targetDate = selectedDate || new Date();
try {
setLoading(true);
await Promise.all([
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
dispatch(fetchDailyBasalMetabolism(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 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 || activeCaloriesLoading) ? 0 : remainingCalories}
totalAvailable={(loading || activeCaloriesLoading) ? 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 || activeCaloriesLoading) ? 0 : remainingCalories}
resetToken={resetToken}
style={styles.mainValue}
format={(v) => (loading || activeCaloriesLoading) ? '--' : 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={activeCaloriesLoading ? 0 : effectiveActiveCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => activeCaloriesLoading ? '--' : 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>
<TouchableOpacity
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
<View style={[styles.foodOptionIcon]}>
<Image
source={require('@/assets/images/icons/icon-recommend.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-between',
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
paddingHorizontal: 4,
},
foodOptionItem: {
alignItems: 'center',
flex: 1,
paddingHorizontal: 2,
},
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',
},
});