feat(notifications): 新增晚餐和心情提醒功能,支持HRV压力检测和后台处理
- 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知 - 实现基于HRV数据的压力检测和智能鼓励通知 - 添加后台任务处理支持,修改iOS后台模式为processing - 优化营养记录页面使用Redux状态管理,支持实时数据更新 - 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食 - 新增营养目标动态计算功能,基于用户身体数据智能推荐 - 完善通知点击跳转逻辑,支持多种提醒类型的路由处理
This commit is contained in:
2
app.json
2
app.json
@@ -18,7 +18,7 @@
|
||||
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
||||
"UIBackgroundModes": [
|
||||
"remote-notification"
|
||||
"processing"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
nutritionSummary={nutritionSummary}
|
||||
nutritionGoals={nutritionGoals}
|
||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
||||
calorieDeficit={0}
|
||||
basalMetabolism={basalMetabolism || 0}
|
||||
activeCalories={activeCalories || 0}
|
||||
resetToken={animToken}
|
||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
||||
console.log('选择餐次:', mealType);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,91 +43,136 @@ 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<ViewMode>('daily');
|
||||
|
||||
// 数据状态
|
||||
const [records, setRecords] = useState<DietRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// 全部记录模式的本地状态
|
||||
const [allRecords, setAllRecords] = useState<DietRecord[]>([]);
|
||||
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);
|
||||
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||
const { records } = response.payload;
|
||||
setAllRecords(records);
|
||||
setHasMoreData(records.length === 10);
|
||||
setPage(1);
|
||||
}
|
||||
setAllRecordsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('加载营养记录失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
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 = () => {
|
||||
const weight = parseFloat(userProfile?.weight || '70'); // 默认70kg
|
||||
@@ -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 (
|
||||
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||
@@ -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 = () => (
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
onPress={handleAddFood}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={colorTokens.primary} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar
|
||||
title="营养记录"
|
||||
onBack={() => 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() {
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={records}
|
||||
data={displayRecords}
|
||||
renderItem={({ item, index }) => 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,
|
||||
|
||||
@@ -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>
|
||||
<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',
|
||||
|
||||
181
docs/notification-reminders-implementation.md
Normal file
181
docs/notification-reminders-implementation.md
Normal file
@@ -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` - 心情统计页面
|
||||
@@ -63,7 +63,7 @@
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 便捷方法
|
||||
|
||||
@@ -454,6 +454,19 @@ export async function fetchTodayHRV(): Promise<number | null> {
|
||||
return fetchHRVForDate(dayjs().toDate());
|
||||
}
|
||||
|
||||
// 获取最近几小时内的实时HRV数据
|
||||
export async function fetchRecentHRV(hoursBack: number = 2): Promise<number | null> {
|
||||
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) => {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养相关的通知辅助函数
|
||||
*/
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排营养记录提醒
|
||||
*/
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用通知辅助函数
|
||||
*/
|
||||
|
||||
78
utils/nutrition.ts
Normal file
78
utils/nutrition.ts
Normal file
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user