feat(notifications): 新增晚餐和心情提醒功能,支持HRV压力检测和后台处理
- 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知 - 实现基于HRV数据的压力检测和智能鼓励通知 - 添加后台任务处理支持,修改iOS后台模式为processing - 优化营养记录页面使用Redux状态管理,支持实时数据更新 - 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食 - 新增营养目标动态计算功能,基于用户身体数据智能推荐 - 完善通知点击跳转逻辑,支持多种提醒类型的路由处理
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, StyleSheet, View } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
@@ -17,6 +18,7 @@ export type CalorieRingChartProps = {
|
||||
proteinGoal: number;
|
||||
fatGoal: number;
|
||||
carbsGoal: number;
|
||||
|
||||
};
|
||||
|
||||
export function CalorieRingChart({
|
||||
@@ -30,6 +32,7 @@ export function CalorieRingChart({
|
||||
proteinGoal,
|
||||
fatGoal,
|
||||
carbsGoal,
|
||||
|
||||
}: CalorieRingChartProps) {
|
||||
const surfaceColor = useThemeColor({}, 'surface');
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
@@ -38,12 +41,12 @@ export function CalorieRingChart({
|
||||
// 动画值
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 计算还能吃多少卡路里
|
||||
const remainingCalories = metabolism + exercise - consumed - goal;
|
||||
// 计算还能吃的卡路里:代谢 + 运动 - 饮食
|
||||
const remainingCalories = metabolism + exercise - consumed;
|
||||
const canEat = Math.max(0, remainingCalories);
|
||||
|
||||
// 计算进度百分比 (用于圆环显示)
|
||||
const totalAvailable = metabolism + exercise - goal;
|
||||
const totalAvailable = metabolism + exercise;
|
||||
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
|
||||
|
||||
// 圆环参数 - 更小的圆环以适应布局
|
||||
@@ -74,7 +77,7 @@ export function CalorieRingChart({
|
||||
{/* 左上角公式展示 */}
|
||||
<View style={styles.formulaContainer}>
|
||||
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
|
||||
还能吃 = 代谢 + 运动 - 饮食 - 目标
|
||||
还能吃 = 代谢 + 运动 - 饮食
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
@@ -113,7 +116,7 @@ export function CalorieRingChart({
|
||||
还能吃
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.centerValue, { color: textColor }]}>
|
||||
{canEat.toLocaleString()}千卡
|
||||
{canEat.toFixed(1)}千卡
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.centerPercentage, { color: textSecondaryColor }]}>
|
||||
{Math.round(progressPercentage)}%
|
||||
@@ -127,30 +130,25 @@ export function CalorieRingChart({
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>代谢</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{metabolism.toLocaleString()}千卡
|
||||
{Math.round(metabolism)}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>运动</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{exercise}千卡
|
||||
{Math.round(exercise)}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>饮食</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{consumed}千卡
|
||||
{Math.round(consumed)}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>目标</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{goal}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -161,7 +159,7 @@ export function CalorieRingChart({
|
||||
蛋白质
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
|
||||
{protein.toFixed(2)}/{proteinGoal.toFixed(2)}g
|
||||
{Math.round(protein)}/{Math.round(proteinGoal)}g
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
@@ -170,7 +168,7 @@ export function CalorieRingChart({
|
||||
脂肪
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
|
||||
{fat.toFixed(2)}/{fatGoal.toFixed(2)}g
|
||||
{Math.round(fat)}/{Math.round(fatGoal)}g
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
@@ -179,7 +177,7 @@ export function CalorieRingChart({
|
||||
碳水化合物
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
|
||||
{carbs.toFixed(2)}/{carbsGoal.toFixed(2)}g
|
||||
{Math.round(carbs)}/{Math.round(carbsGoal)}g
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -11,10 +12,15 @@ import { RadarCategory, RadarChart } from './RadarChart';
|
||||
|
||||
export type NutritionRadarCardProps = {
|
||||
nutritionSummary: NutritionSummary | null;
|
||||
/** 营养目标 */
|
||||
nutritionGoals?: NutritionGoals;
|
||||
/** 基础代谢消耗的卡路里 */
|
||||
burnedCalories?: number;
|
||||
/** 卡路里缺口 */
|
||||
calorieDeficit?: number;
|
||||
/** 基础代谢率 */
|
||||
basalMetabolism?: number;
|
||||
/** 运动消耗卡路里 */
|
||||
activeCalories?: number;
|
||||
|
||||
/** 动画重置令牌 */
|
||||
resetToken?: number;
|
||||
/** 餐次点击回调 */
|
||||
@@ -33,21 +39,24 @@ const NUTRITION_DIMENSIONS: RadarCategory[] = [
|
||||
|
||||
export function NutritionRadarCard({
|
||||
nutritionSummary,
|
||||
nutritionGoals,
|
||||
burnedCalories = 1618,
|
||||
calorieDeficit = 0,
|
||||
basalMetabolism,
|
||||
activeCalories,
|
||||
|
||||
resetToken,
|
||||
onMealPress
|
||||
}: NutritionRadarCardProps) {
|
||||
const [currentMealType, setCurrentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const radarValues = useMemo(() => {
|
||||
// 基于推荐日摄入量计算分数
|
||||
// 基于动态计算的营养目标或默认推荐值
|
||||
const recommendations = {
|
||||
calories: 2000, // 卡路里
|
||||
protein: 50, // 蛋白质(g)
|
||||
carbohydrate: 300, // 碳水化合物(g)
|
||||
fat: 65, // 脂肪(g)
|
||||
fiber: 25, // 膳食纤维(g)
|
||||
sodium: 2300, // 钠(mg)
|
||||
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];
|
||||
@@ -68,7 +77,7 @@ export function NutritionRadarCard({
|
||||
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]);
|
||||
}, [nutritionSummary, nutritionGoals]);
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
@@ -83,7 +92,16 @@ export function NutritionRadarCard({
|
||||
|
||||
// 计算还能吃的卡路里
|
||||
const consumedCalories = nutritionSummary?.totalCalories || 0;
|
||||
const remainingCalories = burnedCalories - consumedCalories - calorieDeficit;
|
||||
|
||||
// 使用分离的代谢和运动数据,如果没有提供则从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);
|
||||
@@ -129,27 +147,38 @@ export function NutritionRadarCard({
|
||||
<View style={styles.calorieSection}>
|
||||
<View style={styles.calorieContent}>
|
||||
<View style={styles.calculationRow}>
|
||||
<Text style={styles.calorieSubtitle}>还能吃(千卡)</Text>
|
||||
<AnimatedNumber
|
||||
value={remainingCalories}
|
||||
resetToken={resetToken}
|
||||
style={styles.mainValue}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
/>
|
||||
<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}>
|
||||
<Ionicons name="flame" size={16} color="#FF6B6B" />
|
||||
<Text style={styles.calculationLabel}>消耗</Text>
|
||||
<Text style={styles.calculationLabel}>基代</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={burnedCalories}
|
||||
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}>
|
||||
<Ionicons name="restaurant" size={16} color="#4ECDC4" />
|
||||
<Text style={styles.calculationLabel}>饮食</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
@@ -158,6 +187,7 @@ export function NutritionRadarCard({
|
||||
style={styles.calculationValue}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
/>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -270,12 +300,12 @@ const styles = StyleSheet.create({
|
||||
gap: 4,
|
||||
},
|
||||
mainValue: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
calculationText: {
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
},
|
||||
@@ -285,15 +315,25 @@ const styles = StyleSheet.create({
|
||||
gap: 2,
|
||||
},
|
||||
calculationLabel: {
|
||||
fontSize: 8,
|
||||
fontSize: 9,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
calculationValue: {
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
},
|
||||
remainingCaloriesContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 2,
|
||||
},
|
||||
calorieUnit: {
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
mealsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
Reference in New Issue
Block a user