feat(notifications): 新增晚餐和心情提醒功能,支持HRV压力检测和后台处理

- 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知
- 实现基于HRV数据的压力检测和智能鼓励通知
- 添加后台任务处理支持,修改iOS后台模式为processing
- 优化营养记录页面使用Redux状态管理,支持实时数据更新
- 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食
- 新增营养目标动态计算功能,基于用户身体数据智能推荐
- 完善通知点击跳转逻辑,支持多种提醒类型的路由处理
This commit is contained in:
richarjiang
2025-09-01 10:29:13 +08:00
parent fe634ba258
commit a34ca556e8
12 changed files with 867 additions and 189 deletions

View File

@@ -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',