feat: 新增喝水提醒功能,支持定期提醒和目标检查
This commit is contained in:
@@ -18,6 +18,8 @@ import { notificationService } from '@/services/notifications';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice';
|
||||
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health';
|
||||
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 { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { debounce } from 'lodash';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
@@ -77,19 +80,21 @@ export default function ExploreScreen() {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
|
||||
// 获取当前选中日期
|
||||
const getCurrentSelectedDate = () => {
|
||||
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
const days = getMonthDaysZh();
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
};
|
||||
|
||||
|
||||
// 获取当前选中日期
|
||||
const currentSelectedDate = getCurrentSelectedDate();
|
||||
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [selectedIndex]);
|
||||
|
||||
const currentSelectedDateString = useMemo(() => {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
|
||||
// 获取今日喝水统计数据
|
||||
const todayWaterStats = useAppSelector(selectTodayStats);
|
||||
|
||||
// 解构健康数据(支持mock数据)
|
||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||
@@ -151,8 +156,36 @@ export default function ExploreScreen() {
|
||||
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
||||
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()}`;
|
||||
|
||||
// 检查数据是否需要刷新(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 获取当前日期的心情记录
|
||||
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;
|
||||
|
||||
try {
|
||||
setIsMoodLoading(true);
|
||||
// 确定要查询的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = currentSelectedDate;
|
||||
}
|
||||
|
||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = getCurrentSelectedDate();
|
||||
}
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
|
||||
// 检查是否正在加载或不需要刷新
|
||||
if (loadingRef.current.mood) {
|
||||
console.log('心情数据正在加载中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceRefresh && !shouldRefreshData(requestKey, 'mood')) {
|
||||
console.log('心情数据缓存未过期,跳过请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingRef.current.mood = true;
|
||||
setIsMoodLoading(true);
|
||||
|
||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||
await dispatch(fetchDailyMoodCheckins(dateString));
|
||||
|
||||
// 更新缓存时间戳
|
||||
updateDataTimestamp(requestKey, 'mood');
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载心情数据失败:', error);
|
||||
} finally {
|
||||
loadingRef.current.mood = 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 {
|
||||
loadingRef.current.health = true;
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
@@ -196,15 +270,6 @@ export default function ExploreScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = getCurrentSelectedDate();
|
||||
}
|
||||
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||
@@ -223,8 +288,10 @@ export default function ExploreScreen() {
|
||||
|
||||
// 更新HRV数据时间
|
||||
setHrvUpdateTime(new Date());
|
||||
|
||||
setAnimToken((t) => t + 1);
|
||||
|
||||
// 更新缓存时间戳
|
||||
updateDataTimestamp(requestKey, 'health');
|
||||
} else {
|
||||
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||||
}
|
||||
@@ -232,55 +299,108 @@ export default function ExploreScreen() {
|
||||
|
||||
} catch (error) {
|
||||
console.error('HealthKit流程出现异常:', error);
|
||||
} finally {
|
||||
loadingRef.current.health = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载营养数据
|
||||
const loadNutritionData = async (targetDate?: Date) => {
|
||||
try {
|
||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = getCurrentSelectedDate();
|
||||
}
|
||||
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 确定要查询的日期
|
||||
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);
|
||||
await dispatch(fetchDailyNutritionData(derivedDate));
|
||||
console.log('营养数据加载完成');
|
||||
|
||||
// 更新缓存时间戳
|
||||
updateDataTimestamp(requestKey, 'nutrition');
|
||||
|
||||
} catch (error) {
|
||||
console.error('营养数据加载失败:', error);
|
||||
} finally {
|
||||
loadingRef.current.nutrition = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载所有数据的统一方法
|
||||
const loadAllData = React.useCallback((targetDate?: Date) => {
|
||||
const dateToUse = targetDate || getCurrentSelectedDate();
|
||||
// 实际执行数据加载的方法
|
||||
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||
const dateToUse = targetDate || currentSelectedDate;
|
||||
if (dateToUse) {
|
||||
loadHealthData(dateToUse);
|
||||
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||
loadHealthData(dateToUse, forceRefresh);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(dateToUse);
|
||||
loadMoodData(dateToUse);
|
||||
loadNutritionData(dateToUse, forceRefresh);
|
||||
loadMoodData(dateToUse, forceRefresh);
|
||||
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||
if (isToday) {
|
||||
dispatch(fetchTodayWaterStats());
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
}, [isLoggedIn, dispatch]);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 每次聚焦时都拉取当前选中日期的最新数据
|
||||
loadAllData();
|
||||
}, [loadAllData])
|
||||
// 使用 lodash debounce 防抖的加载所有数据方法
|
||||
const debouncedLoadAllData = React.useMemo(
|
||||
() => debounce(executeLoadAllData, 300), // 300ms 防抖延迟
|
||||
[executeLoadAllData]
|
||||
);
|
||||
|
||||
// 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(() => {
|
||||
let appStateChangeTimeout: number;
|
||||
|
||||
const handleAppStateChange = (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
// 应用从后台返回前台,刷新当前选中日期的数据
|
||||
console.log('应用从后台返回前台,刷新统计数据...');
|
||||
loadAllData();
|
||||
// 延迟执行,避免与 useFocusEffect 重复触发
|
||||
appStateChangeTimeout = setTimeout(() => {
|
||||
console.log('应用从后台返回前台,强制刷新统计数据...');
|
||||
// 从后台返回时强制刷新数据
|
||||
loadAllData(currentSelectedDate, true);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,19 +408,28 @@ export default function ExploreScreen() {
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
if (appStateChangeTimeout) {
|
||||
clearTimeout(appStateChangeTimeout);
|
||||
}
|
||||
};
|
||||
}, [loadAllData]);
|
||||
}, [loadAllData, currentSelectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
// 注册任务
|
||||
// 注册后台任务 - 只处理健康数据和压力检查
|
||||
registerTask({
|
||||
id: 'health-data-task',
|
||||
name: 'health-data-task',
|
||||
handler: async () => {
|
||||
try {
|
||||
await loadHealthData();
|
||||
console.log('后台任务:更新健康数据和检查压力水平...');
|
||||
// 后台任务只更新健康数据,强制刷新以获取最新数据
|
||||
await loadHealthData(undefined, true);
|
||||
|
||||
checkStressLevelAndNotify()
|
||||
// 执行压力检查
|
||||
await checkStressLevelAndNotify();
|
||||
|
||||
// 执行喝水目标检查
|
||||
await checkWaterGoalAndNotify();
|
||||
} catch (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);
|
||||
loadAllData(date);
|
||||
};
|
||||
console.log('日期切换,加载数据...', date);
|
||||
// 日期切换时不强制刷新,依赖缓存机制减少不必要的请求
|
||||
// loadAllData 内部已经实现了防抖,无需额外防抖处理
|
||||
loadAllData(date, false);
|
||||
}, [loadAllData]);
|
||||
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user