Files
digital-pilates/components/NutritionRadarCard.tsx
richarjiang 02883869fe feat: Implement Food Camera Screen and Floating Food Overlay
- Added FoodCameraScreen for capturing food images with camera functionality.
- Integrated image picker for selecting images from the gallery.
- Created FloatingFoodOverlay for quick access to food library and scanning options.
- Updated NutritionRadarCard to utilize FloatingFoodOverlay for adding food.
- Enhanced ExploreScreen layout and styles for better user experience.
- Removed unused SafeAreaView from ExploreScreen.
- Updated profile edit screen to remove unnecessary state variables.
- Updated avatar image source in profile edit screen.
- Added ExpoCamera dependency for camera functionalities.
2025-09-03 19:17:26 +08:00

387 lines
11 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 { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
import { ROUTES } from '@/constants/Routes';
import { NutritionSummary } from '@/services/dietRecords';
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';
export type NutritionRadarCardProps = {
nutritionSummary: NutritionSummary | null;
/** 营养目标 */
nutritionGoals?: NutritionGoals;
/** 基础代谢消耗的卡路里 */
burnedCalories?: number;
/** 基础代谢率 */
basalMetabolism?: number;
/** 运动消耗卡路里 */
activeCalories?: number;
/** 动画重置令牌 */
resetToken?: number;
/** 餐次点击回调 */
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: '钠' },
];
export function NutritionRadarCard({
nutritionSummary,
nutritionGoals,
burnedCalories = 1618,
basalMetabolism,
activeCalories,
resetToken,
onMealPress
}: NutritionRadarCardProps) {
const [currentMealType, setCurrentMealType] = 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 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;
// 使用分离的代谢和运动数据如果没有提供则从burnedCalories推算
const effectiveBasalMetabolism = basalMetabolism ?? (burnedCalories * 0.7); // 假设70%是基础代谢
const effectiveActiveCalories = activeCalories ?? (burnedCalories * 0.3); // 假设30%是运动消耗
const remainingCalories = calculateRemainingCalories({
basalMetabolism: effectiveBasalMetabolism,
activeCalories: effectiveActiveCalories,
consumedCalories,
});
const handleNavigateToRecords = () => {
router.push(ROUTES.NUTRITION_RECORDS);
};
const handleAddFood = () => {
setShowFoodOverlay(true);
};
return (
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<View style={styles.cardRightContainer}>
<Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
<TouchableOpacity style={styles.addButton} onPress={handleAddFood}>
<Ionicons name="add" size={12} color="#514b4bff" />
</TouchableOpacity>
</View>
</View>
<View style={styles.contentContainer}>
<View style={styles.radarContainer}>
<RadarChart
categories={NUTRITION_DIMENSIONS}
values={radarValues}
size="small"
maxValue={5}
/>
</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>
</View>
{/* 卡路里计算区域 */}
<View style={styles.calorieSection}>
<View style={styles.calorieContent}>
<View style={styles.calculationRow}>
<Text style={styles.calorieSubtitle}></Text>
<View style={styles.remainingCaloriesContainer}>
<AnimatedNumber
value={remainingCalories}
resetToken={resetToken}
style={styles.mainValue}
format={(v) => 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={effectiveBasalMetabolism}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
/>
<Text style={styles.calculationText}> + </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={effectiveActiveCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
/>
<Text style={styles.calculationText}> - </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={consumedCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
/>
</View>
</View>
</View>
{/* 食物添加悬浮窗 */}
<FloatingFoodOverlay
visible={showFoodOverlay}
onClose={() => setShowFoodOverlay(false)}
mealType={currentMealType}
/>
</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,
},
cardTitle: {
fontSize: 14,
color: '#192126',
},
cardRightContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
cardSubtitle: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '600',
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
},
radarContainer: {
alignItems: 'center',
marginRight: 6,
},
statsContainer: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
marginLeft: 4
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
width: '48%',
marginBottom: 16,
},
statDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 8,
},
statLabel: {
fontSize: 10,
color: '#9AA3AE',
flex: 1,
},
statValue: {
fontSize: 12,
color: '#192126',
fontWeight: '600',
},
// 卡路里相关样式
calorieSection: {
},
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,
},
addButton: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: '#e5e8ecff',
marginLeft: 8,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
mealName: {
fontSize: 10,
color: '#64748B',
fontWeight: '600',
},
});