feat: 新增喝水提醒功能,支持定期提醒和目标检查
This commit is contained in:
@@ -18,6 +18,8 @@ import { notificationService } from '@/services/notifications';
|
|||||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||||
|
import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice';
|
||||||
|
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health';
|
||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||||
@@ -26,6 +28,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -77,19 +80,21 @@ export default function ExploreScreen() {
|
|||||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||||
}, [tabBarHeight, insets?.bottom]);
|
}, [tabBarHeight, insets?.bottom]);
|
||||||
|
|
||||||
// 获取当前选中日期
|
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||||
const getCurrentSelectedDate = () => {
|
const currentSelectedDate = useMemo(() => {
|
||||||
const days = getMonthDaysZh();
|
const days = getMonthDaysZh();
|
||||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
};
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const currentSelectedDateString = useMemo(() => {
|
||||||
// 获取当前选中日期
|
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||||
const currentSelectedDate = getCurrentSelectedDate();
|
}, [currentSelectedDate]);
|
||||||
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
// 从 Redux 获取指定日期的健康数据
|
// 从 Redux 获取指定日期的健康数据
|
||||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||||
|
|
||||||
|
// 获取今日喝水统计数据
|
||||||
|
const todayWaterStats = useAppSelector(selectTodayStats);
|
||||||
|
|
||||||
// 解构健康数据(支持mock数据)
|
// 解构健康数据(支持mock数据)
|
||||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||||
@@ -151,8 +156,36 @@ export default function ExploreScreen() {
|
|||||||
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
||||||
const latestRequestKeyRef = useRef<string | null>(null);
|
const latestRequestKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// 请求状态管理,防止重复请求
|
||||||
|
const loadingRef = useRef({
|
||||||
|
health: false,
|
||||||
|
nutrition: false,
|
||||||
|
mood: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据缓存时间戳,避免短时间内重复拉取
|
||||||
|
const dataTimestampRef = useRef<{ [key: string]: number }>({});
|
||||||
|
|
||||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
|
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格)
|
||||||
|
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||||
|
const cacheKey = `${dateKey}-${dataType}`;
|
||||||
|
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 营养数据使用更短的缓存时间,其他数据使用5分钟
|
||||||
|
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
||||||
|
|
||||||
|
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新数据时间戳
|
||||||
|
const updateDataTimestamp = (dateKey: string, dataType: string) => {
|
||||||
|
const cacheKey = `${dateKey}-${dataType}`;
|
||||||
|
dataTimestampRef.current[cacheKey] = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// 从 Redux 获取当前日期的心情记录
|
// 从 Redux 获取当前日期的心情记录
|
||||||
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||||||
@@ -160,33 +193,74 @@ export default function ExploreScreen() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
// 加载心情数据
|
// 加载心情数据
|
||||||
const loadMoodData = async (targetDate?: Date) => {
|
const loadMoodData = async (targetDate?: Date, forceRefresh = false) => {
|
||||||
if (!isLoggedIn) return;
|
if (!isLoggedIn) return;
|
||||||
|
|
||||||
try {
|
// 确定要查询的日期
|
||||||
setIsMoodLoading(true);
|
let derivedDate: Date;
|
||||||
|
if (targetDate) {
|
||||||
|
derivedDate = targetDate;
|
||||||
|
} else {
|
||||||
|
derivedDate = currentSelectedDate;
|
||||||
|
}
|
||||||
|
|
||||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
const requestKey = getDateKey(derivedDate);
|
||||||
let derivedDate: Date;
|
|
||||||
if (targetDate) {
|
// 检查是否正在加载或不需要刷新
|
||||||
derivedDate = targetDate;
|
if (loadingRef.current.mood) {
|
||||||
} else {
|
console.log('心情数据正在加载中,跳过重复请求');
|
||||||
derivedDate = getCurrentSelectedDate();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshData(requestKey, 'mood')) {
|
||||||
|
console.log('心情数据缓存未过期,跳过请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingRef.current.mood = true;
|
||||||
|
setIsMoodLoading(true);
|
||||||
|
|
||||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||||
await dispatch(fetchDailyMoodCheckins(dateString));
|
await dispatch(fetchDailyMoodCheckins(dateString));
|
||||||
|
|
||||||
|
// 更新缓存时间戳
|
||||||
|
updateDataTimestamp(requestKey, 'mood');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载心情数据失败:', error);
|
console.error('加载心情数据失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingRef.current.mood = false;
|
||||||
setIsMoodLoading(false);
|
setIsMoodLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loadHealthData = async (targetDate?: Date) => {
|
const loadHealthData = async (targetDate?: Date, forceRefresh = false) => {
|
||||||
|
// 确定要查询的日期
|
||||||
|
let derivedDate: Date;
|
||||||
|
if (targetDate) {
|
||||||
|
derivedDate = targetDate;
|
||||||
|
} else {
|
||||||
|
derivedDate = currentSelectedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKey = getDateKey(derivedDate);
|
||||||
|
|
||||||
|
// 检查是否正在加载或不需要刷新
|
||||||
|
if (loadingRef.current.health) {
|
||||||
|
console.log('健康数据正在加载中,跳过重复请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshData(requestKey, 'health')) {
|
||||||
|
console.log('健康数据缓存未过期,跳过请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
loadingRef.current.health = true;
|
||||||
console.log('=== 开始HealthKit初始化流程 ===');
|
console.log('=== 开始HealthKit初始化流程 ===');
|
||||||
|
|
||||||
const ok = await ensureHealthPermissions();
|
const ok = await ensureHealthPermissions();
|
||||||
@@ -196,15 +270,6 @@ export default function ExploreScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
|
||||||
let derivedDate: Date;
|
|
||||||
if (targetDate) {
|
|
||||||
derivedDate = targetDate;
|
|
||||||
} else {
|
|
||||||
derivedDate = getCurrentSelectedDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestKey = getDateKey(derivedDate);
|
|
||||||
latestRequestKeyRef.current = requestKey;
|
latestRequestKeyRef.current = requestKey;
|
||||||
|
|
||||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||||
@@ -223,8 +288,10 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
// 更新HRV数据时间
|
// 更新HRV数据时间
|
||||||
setHrvUpdateTime(new Date());
|
setHrvUpdateTime(new Date());
|
||||||
|
|
||||||
setAnimToken((t) => t + 1);
|
setAnimToken((t) => t + 1);
|
||||||
|
|
||||||
|
// 更新缓存时间戳
|
||||||
|
updateDataTimestamp(requestKey, 'health');
|
||||||
} else {
|
} else {
|
||||||
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||||||
}
|
}
|
||||||
@@ -232,55 +299,108 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('HealthKit流程出现异常:', error);
|
console.error('HealthKit流程出现异常:', error);
|
||||||
|
} finally {
|
||||||
|
loadingRef.current.health = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载营养数据
|
// 加载营养数据
|
||||||
const loadNutritionData = async (targetDate?: Date) => {
|
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
|
||||||
try {
|
if (!isLoggedIn) return;
|
||||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
|
||||||
let derivedDate: Date;
|
|
||||||
if (targetDate) {
|
|
||||||
derivedDate = targetDate;
|
|
||||||
} else {
|
|
||||||
derivedDate = getCurrentSelectedDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 确定要查询的日期
|
||||||
|
let derivedDate: Date;
|
||||||
|
if (targetDate) {
|
||||||
|
derivedDate = targetDate;
|
||||||
|
} else {
|
||||||
|
derivedDate = currentSelectedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKey = getDateKey(derivedDate);
|
||||||
|
|
||||||
|
// 检查是否正在加载或不需要刷新
|
||||||
|
if (loadingRef.current.nutrition) {
|
||||||
|
console.log('营养数据正在加载中,跳过重复请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
|
||||||
|
console.log('营养数据缓存未过期,跳过请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingRef.current.nutrition = true;
|
||||||
console.log('加载营养数据...', derivedDate);
|
console.log('加载营养数据...', derivedDate);
|
||||||
await dispatch(fetchDailyNutritionData(derivedDate));
|
await dispatch(fetchDailyNutritionData(derivedDate));
|
||||||
console.log('营养数据加载完成');
|
console.log('营养数据加载完成');
|
||||||
|
|
||||||
|
// 更新缓存时间戳
|
||||||
|
updateDataTimestamp(requestKey, 'nutrition');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('营养数据加载失败:', error);
|
console.error('营养数据加载失败:', error);
|
||||||
|
} finally {
|
||||||
|
loadingRef.current.nutrition = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载所有数据的统一方法
|
// 实际执行数据加载的方法
|
||||||
const loadAllData = React.useCallback((targetDate?: Date) => {
|
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||||
const dateToUse = targetDate || getCurrentSelectedDate();
|
const dateToUse = targetDate || currentSelectedDate;
|
||||||
if (dateToUse) {
|
if (dateToUse) {
|
||||||
loadHealthData(dateToUse);
|
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||||
|
loadHealthData(dateToUse, forceRefresh);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadNutritionData(dateToUse);
|
loadNutritionData(dateToUse, forceRefresh);
|
||||||
loadMoodData(dateToUse);
|
loadMoodData(dateToUse, forceRefresh);
|
||||||
|
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||||
|
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||||
|
if (isToday) {
|
||||||
|
dispatch(fetchTodayWaterStats());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn, dispatch]);
|
||||||
|
|
||||||
useFocusEffect(
|
// 使用 lodash debounce 防抖的加载所有数据方法
|
||||||
React.useCallback(() => {
|
const debouncedLoadAllData = React.useMemo(
|
||||||
// 每次聚焦时都拉取当前选中日期的最新数据
|
() => debounce(executeLoadAllData, 300), // 300ms 防抖延迟
|
||||||
loadAllData();
|
[executeLoadAllData]
|
||||||
}, [loadAllData])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// AppState 监听:应用从后台返回前台时刷新数据
|
// 对外暴露的 loadAllData 方法
|
||||||
|
const loadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||||
|
if (forceRefresh) {
|
||||||
|
// 如果是强制刷新,立即执行,不使用防抖
|
||||||
|
executeLoadAllData(targetDate, forceRefresh);
|
||||||
|
} else {
|
||||||
|
// 普通调用使用防抖
|
||||||
|
debouncedLoadAllData(targetDate, forceRefresh);
|
||||||
|
}
|
||||||
|
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||||
|
|
||||||
|
// 页面聚焦时的数据加载逻辑
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
// 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||||
|
console.log('页面聚焦,检查是否需要刷新数据...');
|
||||||
|
loadAllData(currentSelectedDate);
|
||||||
|
}, [loadAllData, currentSelectedDate])
|
||||||
|
);
|
||||||
|
|
||||||
|
// AppState 监听:应用从后台返回前台时的处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let appStateChangeTimeout: number;
|
||||||
|
|
||||||
const handleAppStateChange = (nextAppState: string) => {
|
const handleAppStateChange = (nextAppState: string) => {
|
||||||
if (nextAppState === 'active') {
|
if (nextAppState === 'active') {
|
||||||
// 应用从后台返回前台,刷新当前选中日期的数据
|
// 延迟执行,避免与 useFocusEffect 重复触发
|
||||||
console.log('应用从后台返回前台,刷新统计数据...');
|
appStateChangeTimeout = setTimeout(() => {
|
||||||
loadAllData();
|
console.log('应用从后台返回前台,强制刷新统计数据...');
|
||||||
|
// 从后台返回时强制刷新数据
|
||||||
|
loadAllData(currentSelectedDate, true);
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,19 +408,28 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription?.remove();
|
subscription?.remove();
|
||||||
|
if (appStateChangeTimeout) {
|
||||||
|
clearTimeout(appStateChangeTimeout);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [loadAllData]);
|
}, [loadAllData, currentSelectedDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 注册任务
|
// 注册后台任务 - 只处理健康数据和压力检查
|
||||||
registerTask({
|
registerTask({
|
||||||
id: 'health-data-task',
|
id: 'health-data-task',
|
||||||
name: 'health-data-task',
|
name: 'health-data-task',
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
try {
|
try {
|
||||||
await loadHealthData();
|
console.log('后台任务:更新健康数据和检查压力水平...');
|
||||||
|
// 后台任务只更新健康数据,强制刷新以获取最新数据
|
||||||
|
await loadHealthData(undefined, true);
|
||||||
|
|
||||||
checkStressLevelAndNotify()
|
// 执行压力检查
|
||||||
|
await checkStressLevelAndNotify();
|
||||||
|
|
||||||
|
// 执行喝水目标检查
|
||||||
|
await checkWaterGoalAndNotify();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('健康数据任务执行失败:', error);
|
console.error('健康数据任务执行失败:', error);
|
||||||
}
|
}
|
||||||
@@ -382,11 +511,54 @@ export default function ExploreScreen() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 检查喝水目标并发送通知
|
||||||
|
const checkWaterGoalAndNotify = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('开始检查喝水目标完成情况...');
|
||||||
|
|
||||||
|
// 获取最新的喝水统计数据
|
||||||
|
if (!todayWaterStats || !todayWaterStats.dailyGoal || todayWaterStats.dailyGoal <= 0) {
|
||||||
|
console.log('没有设置喝水目标或目标无效,跳过喝水检查');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户名
|
||||||
|
const userName = userProfile?.name || '朋友';
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
|
||||||
|
// 构造今日统计数据
|
||||||
|
const waterStatsForCheck = {
|
||||||
|
totalAmount: todayWaterStats.totalAmount || 0,
|
||||||
|
dailyGoal: todayWaterStats.dailyGoal,
|
||||||
|
completionRate: todayWaterStats.completionRate || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调用喝水通知检查函数
|
||||||
|
const notificationSent = await WaterNotificationHelpers.checkWaterGoalAndNotify(
|
||||||
|
userName,
|
||||||
|
waterStatsForCheck,
|
||||||
|
currentHour
|
||||||
|
);
|
||||||
|
|
||||||
|
if (notificationSent) {
|
||||||
|
console.log('喝水提醒通知已发送');
|
||||||
|
} else {
|
||||||
|
console.log('无需发送喝水提醒通知');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查喝水目标失败:', error);
|
||||||
|
}
|
||||||
|
}, [todayWaterStats, userProfile]);
|
||||||
|
|
||||||
// 日期点击时,加载对应日期数据
|
// 日期点击时,加载对应日期数据
|
||||||
const onSelectDate = (index: number, date: Date) => {
|
const onSelectDate = React.useCallback((index: number, date: Date) => {
|
||||||
setSelectedIndex(index);
|
setSelectedIndex(index);
|
||||||
loadAllData(date);
|
console.log('日期切换,加载数据...', date);
|
||||||
};
|
// 日期切换时不强制刷新,依赖缓存机制减少不必要的请求
|
||||||
|
// loadAllData 内部已经实现了防抖,无需额外防抖处理
|
||||||
|
loadAllData(date, false);
|
||||||
|
}, [loadAllData]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@sentry/react-native": "^6.20.0",
|
"@sentry/react-native": "^6.20.0",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
"cos-js-sdk-v5": "^1.6.0",
|
"cos-js-sdk-v5": "^1.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"expo-system-ui": "~5.0.10",
|
"expo-system-ui": "~5.0.10",
|
||||||
"expo-task-manager": "^13.1.6",
|
"expo-task-manager": "^13.1.6",
|
||||||
"expo-web-browser": "~14.2.0",
|
"expo-web-browser": "~14.2.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-native": "0.79.5",
|
"react-native": "0.79.5",
|
||||||
@@ -3853,6 +3855,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.20",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/@types/lodash/-/lodash-4.17.20.tgz",
|
||||||
|
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.2.1",
|
"version": "24.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||||
@@ -9575,6 +9583,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@sentry/react-native": "^6.20.0",
|
"@sentry/react-native": "^6.20.0",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
"cos-js-sdk-v5": "^1.6.0",
|
"cos-js-sdk-v5": "^1.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"expo-system-ui": "~5.0.10",
|
"expo-system-ui": "~5.0.10",
|
||||||
"expo-task-manager": "^13.1.6",
|
"expo-task-manager": "^13.1.6",
|
||||||
"expo-web-browser": "~14.2.0",
|
"expo-web-browser": "~14.2.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-native": "0.79.5",
|
"react-native": "0.79.5",
|
||||||
|
|||||||
@@ -81,6 +81,78 @@ export const createNotificationCheckTask = (): BackgroundTask => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 喝水提醒检查任务
|
||||||
|
export const createWaterReminderTask = (): BackgroundTask => ({
|
||||||
|
id: 'water-reminder-task',
|
||||||
|
name: '喝水提醒检查任务',
|
||||||
|
handler: async (data?: any) => {
|
||||||
|
console.log('开始执行喝水提醒检查任务');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 导入必要的模块
|
||||||
|
const { WaterNotificationHelpers } = await import('@/utils/notificationHelpers');
|
||||||
|
const { getTodayWaterStats } = await import('@/services/waterRecords');
|
||||||
|
const AsyncStorage = (await import('@react-native-async-storage/async-storage')).default;
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
const userProfileJson = await AsyncStorage.getItem('@user_profile');
|
||||||
|
const userProfile = userProfileJson ? JSON.parse(userProfileJson) : null;
|
||||||
|
const userName = userProfile?.name || '朋友';
|
||||||
|
|
||||||
|
// 检查时间限制:早上9点以前和晚上9点以后不通知
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
if (currentHour < 9 || currentHour >= 21) {
|
||||||
|
console.log(`当前时间${currentHour}点,不在通知时间范围内(9:00-21:00),跳过喝水提醒检查`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取今日喝水统计数据
|
||||||
|
let todayStats;
|
||||||
|
try {
|
||||||
|
todayStats = await getTodayWaterStats();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('获取喝水统计数据失败,可能用户未登录或无网络连接:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!todayStats || !todayStats.dailyGoal || todayStats.dailyGoal <= 0) {
|
||||||
|
console.log('没有设置喝水目标或目标无效,跳过喝水检查');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造今日统计数据
|
||||||
|
const waterStatsForCheck = {
|
||||||
|
totalAmount: todayStats.totalAmount || 0,
|
||||||
|
dailyGoal: todayStats.dailyGoal,
|
||||||
|
completionRate: todayStats.completionRate || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调用喝水通知检查函数
|
||||||
|
const notificationSent = await WaterNotificationHelpers.checkWaterGoalAndNotify(
|
||||||
|
userName,
|
||||||
|
waterStatsForCheck,
|
||||||
|
currentHour
|
||||||
|
);
|
||||||
|
|
||||||
|
if (notificationSent) {
|
||||||
|
console.log('喝水提醒通知已发送');
|
||||||
|
} else {
|
||||||
|
console.log('无需发送喝水提醒通知');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('喝水提醒检查任务执行完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('喝水提醒检查任务执行失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
minimumInterval: 60, // 60分钟最小间隔
|
||||||
|
stopOnTerminate: false,
|
||||||
|
startOnBoot: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 示例任务:缓存清理任务
|
// 示例任务:缓存清理任务
|
||||||
export const createCacheCleanupTask = (): BackgroundTask => ({
|
export const createCacheCleanupTask = (): BackgroundTask => ({
|
||||||
id: 'cache-cleanup-task',
|
id: 'cache-cleanup-task',
|
||||||
@@ -142,6 +214,7 @@ export const registerDefaultTasks = async (): Promise<void> => {
|
|||||||
createDataSyncTask(),
|
createDataSyncTask(),
|
||||||
createHealthDataUpdateTask(),
|
createHealthDataUpdateTask(),
|
||||||
createNotificationCheckTask(),
|
createNotificationCheckTask(),
|
||||||
|
createWaterReminderTask(),
|
||||||
createCacheCleanupTask(),
|
createCacheCleanupTask(),
|
||||||
createUserAnalyticsTask(),
|
createUserAnalyticsTask(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ export class NotificationService {
|
|||||||
if (data?.url) {
|
if (data?.url) {
|
||||||
router.push(data.url as any);
|
router.push(data.url as any);
|
||||||
}
|
}
|
||||||
|
} else if (data?.type === 'water_reminder' || data?.type === 'regular_water_reminder') {
|
||||||
|
// 处理喝水提醒通知
|
||||||
|
console.log('用户点击了喝水提醒通知', data);
|
||||||
|
// 跳转到统计页面查看喝水进度
|
||||||
|
if (data?.url) {
|
||||||
|
router.push(data.url as any);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +453,8 @@ export const NotificationTypes = {
|
|||||||
LUNCH_REMINDER: 'lunch_reminder',
|
LUNCH_REMINDER: 'lunch_reminder',
|
||||||
DINNER_REMINDER: 'dinner_reminder',
|
DINNER_REMINDER: 'dinner_reminder',
|
||||||
MOOD_REMINDER: 'mood_reminder',
|
MOOD_REMINDER: 'mood_reminder',
|
||||||
|
WATER_REMINDER: 'water_reminder',
|
||||||
|
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 便捷方法
|
// 便捷方法
|
||||||
|
|||||||
@@ -638,6 +638,210 @@ export class MoodNotificationHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 喝水相关的通知辅助函数
|
||||||
|
*/
|
||||||
|
export class WaterNotificationHelpers {
|
||||||
|
/**
|
||||||
|
* 检查喝水目标完成情况并发送提醒
|
||||||
|
* @param userName 用户名
|
||||||
|
* @param todayStats 今日喝水统计数据
|
||||||
|
* @param currentHour 当前小时(用于时间限制检查)
|
||||||
|
* @returns 是否发送了通知
|
||||||
|
*/
|
||||||
|
static async checkWaterGoalAndNotify(
|
||||||
|
userName: string,
|
||||||
|
todayStats: { totalAmount: number; dailyGoal: number; completionRate: number },
|
||||||
|
currentHour: number = new Date().getHours()
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// 检查时间限制:早上9点以前和晚上9点以后不通知
|
||||||
|
if (currentHour < 9 || currentHour >= 21) {
|
||||||
|
console.log(`当前时间${currentHour}点,不在通知时间范围内(9:00-21:00),跳过喝水提醒`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查喝水目标是否已达成
|
||||||
|
if (todayStats.completionRate >= 100) {
|
||||||
|
console.log('喝水目标已达成,无需发送提醒');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否在过去2小时内已经发送过喝水提醒,避免重复打扰
|
||||||
|
const lastNotificationKey = '@last_water_notification';
|
||||||
|
const AsyncStorage = (await import('@react-native-async-storage/async-storage')).default;
|
||||||
|
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 false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算还需要喝多少水
|
||||||
|
const remainingAmount = todayStats.dailyGoal - todayStats.totalAmount;
|
||||||
|
const completionPercentage = Math.round(todayStats.completionRate);
|
||||||
|
|
||||||
|
// 根据完成度生成不同的提醒消息
|
||||||
|
let title = '💧 该喝水啦!';
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (completionPercentage < 30) {
|
||||||
|
// 完成度低于30%
|
||||||
|
const encouragingMessages = [
|
||||||
|
`${userName},今天才喝了${completionPercentage}%的水哦!还需要${Math.round(remainingAmount)}ml,记得多补水~身体会感谢您的!💙`,
|
||||||
|
`${userName},水分补充进度${completionPercentage}%,再喝${Math.round(remainingAmount)}ml就更健康啦!来一大杯清水吧~🚰`,
|
||||||
|
`${userName},喝水进度才${completionPercentage}%呢~身体需要更多水分,还差${Math.round(remainingAmount)}ml,一起加油!✨`,
|
||||||
|
];
|
||||||
|
body = encouragingMessages[Math.floor(Math.random() * encouragingMessages.length)];
|
||||||
|
} else if (completionPercentage < 60) {
|
||||||
|
// 完成度30-60%
|
||||||
|
const moderateMessages = [
|
||||||
|
`${userName},喝水进度${completionPercentage}%,还需要${Math.round(remainingAmount)}ml哦!保持这个节奏,您做得很棒!👍`,
|
||||||
|
`${userName},水分补充已完成${completionPercentage}%,再来${Math.round(remainingAmount)}ml就达标了!继续保持~💪`,
|
||||||
|
`${userName},今日饮水${completionPercentage}%完成!距离目标还有${Math.round(remainingAmount)}ml,加把劲!🌊`,
|
||||||
|
];
|
||||||
|
body = moderateMessages[Math.floor(Math.random() * moderateMessages.length)];
|
||||||
|
} else {
|
||||||
|
// 完成度60-99%
|
||||||
|
const almostDoneMessages = [
|
||||||
|
`${userName},喝水进度${completionPercentage}%,太棒了!最后${Math.round(remainingAmount)}ml就达成目标啦!🎉`,
|
||||||
|
`${userName},已经完成${completionPercentage}%了!还有${Math.round(remainingAmount)}ml就成功了,您很快就能达成今天的目标!🏆`,
|
||||||
|
`${userName},水分补充进度${completionPercentage}%,就差最后一点点!再喝${Math.round(remainingAmount)}ml就胜利了!🥳`,
|
||||||
|
];
|
||||||
|
body = almostDoneMessages[Math.floor(Math.random() * almostDoneMessages.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送通知
|
||||||
|
const notificationId = await notificationService.sendImmediateNotification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
type: 'water_reminder',
|
||||||
|
completionRate: completionPercentage,
|
||||||
|
remainingAmount: Math.round(remainingAmount),
|
||||||
|
dailyGoal: todayStats.dailyGoal,
|
||||||
|
currentAmount: todayStats.totalAmount,
|
||||||
|
url: '/statistics' // 跳转到统计页面查看详情
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'normal',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录通知发送时间
|
||||||
|
await AsyncStorage.setItem(lastNotificationKey, now.toString());
|
||||||
|
|
||||||
|
console.log(`喝水提醒通知已发送,ID: ${notificationId},完成度: ${completionPercentage}%`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查喝水目标并发送通知失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送立即喝水提醒
|
||||||
|
* @param userName 用户名
|
||||||
|
* @param message 自定义消息(可选)
|
||||||
|
*/
|
||||||
|
static async sendWaterReminder(userName: string, message?: string) {
|
||||||
|
const defaultMessage = `${userName},记得要多喝水哦!保持身体水分充足很重要~💧`;
|
||||||
|
|
||||||
|
return notificationService.sendImmediateNotification({
|
||||||
|
title: '💧 喝水提醒',
|
||||||
|
body: message || defaultMessage,
|
||||||
|
data: {
|
||||||
|
type: 'water_reminder',
|
||||||
|
url: '/statistics'
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'normal',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安排定期喝水提醒(每2小时一次,在9:00-21:00之间)
|
||||||
|
* @param userName 用户名
|
||||||
|
* @returns 通知ID数组
|
||||||
|
*/
|
||||||
|
static async scheduleRegularWaterReminders(userName: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const notificationIds: string[] = [];
|
||||||
|
|
||||||
|
// 检查是否已经存在定期喝水提醒
|
||||||
|
const existingNotifications = await notificationService.getAllScheduledNotifications();
|
||||||
|
|
||||||
|
const existingWaterReminders = existingNotifications.filter(
|
||||||
|
notification =>
|
||||||
|
notification.content.data?.type === 'regular_water_reminder' &&
|
||||||
|
notification.content.data?.isRegularReminder === true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingWaterReminders.length > 0) {
|
||||||
|
console.log('定期喝水提醒已存在,跳过重复注册');
|
||||||
|
return existingWaterReminders.map(n => n.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建多个时间点的喝水提醒(9:00-21:00,每2小时一次)
|
||||||
|
const reminderHours = [9, 11, 13, 15, 17, 19, 21];
|
||||||
|
|
||||||
|
for (const hour of reminderHours) {
|
||||||
|
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
|
||||||
|
{
|
||||||
|
title: '💧 定时喝水提醒',
|
||||||
|
body: `${userName},该喝水啦!记得补充水分,保持身体健康~`,
|
||||||
|
data: {
|
||||||
|
type: 'regular_water_reminder',
|
||||||
|
isRegularReminder: true,
|
||||||
|
reminderHour: hour,
|
||||||
|
url: '/statistics'
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'normal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'DAILY' as any,
|
||||||
|
hour: hour,
|
||||||
|
minute: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
notificationIds.push(notificationId);
|
||||||
|
console.log(`已安排${hour}:00的定期喝水提醒,通知ID: ${notificationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`定期喝水提醒设置完成,共${notificationIds.length}个通知`);
|
||||||
|
return notificationIds;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置定期喝水提醒失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消所有喝水提醒
|
||||||
|
*/
|
||||||
|
static async cancelAllWaterReminders(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const notifications = await notificationService.getAllScheduledNotifications();
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
if (notification.content.data?.type === 'water_reminder' ||
|
||||||
|
notification.content.data?.type === 'regular_water_reminder') {
|
||||||
|
await notificationService.cancelNotification(notification.identifier);
|
||||||
|
console.log('已取消喝水提醒:', notification.identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消喝水提醒失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用通知辅助函数
|
* 通用通知辅助函数
|
||||||
*/
|
*/
|
||||||
@@ -805,4 +1009,38 @@ export const NotificationTemplates = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
water: {
|
||||||
|
reminder: (userName: string, completionRate: number, remainingAmount: number) => ({
|
||||||
|
title: '💧 该喝水啦!',
|
||||||
|
body: `${userName},今日喝水进度${completionRate}%,还需要${Math.round(remainingAmount)}ml,记得补充水分~`,
|
||||||
|
data: {
|
||||||
|
type: 'water_reminder',
|
||||||
|
completionRate,
|
||||||
|
remainingAmount: Math.round(remainingAmount),
|
||||||
|
url: '/statistics'
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'normal' as const,
|
||||||
|
}),
|
||||||
|
regular: (userName: string) => ({
|
||||||
|
title: '💧 定时喝水提醒',
|
||||||
|
body: `${userName},该喝水啦!记得补充水分,保持身体健康~`,
|
||||||
|
data: {
|
||||||
|
type: 'regular_water_reminder',
|
||||||
|
url: '/statistics'
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'normal' as const,
|
||||||
|
}),
|
||||||
|
achievement: (userName: string) => ({
|
||||||
|
title: '🎉 喝水目标达成!',
|
||||||
|
body: `${userName},恭喜您完成了今天的喝水目标!继续保持健康的饮水习惯~`,
|
||||||
|
data: {
|
||||||
|
type: 'water_achievement',
|
||||||
|
url: '/statistics'
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high' as const,
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user