From 70e3152158fd4bbe981e08c7d1b2bfdce6665bd4 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 2 Sep 2025 18:56:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=96=9D=E6=B0=B4?= =?UTF-8?q?=E6=8F=90=E9=86=92=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AE=9A=E6=9C=9F=E6=8F=90=E9=86=92=E5=92=8C=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/statistics.tsx | 294 +++++++++++++++++++++++++++-------- package-lock.json | 14 ++ package.json | 2 + services/backgroundTasks.ts | 73 +++++++++ services/notifications.ts | 9 ++ utils/notificationHelpers.ts | 238 ++++++++++++++++++++++++++++ 6 files changed, 569 insertions(+), 61 deletions(-) diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 4ae7e39..45478b5 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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(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 ( diff --git a/package-lock.json b/package-lock.json index 6d18443..c6ba85d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", "@sentry/react-native": "^6.20.0", + "@types/lodash": "^4.17.20", "cos-js-sdk-v5": "^1.6.0", "dayjs": "^1.11.13", "expo": "~53.0.20", @@ -39,6 +40,7 @@ "expo-system-ui": "~5.0.10", "expo-task-manager": "^13.1.6", "expo-web-browser": "~14.2.0", + "lodash": "^4.17.21", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", @@ -3853,6 +3855,12 @@ "dev": true, "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": { "version": "24.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", @@ -9575,6 +9583,12 @@ "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/package.json b/package.json index 9dedbbc..5c945da 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", "@sentry/react-native": "^6.20.0", + "@types/lodash": "^4.17.20", "cos-js-sdk-v5": "^1.6.0", "dayjs": "^1.11.13", "expo": "~53.0.20", @@ -43,6 +44,7 @@ "expo-system-ui": "~5.0.10", "expo-task-manager": "^13.1.6", "expo-web-browser": "~14.2.0", + "lodash": "^4.17.21", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", diff --git a/services/backgroundTasks.ts b/services/backgroundTasks.ts index e4e80a3..9e406f4 100644 --- a/services/backgroundTasks.ts +++ b/services/backgroundTasks.ts @@ -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 => ({ id: 'cache-cleanup-task', @@ -142,6 +214,7 @@ export const registerDefaultTasks = async (): Promise => { createDataSyncTask(), createHealthDataUpdateTask(), createNotificationCheckTask(), + createWaterReminderTask(), createCacheCleanupTask(), createUserAnalyticsTask(), ]; diff --git a/services/notifications.ts b/services/notifications.ts index a1715d9..fca9d82 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -170,6 +170,13 @@ export class NotificationService { if (data?.url) { 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', DINNER_REMINDER: 'dinner_reminder', MOOD_REMINDER: 'mood_reminder', + WATER_REMINDER: 'water_reminder', + REGULAR_WATER_REMINDER: 'regular_water_reminder', } as const; // 便捷方法 diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index 5d53a21..c37cabb 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -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 { + 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 { + 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 { + 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, + }), + }, };