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

@@ -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);

View File

@@ -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 = () => {

View File

@@ -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<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);
} 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 (
<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,