- Updated font sizes and weights in BasalMetabolismCard, MoodCard, HealthDataCard, and NutritionRadarCard for improved readability. - Removed loading state from MoodCard to simplify the component. - Adjusted styles in WeightHistoryCard for better layout and spacing. - Integrated expo-background-fetch for improved background task handling. - Updated Info.plist to include background fetch capability. - Enhanced background task registration and execution logic in backgroundTaskManager. - Added debug function to manually trigger background task execution for testing purposes.
379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||
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 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 = () => {
|
||
router.push(`/food-library?mealType=${currentMealType}`);
|
||
};
|
||
|
||
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>
|
||
|
||
|
||
</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',
|
||
},
|
||
});
|