From a34ca556e89f782553be3238eab7686651932e22 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 1 Sep 2025 10:29:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=99=9A=E9=A4=90=E5=92=8C=E5=BF=83=E6=83=85=E6=8F=90=E9=86=92?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81HRV=E5=8E=8B?= =?UTF-8?q?=E5=8A=9B=E6=A3=80=E6=B5=8B=E5=92=8C=E5=90=8E=E5=8F=B0=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知 - 实现基于HRV数据的压力检测和智能鼓励通知 - 添加后台任务处理支持,修改iOS后台模式为processing - 优化营养记录页面使用Redux状态管理,支持实时数据更新 - 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食 - 新增营养目标动态计算功能,基于用户身体数据智能推荐 - 完善通知点击跳转逻辑,支持多种提醒类型的路由处理 --- app.json | 2 +- app/(tabs)/statistics.tsx | 96 ++++++- app/_layout.tsx | 27 +- app/nutrition/records.tsx | 264 +++++++++++++----- components/CalorieRingChart.tsx | 32 +-- components/NutritionRadarCard.tsx | 94 +++++-- docs/notification-reminders-implementation.md | 181 ++++++++++++ ios/digitalpilates/Info.plist | 2 +- services/notifications.ts | 25 +- utils/health.ts | 13 + utils/notificationHelpers.ts | 242 ++++++++++++---- utils/nutrition.ts | 78 ++++++ 12 files changed, 867 insertions(+), 189 deletions(-) create mode 100644 docs/notification-reminders-implementation.md create mode 100644 utils/nutrition.ts diff --git a/app.json b/app.json index 7f8e5ed..71386ef 100644 --- a/app.json +++ b/app.json @@ -18,7 +18,7 @@ "NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。", "NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。", "UIBackgroundModes": [ - "remote-notification" + "processing" ] } }, diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 707ab43..1772278 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -13,12 +13,15 @@ import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useBackgroundTasks } from '@/hooks/useBackgroundTasks'; +import { notificationService } from '@/services/notifications'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; -import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; +import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHRV, fetchRecentHRV } from '@/utils/health'; import { getTestHealthData } from '@/utils/mockHealthData'; +import { calculateNutritionGoals } from '@/utils/nutrition'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; @@ -58,6 +61,7 @@ const FloatingCard = ({ children, delay = 0, style }: { export default function ExploreScreen() { const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; + const userProfile = useAppSelector((s) => s.user.profile); // 开发调试:设置为true来使用mock数据 const useMockData = __DEV__; // 改为true来启用mock数据调试 @@ -128,6 +132,16 @@ export default function ExploreScreen() { // 从 Redux 获取营养数据 const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString)); + // 计算用户的营养目标 + const nutritionGoals = useMemo(() => { + return calculateNutritionGoals({ + weight: userProfile.weight, + height: userProfile.height, + birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined, + gender: userProfile?.gender || undefined, + }); + }, [userProfile]); + const { registerTask } = useBackgroundTasks(); // 心情相关状态 const dispatch = useAppDispatch(); @@ -284,6 +298,8 @@ export default function ExploreScreen() { handler: async () => { try { await loadHealthData(); + + checkStressLevelAndNotify() } catch (error) { console.error('健康数据任务执行失败:', error); } @@ -291,6 +307,80 @@ export default function ExploreScreen() { }); }, []); + // 检查压力水平并发送通知 + const checkStressLevelAndNotify = React.useCallback(async () => { + try { + console.log('开始检查压力水平...'); + + // 确保有健康权限 + const hasPermission = await ensureHealthPermissions(); + if (!hasPermission) { + console.log('没有健康权限,跳过压力检查'); + return; + } + + // 获取最近2小时内的实时HRV数据 + const recentHRV = await fetchRecentHRV(2); + console.log('获取到的最近2小时HRV值:', recentHRV); + + if (recentHRV === null || recentHRV === undefined) { + console.log('没有最近的HRV数据,跳过压力检查'); + return; + } + + // 判断压力水平(HRV值低于60表示压力过大) + if (recentHRV < 60) { + console.log(`检测到压力过大,HRV值: ${recentHRV},准备发送鼓励通知`); + + // 检查是否在过去2小时内已经发送过压力提醒,避免重复打扰 + const lastNotificationKey = '@last_stress_notification'; + const lastNotificationTime = await AsyncStorage.getItem(lastNotificationKey); + const now = new Date().getTime(); + const twoHoursAgo = now - (2 * 60 * 60 * 1000); // 2小时前 + + if (lastNotificationTime && parseInt(lastNotificationTime) > twoHoursAgo) { + console.log('2小时内已发送过压力提醒,跳过本次通知'); + return; + } + + // 随机选择一条鼓励性消息 + const encouragingMessages = [ + '放松一下吧 🌸\n检测到您的压力指数较高,不妨暂停一下,做几个深呼吸,或者来一段轻松的普拉提练习。您的健康最重要!', + '该休息一下了 🧘‍♀️\n您的身体在提醒您需要放松。试试冥想、散步或听听舒缓的音乐,让心情平静下来。', + '压力山大?我们来帮您 💆‍♀️\n高压力对健康不利,建议您做一些放松运动,比如瑜伽或普拉提,释放身心压力。', + '关爱自己,从现在开始 💝\n检测到您可能承受较大压力,记得给自己一些时间,做喜欢的事情,保持身心健康。', + '深呼吸,一切都会好的 🌈\n压力只是暂时的,试试腹式呼吸或简单的伸展运动,让身体和心灵都得到放松。' + ]; + + const randomMessage = encouragingMessages[Math.floor(Math.random() * encouragingMessages.length)]; + const [title, body] = randomMessage.split('\n'); + + // 发送鼓励性推送通知 + await notificationService.sendImmediateNotification({ + title: title, + body: body, + data: { + type: 'stress_alert', + hrvValue: recentHRV, + timestamp: new Date().toISOString(), + url: '/mood/calendar' // 点击通知跳转到心情页面 + }, + sound: true, + priority: 'high' + }); + + // 记录通知发送时间 + await AsyncStorage.setItem(lastNotificationKey, now.toString()); + + console.log('压力提醒通知已发送'); + } else { + console.log(`压力水平正常,HRV值: ${recentHRV}`); + } + } catch (error) { + console.error('检查压力水平失败:', error); + } + }, []); + // 日期点击时,加载对应日期数据 const onSelectDate = (index: number, date: Date) => { setSelectedIndex(index); @@ -331,8 +421,10 @@ export default function ExploreScreen() { {/* 营养摄入雷达图卡片 */} { console.log('选择餐次:', mealType); diff --git a/app/_layout.tsx b/app/_layout.tsx index a128e5e..6f9ffd0 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,7 +12,7 @@ import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import { notificationService } from '@/services/notifications'; import { store } from '@/store'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; -import { NutritionNotificationHelpers } from '@/utils/notificationHelpers'; +import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import React from 'react'; import RNExitApp from 'react-native-exit-app'; @@ -45,12 +45,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { try { // 初始化通知服务 await notificationService.initialize(); - - // 只有在用户数据加载完成后且用户名存在时才注册午餐提醒 - if (userDataLoaded && profile?.name) { - await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name); - console.log('通知服务初始化成功,午餐提醒已注册'); - } + console.log('通知服务初始化成功'); } catch (error) { console.error('通知服务初始化失败:', error); } @@ -70,21 +65,31 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { } }, [userDataLoaded, privacyAgreed]); - // 当用户数据加载完成且用户名存在时,注册午餐提醒 + // 当用户数据加载完成且用户名存在时,注册所有提醒 React.useEffect(() => { - const registerLunchReminder = async () => { + const registerAllReminders = async () => { if (userDataLoaded && profile?.name) { try { await notificationService.initialize(); + + // 注册午餐提醒(12:00) await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name); console.log('午餐提醒已注册'); + + // 注册晚餐提醒(18:00) + await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name); + console.log('晚餐提醒已注册'); + + // 注册心情提醒(21:00) + await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name); + console.log('心情提醒已注册'); } catch (error) { - console.error('注册午餐提醒失败:', error); + console.error('注册提醒失败:', error); } } }; - registerLunchReminder(); + registerAllReminders(); }, [userDataLoaded, profile?.name]); const handlePrivacyAgree = () => { diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 5a39c28..ee898a3 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -5,14 +5,22 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { DietRecord, deleteDietRecord, getDietRecords } from '@/services/dietRecords'; +import { DietRecord } from '@/services/dietRecords'; import { selectHealthDataByDate } from '@/store/healthSlice'; -import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; +import { + deleteNutritionRecord, + fetchDailyNutritionData, + fetchNutritionRecords, + selectNutritionLoading, + selectNutritionRecordsByDate, + selectNutritionSummaryByDate +} from '@/store/nutritionSlice'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; import { router } from 'expo-router'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -35,90 +43,135 @@ export default function NutritionRecordsScreen() { const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const monthTitle = getMonthTitleZh(); - // 获取当前选中日期 - const getCurrentSelectedDate = () => { + // 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象 + const currentSelectedDate = useMemo(() => { return days[selectedIndex]?.date?.toDate() ?? new Date(); - }; + }, [selectedIndex, days]); - const currentSelectedDate = getCurrentSelectedDate(); - const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD'); + const currentSelectedDateString = useMemo(() => { + return dayjs(currentSelectedDate).format('YYYY-MM-DD'); + }, [currentSelectedDate]); // 从 Redux 获取数据 const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString)); const userProfile = useAppSelector((state) => state.user.profile); + // 从 Redux 获取营养记录数据 + const nutritionRecords = useAppSelector(selectNutritionRecordsByDate(currentSelectedDateString)); + const nutritionLoading = useAppSelector(selectNutritionLoading); + // 视图模式:按天查看 vs 全部查看 const [viewMode, setViewMode] = useState('daily'); - // 数据状态 - const [records, setRecords] = useState([]); - const [loading, setLoading] = useState(true); + // 全部记录模式的本地状态 + const [allRecords, setAllRecords] = useState([]); + const [allRecordsLoading, setAllRecordsLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [hasMoreData, setHasMoreData] = useState(true); const [page, setPage] = useState(1); - // 加载记录数据 - const loadRecords = async (isRefresh = false, loadMore = false) => { - try { - if (isRefresh) { - setRefreshing(true); - setPage(1); - } else if (loadMore) { - // 加载更多时不显示loading - } else { - setLoading(true); - } + // 根据视图模式选择使用的数据 + const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords; + const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading; - const currentPage = isRefresh ? 1 : (loadMore ? page + 1 : 1); - - let startDate: string | undefined; - let endDate: string | undefined; + // 页面聚焦时自动刷新数据 + useFocusEffect( + useCallback(() => { + console.log('营养记录页面聚焦,刷新数据...'); if (viewMode === 'daily') { - // 按天查看时,获取选中日期的数据 - startDate = days[selectedIndex]?.date.startOf('day').toISOString(); - endDate = days[selectedIndex]?.date.endOf('day').toISOString(); - } - - const data = await getDietRecords({ - startDate, - endDate, - page: currentPage, - limit: 10, - }); - - if (isRefresh || currentPage === 1) { - setRecords(data.records); + dispatch(fetchDailyNutritionData(currentSelectedDate)); } else { - setRecords(prev => [...prev, ...data.records]); - } + // 全部记录模式:重新加载数据 + const loadAllRecords = async () => { + try { + setAllRecordsLoading(true); + const response = await dispatch(fetchNutritionRecords({ + page: 1, + limit: 10, + append: false, + })); - setHasMoreData(data.records.length === 10); // 如果返回的记录数少于limit,说明没有更多数据 - setPage(currentPage); - } catch (error) { - console.error('加载营养记录失败:', error); - } finally { - setLoading(false); - setRefreshing(false); - } - }; + if (fetchNutritionRecords.fulfilled.match(response)) { + const { records } = response.payload; + setAllRecords(records); + setHasMoreData(records.length === 10); + setPage(1); + } + setAllRecordsLoading(false); + } catch (error) { + console.error('加载全部记录失败:', error); + setAllRecordsLoading(false); + } + }; + + loadAllRecords(); + } + }, [viewMode, currentSelectedDateString, dispatch]) + ); // 当选中日期或视图模式变化时重新加载数据 - useEffect(() => { - loadRecords(); - }, [selectedIndex, viewMode]); - - // 当选中日期变化时获取营养数据 useEffect(() => { if (viewMode === 'daily') { dispatch(fetchDailyNutritionData(currentSelectedDate)); - } - }, [selectedIndex, viewMode, currentSelectedDate, dispatch]); + } else { + setPage(1); // 重置分页 + setAllRecords([]); // 清空记录 - const onRefresh = () => { - loadRecords(true); - }; + // 全部记录模式:加载数据 + const loadAllRecords = async () => { + try { + setAllRecordsLoading(true); + const response = await dispatch(fetchNutritionRecords({ + page: 1, + limit: 10, + append: false, + })); + + if (fetchNutritionRecords.fulfilled.match(response)) { + const { records } = response.payload; + setAllRecords(records); + setHasMoreData(records.length === 10); + } + setAllRecordsLoading(false); + } catch (error) { + console.error('加载全部记录失败:', error); + setAllRecordsLoading(false); + } + }; + + loadAllRecords(); + } + }, [viewMode, currentSelectedDateString, dispatch]); + + const onRefresh = useCallback(async () => { + try { + setRefreshing(true); + + if (viewMode === 'daily') { + await dispatch(fetchDailyNutritionData(currentSelectedDate)); + } else { + // 全部记录模式:刷新数据 + setPage(1); + const response = await dispatch(fetchNutritionRecords({ + page: 1, + limit: 10, + append: false, + })); + + if (fetchNutritionRecords.fulfilled.match(response)) { + const { records } = response.payload; + setAllRecords(records); + setHasMoreData(records.length === 10); + } + } + } catch (error) { + console.error('刷新数据失败:', error); + } finally { + setRefreshing(false); + } + }, [viewMode, currentSelectedDateString, dispatch]); // 计算营养目标 const calculateNutritionGoals = () => { @@ -153,21 +206,47 @@ export default function NutritionRecordsScreen() { const nutritionGoals = calculateNutritionGoals(); - const loadMoreRecords = () => { - if (hasMoreData && !loading && !refreshing) { - loadRecords(false, true); + const loadMoreRecords = useCallback(async () => { + if (hasMoreData && !loading && !refreshing && viewMode === 'all') { + try { + const nextPage = page + 1; + const response = await dispatch(fetchNutritionRecords({ + page: nextPage, + limit: 10, + append: true, + })); + + if (fetchNutritionRecords.fulfilled.match(response)) { + const { records } = response.payload; + setAllRecords(prev => [...prev, ...records]); + setHasMoreData(records.length === 10); + setPage(nextPage); + } + } catch (error) { + console.error('加载更多记录失败:', error); + } } - }; + }, [hasMoreData, loading, refreshing, viewMode, page, dispatch]); // 删除记录 const handleDeleteRecord = async (recordId: number) => { try { - await deleteDietRecord(recordId); - // 从本地状态中移除已删除的记录 - setRecords(prev => prev.filter(record => record.id !== recordId)); + if (viewMode === 'daily') { + // 按天查看模式,使用 Redux 删除 + await dispatch(deleteNutritionRecord({ + recordId, + dateKey: currentSelectedDateString + })); + } else { + // 全部记录模式,从本地状态中移除 + await dispatch(deleteNutritionRecord({ + recordId, + dateKey: currentSelectedDateString + })); + setAllRecords(prev => prev.filter(record => record.id !== recordId)); + } } catch (error) { console.error('删除营养记录失败:', error); - // 可以添加错误提示 } }; @@ -254,7 +333,7 @@ export default function NutritionRecordsScreen() { ); } - if (viewMode === 'all' && records.length > 0) { + if (viewMode === 'all' && displayRecords.length > 0) { return ( @@ -267,11 +346,44 @@ export default function NutritionRecordsScreen() { return null; }; + // 根据当前时间智能判断餐次类型 + const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => { + const hour = new Date().getHours(); + + if (hour >= 5 && hour < 11) { + return 'breakfast'; // 5:00-10:59 早餐 + } else if (hour >= 11 && hour < 14) { + return 'lunch'; // 11:00-13:59 午餐 + } else if (hour >= 17 && hour < 21) { + return 'dinner'; // 17:00-20:59 晚餐 + } else { + return 'snack'; // 其他时间默认为零食 + } + }; + + // 添加食物的处理函数 + const handleAddFood = () => { + const mealType = getCurrentMealType(); + router.push(`/food-library?mealType=${mealType}`); + }; + + // 渲染右侧添加按钮 + const renderRightButton = () => ( + + + + ); + return ( router.back()} + right={renderRightButton()} /> {renderViewModeToggle()} @@ -282,7 +394,7 @@ export default function NutritionRecordsScreen() { metabolism={healthData?.basalEnergyBurned || 1482} exercise={healthData?.activeEnergyBurned || 0} consumed={nutritionSummary?.totalCalories || 0} - goal={userProfile?.dailyCaloriesGoal || 200} + goal={0} protein={nutritionSummary?.totalProtein || 0} fat={nutritionSummary?.totalFat || 0} carbs={nutritionSummary?.totalCarbohydrate || 0} @@ -300,7 +412,7 @@ export default function NutritionRecordsScreen() { ) : ( renderRecord({ item, index })} keyExtractor={(item) => item.id.toString()} contentContainerStyle={[ @@ -393,6 +505,14 @@ const styles = StyleSheet.create({ borderRadius: 16, alignItems: 'center', justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, }, loadingContainer: { flex: 1, diff --git a/components/CalorieRingChart.tsx b/components/CalorieRingChart.tsx index d08a780..22c28cb 100644 --- a/components/CalorieRingChart.tsx +++ b/components/CalorieRingChart.tsx @@ -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({ {/* 左上角公式展示 */} - 还能吃 = 代谢 + 运动 - 饮食 - 目标 + 还能吃 = 代谢 + 运动 - 饮食 @@ -113,7 +116,7 @@ export function CalorieRingChart({ 还能吃 - {canEat.toLocaleString()}千卡 + {canEat.toFixed(1)}千卡 {Math.round(progressPercentage)}% @@ -127,30 +130,25 @@ export function CalorieRingChart({ 代谢 - {metabolism.toLocaleString()}千卡 + {Math.round(metabolism)}千卡 运动 - {exercise}千卡 + {Math.round(exercise)}千卡 饮食 - {consumed}千卡 + {Math.round(consumed)}千卡 - - 目标 - - {goal}千卡 - - + @@ -161,7 +159,7 @@ export function CalorieRingChart({ 蛋白质 - {protein.toFixed(2)}/{proteinGoal.toFixed(2)}g + {Math.round(protein)}/{Math.round(proteinGoal)}g @@ -170,7 +168,7 @@ export function CalorieRingChart({ 脂肪 - {fat.toFixed(2)}/{fatGoal.toFixed(2)}g + {Math.round(fat)}/{Math.round(fatGoal)}g @@ -179,7 +177,7 @@ export function CalorieRingChart({ 碳水化合物 - {carbs.toFixed(2)}/{carbsGoal.toFixed(2)}g + {Math.round(carbs)}/{Math.round(carbsGoal)}g diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index ed0959f..4f1036c 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -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({ - 还能吃(千卡) - Math.round(v).toString()} - /> + 还能吃 + + Math.round(v).toString()} + /> + 千卡 + = - - 消耗 + 基代 Math.round(v).toString()} + /> + + + + 运动 + + Math.round(v).toString()} /> - - 饮食 Math.round(v).toString()} /> + @@ -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', diff --git a/docs/notification-reminders-implementation.md b/docs/notification-reminders-implementation.md new file mode 100644 index 0000000..45583ee --- /dev/null +++ b/docs/notification-reminders-implementation.md @@ -0,0 +1,181 @@ +# 新增提醒功能实现文档 + +## 功能概述 + +基于现有的午餐提醒功能,新增了两个提醒功能: +1. **晚餐提醒**:每天晚上6点提醒用户记录晚餐,点击跳转到营养记录页面 +2. **心情提醒**:每天晚上9点提醒用户记录当日心情,点击跳转到心情统计页面 + +## 实现细节 + +### 1. 通知帮助类扩展 + +#### 晚餐提醒功能 (`NutritionNotificationHelpers`) + +新增方法: +- `scheduleDailyDinnerReminder(userName, hour=18, minute=0)`: 注册每日晚餐提醒 +- `cancelDinnerReminder()`: 取消晚餐提醒 + +**特点:** +- 默认时间:18:00(晚上6点) +- 提醒文案:`🍽️ 晚餐时光到啦!${userName},美好的晚餐时光开始了~记得记录今天的晚餐哦!营养均衡很重要呢 💪` +- 跳转链接:`/nutrition/records` +- 通知类型:`dinner_reminder` + +#### 心情提醒功能 (`MoodNotificationHelpers`) + +新增类和方法: +- `scheduleDailyMoodReminder(userName, hour=21, minute=0)`: 注册每日心情提醒 +- `sendMoodReminder(userName)`: 发送即时心情提醒 +- `cancelMoodReminder()`: 取消心情提醒 + +**特点:** +- 默认时间:21:00(晚上9点) +- 提醒文案:`🌙 今天过得怎么样呀?${userName},夜深了~来记录一下今天的心情吧!每一份情感都值得被珍藏 ✨💕` +- 跳转链接:`/mood-statistics` +- 通知类型:`mood_reminder` + +### 2. 应用启动注册 + +在 `app/_layout.tsx` 的 `Bootstrapper` 组件中: + +```typescript +// 当用户数据加载完成且用户名存在时,注册所有提醒 +React.useEffect(() => { + const registerAllReminders = async () => { + if (userDataLoaded && profile?.name) { + try { + await notificationService.initialize(); + + // 注册午餐提醒(12:00) + await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name); + + // 注册晚餐提醒(18:00) + await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name); + + // 注册心情提醒(21:00) + await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name); + + console.log('所有提醒已注册'); + } catch (error) { + console.error('注册提醒失败:', error); + } + } + }; + + registerAllReminders(); +}, [userDataLoaded, profile?.name]); +``` + +### 3. 通知点击处理 + +在 `services/notifications.ts` 中扩展了 `handleNotificationResponse` 方法: + +```typescript +private handleNotificationResponse(response: Notifications.NotificationResponse): void { + const { notification } = response; + const data = notification.request.content.data; + + // ... 其他处理逻辑 + + if (data?.type === 'dinner_reminder') { + // 处理晚餐提醒通知 + console.log('用户点击了晚餐提醒通知', data); + // 跳转到营养记录页面 + if (data?.url) { + router.push(data.url as string); + } + } else if (data?.type === 'mood_reminder') { + // 处理心情提醒通知 + console.log('用户点击了心情提醒通知', data); + // 跳转到心情页面 + if (data?.url) { + router.push(data.url as string); + } + } +} +``` + +### 4. 通知类型扩展 + +在 `NotificationTypes` 中新增: +```typescript +export const NotificationTypes = { + // ... 现有类型 + DINNER_REMINDER: 'dinner_reminder', + MOOD_REMINDER: 'mood_reminder', +} as const; +``` + +## 用户体验设计 + +### 1. 提醒文案设计原则 +- **可爱生动**:使用emoji和亲切的语言 +- **个性化**:包含用户名称 +- **激励性**:鼓励用户养成良好习惯 +- **情感化**:让用户感受到关怀 + +### 2. 时间安排 +- **午餐提醒**:12:00 - 用餐高峰期 +- **晚餐提醒**:18:00 - 晚餐准备时间 +- **心情提醒**:21:00 - 一天结束,适合反思 + +### 3. 跳转逻辑 +- **营养提醒**:直接跳转到营养记录页面,方便用户快速记录 +- **心情提醒**:跳转到心情统计页面,用户可以查看历史并添加新记录 + +## 技术特点 + +### 1. 防重复注册 +- 每个提醒类型都会检查是否已存在相同的提醒 +- 避免重复注册导致的多次通知 + +### 2. 错误处理 +- 完整的try-catch错误处理 +- 详细的日志记录 +- 优雅的降级处理 + +### 3. 类型安全 +- 完整的TypeScript类型定义 +- 通知数据结构的类型约束 + +### 4. 可扩展性 +- 模块化的设计 +- 易于添加新的提醒类型 +- 统一的接口规范 + +## 测试建议 + +### 1. 功能测试 +- [ ] 验证提醒是否在正确时间触发 +- [ ] 测试通知点击跳转是否正确 +- [ ] 检查防重复注册机制 +- [ ] 验证用户名个性化显示 + +### 2. 用户体验测试 +- [ ] 提醒文案是否吸引人 +- [ ] 跳转页面是否符合预期 +- [ ] 通知频率是否合适 +- [ ] 整体用户流程是否顺畅 + +### 3. 边界情况测试 +- [ ] 用户名为空的处理 +- [ ] 权限被拒绝的处理 +- [ ] 应用被杀死后的提醒恢复 +- [ ] 系统时间变更的影响 + +## 后续优化建议 + +1. **个性化时间设置**:允许用户自定义提醒时间 +2. **智能提醒**:根据用户习惯调整提醒时间 +3. **提醒开关**:允许用户单独控制每种提醒 +4. **提醒统计**:记录用户对提醒的响应情况 +5. **A/B测试**:测试不同文案的效果 + +## 相关文件 + +- `utils/notificationHelpers.ts` - 提醒功能实现 +- `app/_layout.tsx` - 应用启动时的提醒注册 +- `services/notifications.ts` - 通知服务和点击处理 +- `app/nutrition/records.tsx` - 营养记录页面 +- `app/mood-statistics.tsx` - 心情统计页面 \ No newline at end of file diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist index 222a970..bb7c83d 100644 --- a/ios/digitalpilates/Info.plist +++ b/ios/digitalpilates/Info.plist @@ -63,7 +63,7 @@ UIBackgroundModes - fetch + processing UILaunchStoryboardName SplashScreen diff --git a/services/notifications.ts b/services/notifications.ts index e440415..e535fd3 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -1,4 +1,5 @@ import * as Notifications from 'expo-notifications'; +import { router } from 'expo-router'; // 配置通知处理方式 Notifications.setNotificationHandler({ @@ -133,6 +134,8 @@ export class NotificationService { const { notification } = response; const data = notification.request.content.data; + console.log('处理通知点击:', data); + // 根据通知类型处理不同的逻辑 if (data?.type === 'workout_reminder') { // 处理运动提醒 @@ -150,8 +153,24 @@ export class NotificationService { } else if (data?.type === 'lunch_reminder') { // 处理午餐提醒通知 console.log('用户点击了午餐提醒通知', data); - // 这里可以添加导航到午餐记录页面的逻辑 - + // 跳转到营养记录页面 + if (data?.url) { + router.push(data.url as any); + } + } else if (data?.type === 'dinner_reminder') { + // 处理晚餐提醒通知 + console.log('用户点击了晚餐提醒通知', data); + // 跳转到营养记录页面 + if (data?.url) { + router.push(data.url as any); + } + } else if (data?.type === 'mood_reminder') { + // 处理心情提醒通知 + console.log('用户点击了心情提醒通知', data); + // 跳转到心情页面 + if (data?.url) { + router.push(data.url as any); + } } } @@ -426,6 +445,8 @@ export const NotificationTypes = { NUTRITION_REMINDER: 'nutrition_reminder', PROGRESS_UPDATE: 'progress_update', LUNCH_REMINDER: 'lunch_reminder', + DINNER_REMINDER: 'dinner_reminder', + MOOD_REMINDER: 'mood_reminder', } as const; // 便捷方法 diff --git a/utils/health.ts b/utils/health.ts index 33118ba..5619260 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -454,6 +454,19 @@ export async function fetchTodayHRV(): Promise { return fetchHRVForDate(dayjs().toDate()); } +// 获取最近几小时内的实时HRV数据 +export async function fetchRecentHRV(hoursBack: number = 2): Promise { + console.log(`开始获取最近${hoursBack}小时内的HRV数据...`); + + const now = new Date(); + const options = { + startDate: dayjs(now).subtract(hoursBack, 'hour').toDate().toISOString(), + endDate: now.toISOString() + }; + + return fetchHeartRateVariability(options); +} + // 更新healthkit中的体重 export async function updateWeight(weight: number) { return new Promise((resolve) => { diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index 481877c..eb7d1ab 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -12,12 +12,12 @@ export function buildCoachDeepLink(params: { }): string { const baseUrl = '/coach'; const searchParams = new URLSearchParams(); - + if (params.action) searchParams.set('action', params.action); if (params.subAction) searchParams.set('subAction', params.subAction); if (params.meal) searchParams.set('meal', params.meal); if (params.message) searchParams.set('message', encodeURIComponent(params.message)); - + const queryString = searchParams.toString(); return queryString ? `${baseUrl}?${queryString}` : baseUrl; } @@ -286,48 +286,6 @@ export class GoalNotificationHelpers { } } -/** - * 心情相关的通知辅助函数 - */ -export class MoodNotificationHelpers { - /** - * 发送心情打卡提醒 - */ - static async sendMoodCheckinReminder(userName: string) { - return notificationService.sendImmediateNotification({ - title: '心情打卡', - body: `${userName},记得记录今天的心情状态哦`, - data: { type: 'mood_checkin_reminder' }, - sound: true, - priority: 'normal', - }); - } - - /** - * 安排每日心情打卡提醒 - */ - static async scheduleDailyMoodReminder(userName: string, hour: number = 20, minute: number = 0) { - const reminderTime = new Date(); - reminderTime.setHours(hour, minute, 0, 0); - - // 如果今天的时间已经过了,设置为明天 - if (reminderTime.getTime() <= Date.now()) { - reminderTime.setDate(reminderTime.getDate() + 1); - } - - return notificationService.scheduleRepeatingNotification( - { - title: '每日心情打卡', - body: `${userName},记得记录今天的心情状态哦`, - data: { type: 'daily_mood_reminder' }, - sound: true, - priority: 'normal', - }, - { days: 1 } - ); - } -} - /** * 营养相关的通知辅助函数 */ @@ -423,8 +381,8 @@ export class NutritionNotificationHelpers { return notificationService.sendImmediateNotification({ title: '午餐记录提醒', body: `${userName},记得记录今天的午餐情况哦!`, - data: { - type: 'lunch_reminder', + data: { + type: 'lunch_reminder', meal: '午餐', url: coachUrl }, @@ -453,6 +411,82 @@ export class NutritionNotificationHelpers { } } + /** + * 安排每日晚餐提醒 + * @param userName 用户名 + * @param hour 小时 (默认18点) + * @param minute 分钟 (默认0分) + * @returns 通知ID + */ + static async scheduleDailyDinnerReminder( + userName: string, + hour: number = 18, + minute: number = 0 + ): Promise { + try { + // 检查是否已经存在晚餐提醒 + const existingNotifications = await notificationService.getAllScheduledNotifications(); + + const existingDinnerReminder = existingNotifications.find( + notification => + notification.content.data?.type === 'dinner_reminder' && + notification.content.data?.isDailyReminder === true + ); + + if (existingDinnerReminder) { + console.log('晚餐提醒已存在,跳过重复注册:', existingDinnerReminder.identifier); + return existingDinnerReminder.identifier; + } + + // 创建晚餐提醒通知 + const notificationId = await notificationService.scheduleCalendarRepeatingNotification( + { + title: '🍽️ 晚餐时光到啦!', + body: `${userName},美好的晚餐时光开始了~记得记录今天的晚餐哦!营养均衡很重要呢 💪`, + data: { + type: 'dinner_reminder', + isDailyReminder: true, + meal: '晚餐', + url: '/nutrition/records' // 直接跳转到营养记录页面 + }, + sound: true, + priority: 'normal', + }, + { + type: Notifications.SchedulableTriggerInputTypes.DAILY, + hour: hour, + minute: minute, + } + ); + + console.log('每日晚餐提醒已安排,ID:', notificationId); + return notificationId; + } catch (error) { + console.error('安排每日晚餐提醒失败:', error); + throw error; + } + } + + /** + * 取消晚餐提醒 + */ + static async cancelDinnerReminder(): Promise { + try { + const notifications = await notificationService.getAllScheduledNotifications(); + + for (const notification of notifications) { + if (notification.content.data?.type === 'dinner_reminder' && + notification.content.data?.isDailyReminder === true) { + await notificationService.cancelNotification(notification.identifier); + console.log('已取消晚餐提醒:', notification.identifier); + } + } + } catch (error) { + console.error('取消晚餐提醒失败:', error); + throw error; + } + } + /** * 安排营养记录提醒 */ @@ -478,10 +512,10 @@ export class NutritionNotificationHelpers { // 构建深度链接 const mealTypeMap: Record = { '早餐': 'breakfast', - '午餐': 'lunch', + '午餐': 'lunch', '晚餐': 'dinner' }; - + const coachUrl = buildCoachDeepLink({ action: 'diet', subAction: 'card', @@ -492,8 +526,8 @@ export class NutritionNotificationHelpers { { title: `${mealTime.meal}提醒`, body: `${userName},记得记录您的${mealTime.meal}情况`, - data: { - type: 'meal_reminder', + data: { + type: 'meal_reminder', meal: mealTime.meal, url: coachUrl }, @@ -510,6 +544,102 @@ export class NutritionNotificationHelpers { } } +/** + * 心情相关的通知辅助函数 + */ +export class MoodNotificationHelpers { + /** + * 安排每日心情提醒 + * @param userName 用户名 + * @param hour 小时 (默认21点) + * @param minute 分钟 (默认0分) + * @returns 通知ID + */ + static async scheduleDailyMoodReminder( + userName: string, + hour: number = 21, + minute: number = 0 + ): Promise { + try { + // 检查是否已经存在心情提醒 + const existingNotifications = await notificationService.getAllScheduledNotifications(); + + const existingMoodReminder = existingNotifications.find( + notification => + notification.content.data?.type === 'mood_reminder' && + notification.content.data?.isDailyReminder === true + ); + + if (existingMoodReminder) { + console.log('心情提醒已存在,跳过重复注册:', existingMoodReminder.identifier); + return existingMoodReminder.identifier; + } + + // 创建心情提醒通知 + const notificationId = await notificationService.scheduleCalendarRepeatingNotification( + { + title: '🌙 今天过得怎么样呀?', + body: `${userName},夜深了~来记录一下今天的心情吧!每一份情感都值得被珍藏 ✨💕`, + data: { + type: 'mood_reminder', + isDailyReminder: true, + url: '/mood-statistics' // 跳转到心情统计页面 + }, + sound: true, + priority: 'normal', + }, + { + type: Notifications.SchedulableTriggerInputTypes.DAILY, + hour: hour, + minute: minute, + } + ); + + console.log('每日心情提醒已安排,ID:', notificationId); + return notificationId; + } catch (error) { + console.error('安排每日心情提醒失败:', error); + throw error; + } + } + + /** + * 发送心情记录提醒 + */ + static async sendMoodReminder(userName: string) { + return notificationService.sendImmediateNotification({ + title: '🌙 今天过得怎么样呀?', + body: `${userName},夜深了~来记录一下今天的心情吧!每一份情感都值得被珍藏 ✨💕`, + data: { + type: 'mood_reminder', + url: '/mood-statistics' + }, + sound: true, + priority: 'normal', + }); + } + + /** + * 取消心情提醒 + */ + static async cancelMoodReminder(): Promise { + try { + const notifications = await notificationService.getAllScheduledNotifications(); + + for (const notification of notifications) { + if (notification.content.data?.type === 'mood_reminder' && + notification.content.data?.isDailyReminder === true) { + await notificationService.cancelNotification(notification.identifier); + console.log('已取消心情提醒:', notification.identifier); + } + } + } catch (error) { + console.error('取消心情提醒失败:', error); + throw error; + } + } +} + /** * 通用通知辅助函数 */ @@ -634,11 +764,11 @@ export const NotificationTemplates = { reminder: (userName: string, meal: string) => { const mealTypeMap: Record = { '早餐': 'breakfast', - '午餐': 'lunch', + '午餐': 'lunch', '晚餐': 'dinner', '加餐': 'snack' }; - + const coachUrl = buildCoachDeepLink({ action: 'diet', subAction: 'card', @@ -648,8 +778,8 @@ export const NotificationTemplates = { return { title: `${meal}提醒`, body: `${userName},记得记录您的${meal}情况`, - data: { - type: 'meal_reminder', + data: { + type: 'meal_reminder', meal, url: coachUrl }, @@ -667,8 +797,8 @@ export const NotificationTemplates = { return { title: '午餐记录提醒', body: `${userName},记得记录今天的午餐情况哦!`, - data: { - type: 'lunch_reminder', + data: { + type: 'lunch_reminder', meal: '午餐', url: coachUrl }, diff --git a/utils/nutrition.ts b/utils/nutrition.ts new file mode 100644 index 0000000..97a8c37 --- /dev/null +++ b/utils/nutrition.ts @@ -0,0 +1,78 @@ +import dayjs from 'dayjs'; + +export interface NutritionGoals { + calories: number; + proteinGoal: number; + fatGoal: number; + carbsGoal: number; + fiberGoal: number; + sodiumGoal: number; +} + +export interface UserProfileForNutrition { + weight?: string; + height?: string; + birthDate?: Date; + gender?: 'male' | 'female'; +} + +/** + * 计算用户的营养目标 + * 基于Mifflin-St Jeor公式计算基础代谢率,然后计算各营养素目标 + */ +export const calculateNutritionGoals = (userProfile?: UserProfileForNutrition): NutritionGoals => { + const weight = parseFloat(userProfile?.weight || '70'); // 默认70kg + const height = parseFloat(userProfile?.height || '170'); // 默认170cm + const age = userProfile?.birthDate ? + dayjs().diff(dayjs(userProfile.birthDate), 'year') : 25; // 默认25岁 + const isWoman = userProfile?.gender === 'female'; + + // 基础代谢率计算(Mifflin-St Jeor Equation) + let bmr; + if (isWoman) { + bmr = 10 * weight + 6.25 * height - 5 * age - 161; + } else { + bmr = 10 * weight + 6.25 * height - 5 * age + 5; + } + + // 总热量需求(假设轻度活动) + const totalCalories = bmr * 1.375; + + // 计算营养素目标 + const proteinGoal = weight * 1.6; // 1.6g/kg + const fatGoal = totalCalories * 0.25 / 9; // 25%来自脂肪,9卡/克 + const carbsGoal = (totalCalories - proteinGoal * 4 - fatGoal * 9) / 4; // 剩余来自碳水 + + // 纤维目标:成人推荐25-35g/天 + const fiberGoal = 25; + + // 钠目标:WHO推荐<2300mg/天 + const sodiumGoal = 2300; + + return { + calories: Math.round(totalCalories), + proteinGoal: Math.round(proteinGoal * 10) / 10, + fatGoal: Math.round(fatGoal * 10) / 10, + carbsGoal: Math.round(carbsGoal * 10) / 10, + fiberGoal, + sodiumGoal, + }; +}; + +/** + * 计算剩余可摄入卡路里 + * 公式:还能吃 = 基础代谢 + 运动消耗 - 已摄入饮食 + */ +export const calculateRemainingCalories = (params: { + basalMetabolism: number; + activeCalories: number; + consumedCalories: number; +}): number => { + const { basalMetabolism, activeCalories, consumedCalories } = params; + + // 总消耗 = 基础代谢 + 运动消耗 + const totalBurned = basalMetabolism + activeCalories; + + // 剩余可摄入 = 总消耗 - 已摄入 + return totalBurned - consumedCalories; +}; \ No newline at end of file