feat(hrv): 添加心率变异性监控和压力评估功能

- 新增 HRV 监听服务,实时监控心率变异性数据
- 实现 HRV 到压力指数的转换算法和压力等级评估
- 添加智能通知服务,在压力偏高时推送健康建议
- 优化日志系统,修复日志丢失问题并增强刷新机制
- 改进个人页面下拉刷新,支持并行数据加载
- 优化勋章数据缓存策略,减少不必要的网络请求
- 重构应用初始化流程,优化权限服务和健康监听服务的启动顺序
- 移除冗余日志输出,提升应用性能
This commit is contained in:
richarjiang
2025-11-18 14:08:20 +08:00
parent 3f21f521ea
commit 21e57634e0
15 changed files with 791 additions and 288 deletions

View File

@@ -22,7 +22,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Linking, Modal, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; import { Linking, Modal, RefreshControl, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLanguage, changeAppLanguage, getNormalizedLanguage } from '@/i18n'; import { AppLanguage, changeAppLanguage, getNormalizedLanguage } from '@/i18n';
@@ -62,6 +62,7 @@ export default function PersonalScreen() {
const isLgAvaliable = isLiquidGlassAvailable(); const isLgAvaliable = isLiquidGlassAvailable();
const [languageModalVisible, setLanguageModalVisible] = useState(false); const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false); const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const languageOptions = useMemo<LanguageOption[]>(() => ([ const languageOptions = useMemo<LanguageOption[]>(() => ([
{ {
@@ -165,18 +166,38 @@ export default function PersonalScreen() {
console.log('badgePreview', badgePreview); console.log('badgePreview', badgePreview);
// 页面聚焦时获取最新用户信息 // 首次加载时获取用户信息和数据
useFocusEffect( useEffect(() => {
React.useCallback(() => {
dispatch(fetchMyProfile()); dispatch(fetchMyProfile());
dispatch(fetchActivityHistory()); dispatch(fetchActivityHistory());
dispatch(fetchAvailableBadges()); dispatch(fetchAvailableBadges());
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面 }, [dispatch]);
// 加载开发者模式状态
loadDeveloperModeState(); // 页面聚焦时智能刷新(依赖 Redux 的缓存策略)
useFocusEffect(
useCallback(() => {
// 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求
dispatch(fetchAvailableBadges());
}, [dispatch]) }, [dispatch])
); );
// 手动刷新处理
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
// 并行刷新所有数据
await Promise.all([
dispatch(fetchMyProfile()).unwrap(),
dispatch(fetchActivityHistory()).unwrap(),
dispatch(fetchAvailableBadges()).unwrap(),
]);
} catch (error) {
log.warn('刷新数据失败', error);
} finally {
setRefreshing(false);
}
}, [dispatch]);
// 移除 loadNotificationPreference 函数,因为已移到通知设置页面 // 移除 loadNotificationPreference 函数,因为已移到通知设置页面
// 加载开发者模式状态 // 加载开发者模式状态
@@ -695,6 +716,15 @@ export default function PersonalScreen() {
paddingHorizontal: 16, paddingHorizontal: 16,
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#9370DB"
colors={['#9370DB']}
progressViewOffset={insets.top}
/>
}
> >
<UserHeader /> <UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />} {userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}

View File

@@ -10,6 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions'; import { useQuickActions } from '@/hooks/useQuickActions';
import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { notificationService } from '@/services/notifications'; import { notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions'; import { setupQuickActions } from '@/services/quickActions';
import { sleepMonitorService } from '@/services/sleepMonitor'; import { sleepMonitorService } from '@/services/sleepMonitor';
@@ -21,7 +22,7 @@ import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fast
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice'; import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice'; import { createWaterRecordAction } from '@/store/waterSlice';
import { loadActiveFastingSchedule } from '@/utils/fasting'; import { loadActiveFastingSchedule } from '@/utils/fasting';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
@@ -130,10 +131,10 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
initializeBasicServices(); initializeBasicServices();
}, [dispatch]); }, [dispatch]);
// ==================== 权限相关服务初始化(仅在 onboarding 完成后执行)==================== // ==================== 权限相关服务初始化(应用启动时执行)====================
React.useEffect(() => { React.useEffect(() => {
// 如果还没完成 onboarding已经初始化过权限,则跳过 // 如果已经初始化过,则跳过(确保只初始化一次)
if (!onboardingCompleted || permissionInitializedRef.current) { if (permissionInitializedRef.current) {
return; return;
} }
@@ -141,85 +142,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
const initializePermissionServices = async () => {
try {
logger.info('🔐 开始初始化需要权限的服务onboarding 已完成)...');
// 1. 初始化通知服务(包含权限请求)
await notificationService.initialize();
logger.info('✅ 通知服务初始化完成');
// 2. 延迟请求 HealthKit 权限(避免立即弹窗打断用户)
await delay(2000);
try {
const granted = await ensureHealthPermissions();
if (granted) {
logger.info('✅ HealthKit 权限请求完成');
} else {
logger.warn('⚠️ 用户未授予 HealthKit 权限,相关功能将受限');
}
} catch (error) {
logger.warn('⚠️ HealthKit 权限请求失败,可能在模拟器上运行:', error);
}
// 3. 异步同步 Widget 数据
syncWidgetDataInBackground();
logger.info('🎉 权限相关服务初始化完成');
} catch (error) {
logger.error('❌ 权限相关服务初始化失败:', error);
throw error;
}
};
// ==================== 后台服务初始化(延迟执行)====================
const initializeBackgroundServices = () => {
const { InteractionManager } = require('react-native');
InteractionManager.runAfterInteractions(() => {
setTimeout(async () => {
try {
logger.info('📅 开始初始化后台服务...');
// 1. 批量注册所有通知提醒
await registerAllNotifications();
// 2. 初始化后台任务管理器
await initializeBackgroundTaskManager();
// 3. 初始化健康监听服务
await initializeHealthMonitoring();
logger.info('🎉 后台服务初始化完成');
} catch (error) {
logger.error('❌ 后台服务初始化失败:', error);
}
}, 3000);
});
};
// ==================== 空闲服务初始化====================
const initializeIdleServices = () => {
setTimeout(async () => {
try {
logger.info('🔄 开始初始化空闲服务...');
// 1. 后台任务详细状态检查
await checkBackgroundTaskStatus();
// 2. 开发环境调试工具
if (__DEV__ && BackgroundTaskDebugger) {
BackgroundTaskDebugger.getInstance().initialize();
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
}
logger.info('🎉 空闲服务初始化完成');
} catch (error) {
logger.error('❌ 空闲服务初始化失败:', error);
}
}, 8000);
};
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
// 异步同步 Widget 数据(不阻塞主流程) // 异步同步 Widget 数据(不阻塞主流程)
@@ -320,9 +242,10 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
try { try {
logger.info('💪 初始化健康监听服务...'); logger.info('💪 初始化健康监听服务...');
const [workoutResult, sleepResult] = await Promise.allSettled([ const [workoutResult, sleepResult, hrvResult] = await Promise.allSettled([
workoutMonitorService.initialize(), workoutMonitorService.initialize(),
sleepMonitorService.initialize(), sleepMonitorService.initialize(),
hrvMonitorService.initialize(),
]); ]);
const workoutReady = workoutResult.status === 'fulfilled'; const workoutReady = workoutResult.status === 'fulfilled';
@@ -339,13 +262,20 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
logger.error('❌ 睡眠监听服务初始化失败:', sleepResult.reason); logger.error('❌ 睡眠监听服务初始化失败:', sleepResult.reason);
} }
if (workoutReady && sleepReady) { const hrvReady = hrvResult.status === 'fulfilled';
if (hrvReady) {
logger.info('✅ HRV 监听服务初始化成功');
} else {
logger.error('❌ HRV 监听服务初始化失败:', hrvResult.reason);
}
if (workoutReady && sleepReady && hrvReady) {
logger.info('🎉 健康监听服务初始化完成'); logger.info('🎉 健康监听服务初始化完成');
} else { } else {
logger.warn('⚠️ 健康监听服务部分未能初始化成功,请检查上述错误日志'); logger.warn('⚠️ 健康监听服务部分未能初始化成功,请检查上述错误日志');
} }
return workoutReady && sleepReady; return workoutReady && sleepReady && hrvReady;
} catch (error) { } catch (error) {
logger.error('❌ 健康监听服务初始化失败:', error); logger.error('❌ 健康监听服务初始化失败:', error);
return false; return false;
@@ -381,6 +311,74 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
} }
}; };
// 权限服务初始化
const initializePermissionServices = async () => {
try {
logger.info('🔐 开始初始化需要权限的服务...');
// 1. 初始化通知服务(包含权限请求)
await notificationService.initialize();
logger.info('✅ 通知服务初始化完成');
// 2. 异步同步 Widget 数据(不阻塞主流程)
syncWidgetDataInBackground();
logger.info('🎉 权限相关服务初始化完成');
logger.info('💡 HealthKit 权限将在用户首次访问健康数据时请求');
} catch (error) {
logger.error('❌ 权限相关服务初始化失败:', error);
throw error;
}
};
// ==================== 后台服务初始化(延迟执行)====================
const initializeBackgroundServices = () => {
const { InteractionManager } = require('react-native');
InteractionManager.runAfterInteractions(() => {
setTimeout(async () => {
try {
logger.info('📅 开始初始化后台服务...');
// 1. 批量注册所有通知提醒
await registerAllNotifications();
// 2. 初始化后台任务管理器
await initializeBackgroundTaskManager();
// 3. 初始化健康监听服务
await initializeHealthMonitoring();
logger.info('🎉 后台服务初始化完成');
} catch (error) {
logger.error('❌ 后台服务初始化失败:', error);
}
}, 3000);
});
};
// ==================== 空闲服务初始化====================
const initializeIdleServices = () => {
setTimeout(async () => {
try {
logger.info('🔄 开始初始化空闲服务...');
// 1. 后台任务详细状态检查
await checkBackgroundTaskStatus();
// 2. 开发环境调试工具
if (__DEV__ && BackgroundTaskDebugger) {
BackgroundTaskDebugger.getInstance().initialize();
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
}
logger.info('🎉 空闲服务初始化完成');
} catch (error) {
logger.error('❌ 空闲服务初始化失败:', error);
}
}, 8000);
};
const runInitializationSequence = async () => { const runInitializationSequence = async () => {
try { try {
await initializePermissionServices(); await initializePermissionServices();
@@ -397,7 +395,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
runInitializationSequence(); runInitializationSequence();
}, [onboardingCompleted, profile.name]); }, []); // 每次应用启动都执行,不依赖其他状态
React.useEffect(() => { React.useEffect(() => {

View File

@@ -1,4 +1,5 @@
import { fetchHRVWithStatus } from '@/utils/health'; import { fetchHRVWithStatus } from '@/utils/health';
import { convertHrvToStressIndex } from '@/utils/stress';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -13,20 +14,6 @@ interface StressMeterProps {
export function StressMeter({ curDate }: StressMeterProps) { export function StressMeter({ curDate }: StressMeterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// 将HRV值转换为压力指数0-100
// HRV值范围30-110ms映射到压力指数100-0
// HRV值越高压力越小HRV值越低压力越大
const convertHrvToStressIndex = (hrv: number | null): number | null => {
if (hrv === null || hrv === 0) return null;
// HRV 范围: 30-110ms对应压力指数: 100-0
// 线性映射: stressIndex = 100 - ((hrv - 30) / (110 - 30)) * 100
const normalizedHrv = Math.max(30, Math.min(130, hrv));
const stressIndex = 100 - ((normalizedHrv - 30) / (130 - 30)) * 100;
return Math.round(stressIndex);
};
const [hrvValue, setHrvValue] = useState(0) const [hrvValue, setHrvValue] = useState(0)

View File

@@ -2,7 +2,6 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi'; import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice'; import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health'; import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { logger } from '@/utils/logger';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences'; import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
import { refreshWidget, syncWaterDataToWidget } from '@/utils/widgetDataSync'; import { refreshWidget, syncWaterDataToWidget } from '@/utils/widgetDataSync';
@@ -532,21 +531,15 @@ export const useWaterDataByDate = (targetDate?: string) => {
setError(null); setError(null);
try { try {
logger.debug('🚰 开始获取饮水记录,日期:', date);
const options = createDateRange(date); const options = createDateRange(date);
logger.debug('🚰 查询选项:', options);
const healthKitRecords = await getWaterIntakeFromHealthKit(options); const healthKitRecords = await getWaterIntakeFromHealthKit(options);
logger.debug('🚰 从HealthKit获取到的原始数据:', healthKitRecords);
// 转换数据格式并按时间排序 // 转换数据格式并按时间排序
const convertedRecords = healthKitRecords const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord) .map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()); .sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
logger.debug('🚰 转换后的记录:', convertedRecords);
logger.debug('🚰 记录数量:', convertedRecords.length);
setWaterRecords(convertedRecords); setWaterRecords(convertedRecords);
return convertedRecords; return convertedRecords;
} catch (error) { } catch (error) {
@@ -689,11 +682,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
// 计算指定日期的统计数据 // 计算指定日期的统计数据
const waterStats = useMemo(() => { const waterStats = useMemo(() => {
logger.debug('🚰 计算waterStats - waterRecords:', waterRecords);
logger.debug('🚰 计算waterStats - dailyWaterGoal:', dailyWaterGoal);
if (!waterRecords || waterRecords.length === 0) { if (!waterRecords || waterRecords.length === 0) {
logger.debug('🚰 没有饮水记录,返回默认值');
return { return {
totalAmount: 0, totalAmount: 0,
completionRate: 0, completionRate: 0,
@@ -704,10 +693,6 @@ export const useWaterDataByDate = (targetDate?: string) => {
const totalAmount = waterRecords.reduce((total, record) => total + record.amount, 0); const totalAmount = waterRecords.reduce((total, record) => total + record.amount, 0);
const completionRate = dailyWaterGoal > 0 ? Math.min((totalAmount / dailyWaterGoal) * 100, 100) : 0; const completionRate = dailyWaterGoal > 0 ? Math.min((totalAmount / dailyWaterGoal) * 100, 100) : 0;
logger.debug('🚰 计算结果 - totalAmount:', totalAmount);
logger.debug('🚰 计算结果 - completionRate:', completionRate);
logger.debug('🚰 计算结果 - recordCount:', waterRecords.length);
return { return {
totalAmount, totalAmount,
completionRate, completionRate,

View File

@@ -97,4 +97,11 @@ RCT_EXTERN_METHOD(startSleepObserver:(RCTPromiseResolveBlock)resolver
RCT_EXTERN_METHOD(stopSleepObserver:(RCTPromiseResolveBlock)resolver RCT_EXTERN_METHOD(stopSleepObserver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter) rejecter:(RCTPromiseRejectBlock)rejecter)
// HRV Observer Methods
RCT_EXTERN_METHOD(startHRVObserver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(stopHRVObserver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end @end

View File

@@ -1704,6 +1704,7 @@ class HealthKitManager: RCTEventEmitter {
private var workoutObserverQuery: HKObserverQuery? private var workoutObserverQuery: HKObserverQuery?
private var sleepObserverQuery: HKObserverQuery? private var sleepObserverQuery: HKObserverQuery?
private var hrvObserverQuery: HKObserverQuery?
@objc @objc
func startWorkoutObserver( func startWorkoutObserver(
@@ -1861,10 +1862,81 @@ private func sendSleepUpdateEvent() {
]) ])
} }
// MARK: - HRV Observer Methods
@objc
func startHRVObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
if let existingQuery = hrvObserverQuery {
healthStore.stop(existingQuery)
hrvObserverQuery = nil
}
let hrvType = ReadTypes.heartRateVariability
hrvObserverQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] (_, completionHandler, error) in
if let error = error {
print("HRV observer error: \(error.localizedDescription)")
completionHandler()
return
}
print("HRV data updated, sending event to React Native")
self?.sendHRVUpdateEvent()
completionHandler()
}
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
if let error = error {
print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
} else if success {
print("Background delivery for HRV enabled successfully")
}
}
if let query = hrvObserverQuery {
healthStore.execute(query)
}
resolver(["success": true])
}
@objc
func stopHRVObserver(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
if let query = hrvObserverQuery {
healthStore.stop(query)
hrvObserverQuery = nil
healthStore.disableBackgroundDelivery(for: ReadTypes.heartRateVariability) { success, error in
if let error = error {
print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
}
}
}
resolver(["success": true])
}
private func sendHRVUpdateEvent() {
sendEvent(withName: "hrvUpdate", body: [
"timestamp": Date().timeIntervalSince1970,
"type": "hrv_data_updated"
])
}
// MARK: - RCTEventEmitter Overrides // MARK: - RCTEventEmitter Overrides
override func supportedEvents() -> [String]! { override func supportedEvents() -> [String]! {
return ["workoutUpdate", "sleepUpdate"] return ["workoutUpdate", "sleepUpdate", "hrvUpdate"]
} }
override static func requiresMainQueueSetup() -> Bool { override static func requiresMainQueueSetup() -> Bool {

View File

@@ -2679,127 +2679,127 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
EXApplication: a9d1c46d473d36f61302a9a81db2379441f3f094 EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
EXConstants: e6e50cdfcb4524f40121d1fdcff24e97b7dcd2fd EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
EXImageLoader: e501c001bc40b8326605e82e6e80363c80fe06b5 EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
EXNotifications: 7aab54f0e5f3023122bc95699eaff7c52bacb559 EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
Expo: 795e9f87aca407bf92895d54ae5f7777fc1f3fbc Expo: 27ae59be9be4feab2b1c1ae06550752c524ca558
ExpoAppleAuthentication: 414e4316f8e25a2afbc3943cf725579c910f24b8 ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
ExpoAsset: 3c3b7dd9b1318846a02ef05ce420e63d542aeb9f ExpoAsset: 9ba6fbd677fb8e241a3899ac00fa735bc911eadf
ExpoBackgroundTask: e048da30cd2d669c5ba20d5d704bee8dd6da320c ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
ExpoBlur: b5b7a26572b3c33a11f0b2aa2f95c17c4c393b76 ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoCamera: 6d0c5bc68bc8de669f1ecd4242284de0827c4431 ExpoCamera: e75f6807a2c047f3338bbadd101af4c71a1d13a5
ExpoFileSystem: 56f081328f5b48a6dcc8302eee51d4f2d9d0049b ExpoFileSystem: b79eadbda7b7f285f378f95f959cc9313a1c9c61
ExpoFont: b881d43057dceb7b31ff767b24f612609e80f60f ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
ExpoGlassEffect: d35ec1a8e9d84492f23755d3020a6a81a20bd585 ExpoGlassEffect: 779c46bd04ea47ba4726efb73267b5bcc6abd664
ExpoHaptics: b48d913e7e5f23816c6f130e525c9a6501b160b5 ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoHead: aa5f5a8afaa9bd4969bfdd6d5b76681e2490fe5b ExpoHead: e317214fa14edeaf17748d39ec9e550a3d1194fb
ExpoImage: 6eb842cd07817402640545c41884dd7f5fbfbca5 ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
ExpoImagePicker: bd0a5c81d7734548f6908a480609257e85d19ea8 ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
ExpoKeepAwake: 3f5e3ac53627849174f3603271df8e08f174ed4a ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
ExpoLinearGradient: f9e7182e5253d53b2de4134b69d70bbfc2d50588 ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
ExpoLinking: f5c171877e118e792cb9a77e5ade0b080d899b14 ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
ExpoLocalization: 6c6f0f89ad2822001ab0bc2eb6d4d980c77f080c ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoMediaLibrary: 648cee3f5dcba13410ec9cc8ac9a426e89a61a31 ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
ExpoModulesCore: ed46799cfdf75784ee3ca37dac982c5298683e83 ExpoModulesCore: 5f20603cf25698682d7c43c05fbba8c748b189d2
ExpoQuickActions: 62b9db8a20618be1cc19efa3b562ac963c803d58 ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
ExpoSplashScreen: 9d2ff8fa08f2c00336a83f93bebffed3a8312519 ExpoSplashScreen: cbb839de72110dea1851dd3e85080b7923af2540
ExpoSQLite: f9d1202877e12bfa78a58309a3977ee4ea0b1314 ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
ExpoSymbols: ef7b8ac77ac2d496b1bc3f0f7daf5e19c3a9933a ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
ExpoSystemUI: 9441d46a8efbf9224d1b2e6b18042452ffd0ed79 ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
ExpoUI: 821b058da921ea4aa6172b36d080991ea6fb2fae ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
ExpoWebBrowser: 51218ce6ef35ea769e33409aac87fea3df4b919d ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
EXTaskManager: 53f87ed11659341c3f3f02c0041498ef293f5684 EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 97a11537edc72d0763edab0c83e8cc8a0b9d8484 lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
PurchasesHybridCommon: b7b4eafb55fbaaac19b4c36d4082657a3f0d8490 PurchasesHybridCommon: b7b4eafb55fbaaac19b4c36d4082657a3f0d8490
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
React: 914f8695f9bf38e6418228c2ffb70021e559f92f React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: add9b4ba236fe95ec600604d0fc72f395433dd59 react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513
react-native-view-shot: 26174e54ec6b4b7c5d70b86964b747919759adc1 react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
react-native-voice: f5e8eec2278451d0017eb6a30a6ccc726aca34e0 react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
react-native-webview: a4f0775a31b73cf13cfc3d2d2b119aa94ec76e49 react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
React-timing: 03c7217455d2bff459b27a3811be25796b600f47 React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a React-utils: 6e2035b53d087927768649a11a26c4e092448e34
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RevenueCat: a51003d4cb33820cc504cf177c627832b462a98e RevenueCat: a51003d4cb33820cc504cf177c627832b462a98e
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNCMaskedView: 3c9d7586e2b9bbab573591dcb823918bc4668005 RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
RNCPicker: f97c908b7774248c1093ec3831ca70d338627bf7 RNCPicker: a7170edbcbf8288de8edb2502e08e7fc757fa755
RNDateTimePicker: 6fdd63f5d1e0f21faf4cc8674957c52958a7efae RNDateTimePicker: be0e44bcb9ed0607c7c5f47dbedd88cf091f6791
RNDeviceInfo: 8b6fa8379062949dd79a009cf3d6b02a9c03ca59 RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNGestureHandler: 6a488ce85c88e82d8610db1108daf04e9b2d5162 RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
RNPurchases: e7d57c35ec94625f455981307c1487adde5e3188 RNPurchases: 2569675abdc1dbc739f2eec0fa564a112cf860de
RNReanimated: 1c03486192caeabe2795787e4bb046116383be7a RNReanimated: 3895a29fdf77bbe2a627e1ed599a5e5d1df76c29
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSentry: bf366a415176cb6971a5adac37bbe66dfea272f3 RNSentry: 41979b419908128847ef662cc130a400b7576fa9
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 83609071441ac7d623f1e0e63b9043f4f345e2a2 RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c

173
services/hrvMonitor.ts Normal file
View File

@@ -0,0 +1,173 @@
import { logger } from '@/utils/logger';
import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health';
import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress';
import { sendHRVStressNotification } from './hrvNotificationService';
const { HealthKitManager } = NativeModules;
const HRV_EVENT_NAME = 'hrvUpdate';
const HRV_NOTIFICATION_STATE_KEY = '@hrv_stress_notification_state';
const MIN_NOTIFICATION_INTERVAL_HOURS = 4;
interface HrvEventData {
timestamp: number;
type: 'hrv_data_updated' | string;
}
interface NotificationState {
lastSentAt: number;
lastStressLevel: StressLevel;
}
class HRVMonitorService {
private eventEmitter: NativeEventEmitter | null = null;
private eventSubscription: any = null;
private isInitialized = false;
private lastProcessedTime = 0;
private debounceDelay = 3000;
async initialize(): Promise<boolean> {
if (this.isInitialized) {
logger.info('[HRVMonitor] Already initialized');
return true;
}
try {
this.eventEmitter = new NativeEventEmitter(HealthKitManager);
this.eventSubscription = this.eventEmitter.addListener(
HRV_EVENT_NAME,
this.handleHRVUpdate.bind(this)
);
if (HealthKitManager.startHRVObserver) {
await HealthKitManager.startHRVObserver();
} else {
logger.warn('[HRVMonitor] Native startHRVObserver not available');
return false;
}
this.isInitialized = true;
logger.info('[HRVMonitor] Initialized successfully');
return true;
} catch (error) {
logger.error('[HRVMonitor] Initialization failed:', error);
return false;
}
}
async stop(): Promise<void> {
if (!this.isInitialized) return;
try {
if (this.eventSubscription) {
this.eventSubscription.remove();
this.eventSubscription = null;
}
if (HealthKitManager.stopHRVObserver) {
await HealthKitManager.stopHRVObserver();
}
this.isInitialized = false;
logger.info('[HRVMonitor] Stopped successfully');
} catch (error) {
logger.error('[HRVMonitor] Failed to stop:', error);
}
}
private async handleHRVUpdate(event: HrvEventData): Promise<void> {
logger.info('[HRVMonitor] HRV data updated:', event);
const now = Date.now();
if (now - this.lastProcessedTime < this.debounceDelay) {
logger.info('[HRVMonitor] Debouncing HRV update');
return;
}
this.lastProcessedTime = now;
try {
const canNotify = await this.canSendNotification(now);
if (!canNotify) {
logger.info('[HRVMonitor] Notification recently sent, skipping');
return;
}
const { hrvData, message } = await fetchHRVWithStatus(new Date());
if (!hrvData) {
logger.warn('[HRVMonitor] No HRV data available for analysis');
return;
}
const analysis = analyzeHRVData(hrvData);
const stressIndex = convertHrvToStressIndex(hrvData.value);
if (stressIndex == null) {
logger.warn('[HRVMonitor] Unable to derive stress index from HRV value:', hrvData.value);
return;
}
const stressInfo = getStressLevelInfo(stressIndex);
await sendHRVStressNotification({
hrvValue: Math.round(hrvData.value),
stressIndex,
stressLevel: stressInfo.level,
recordedAt: hrvData.recordedAt,
interpretation: analysis.interpretation,
recommendations: analysis.recommendations,
dataSource: analysis.dataSource,
message,
});
await this.markNotificationSent(now, stressInfo.level);
} catch (error) {
logger.error('[HRVMonitor] Failed to process HRV update:', error);
}
}
private async canSendNotification(now: number): Promise<boolean> {
try {
const stored = await AsyncStorage.getItem(HRV_NOTIFICATION_STATE_KEY);
if (!stored) {
return true;
}
const state: NotificationState = JSON.parse(stored);
const elapsed = now - state.lastSentAt;
const minIntervalMs = MIN_NOTIFICATION_INTERVAL_HOURS * 60 * 60 * 1000;
if (elapsed < minIntervalMs) {
const hoursLeft = ((minIntervalMs - elapsed) / (1000 * 60 * 60)).toFixed(1);
logger.info(`[HRVMonitor] Cooldown active, ${hoursLeft}h remaining`);
return false;
}
const lastSentDay = dayjs(state.lastSentAt).format('YYYY-MM-DD');
const today = dayjs().format('YYYY-MM-DD');
if (lastSentDay === today && state.lastStressLevel !== 'high') {
logger.info('[HRVMonitor] Already sent HRV notification today');
return false;
}
return true;
} catch (error) {
logger.warn('[HRVMonitor] Failed to read notification state:', error);
return true;
}
}
private async markNotificationSent(timestamp: number, level: StressLevel): Promise<void> {
try {
const state: NotificationState = {
lastSentAt: timestamp,
lastStressLevel: level,
};
await AsyncStorage.setItem(HRV_NOTIFICATION_STATE_KEY, JSON.stringify(state));
} catch (error) {
logger.warn('[HRVMonitor] Failed to persist notification state:', error);
}
}
}
export const hrvMonitorService = new HRVMonitorService();

View File

@@ -0,0 +1,73 @@
import { StressLevel } from '@/utils/stress';
import { notificationService, NotificationData, NotificationTypes } from './notifications';
interface HRVStressNotificationPayload {
hrvValue: number;
stressIndex: number;
stressLevel: StressLevel;
recordedAt: string;
interpretation: string;
recommendations: string[];
dataSource: string;
message?: string;
}
const LEVEL_CONFIG: Record<
StressLevel,
{
emoji: string;
title: string;
priority: NotificationData['priority'];
}
> = {
low: {
emoji: '🧘',
title: '状态放松',
priority: 'normal',
},
moderate: {
emoji: '🙂',
title: '压力适中',
priority: 'normal',
},
high: {
emoji: '⚠️',
title: '压力偏高',
priority: 'high',
},
};
export async function sendHRVStressNotification(
payload: HRVStressNotificationPayload
): Promise<void> {
const config = LEVEL_CONFIG[payload.stressLevel];
const recommendation = payload.recommendations[0];
const lines = [
`HRV ${payload.hrvValue}ms · 压力指数 ${payload.stressIndex}`,
payload.interpretation,
`数据来源:${payload.dataSource}`,
];
if (payload.message) {
lines.push(payload.message);
}
if (recommendation) {
lines.push(`建议:${recommendation}`);
}
await notificationService.sendImmediateNotification({
title: `${config.emoji} ${config.title}`,
body: lines.join('\n'),
data: {
type: NotificationTypes.HRV_STRESS_ALERT,
stressLevel: payload.stressLevel,
stressIndex: payload.stressIndex,
hrvValue: payload.hrvValue,
recordedAt: payload.recordedAt,
url: '/(tabs)/statistics',
},
sound: true,
priority: config.priority ?? 'normal',
});
}

View File

@@ -204,6 +204,10 @@ export class NotificationService {
console.log('用户点击了锻炼完成通知', data); console.log('用户点击了锻炼完成通知', data);
// 跳转到锻炼历史页面 // 跳转到锻炼历史页面
router.push('/workout/history' as any); router.push('/workout/history' as any);
} else if (data?.type === NotificationTypes.HRV_STRESS_ALERT) {
console.log('用户点击了 HRV 压力通知', data);
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
router.push(targetUrl as any);
} else if (data?.type === NotificationTypes.MEDICATION_REMINDER) { } else if (data?.type === NotificationTypes.MEDICATION_REMINDER) {
// 处理药品提醒通知 // 处理药品提醒通知
console.log('用户点击了药品提醒通知', data); console.log('用户点击了药品提醒通知', data);
@@ -551,6 +555,7 @@ export const NotificationTypes = {
FASTING_START: 'fasting_start', FASTING_START: 'fasting_start',
FASTING_END: 'fasting_end', FASTING_END: 'fasting_end',
MEDICATION_REMINDER: 'medication_reminder', MEDICATION_REMINDER: 'medication_reminder',
HRV_STRESS_ALERT: 'hrv_stress_alert',
} as const; } as const;
// 便捷方法 // 便捷方法

View File

@@ -87,8 +87,6 @@ export class PushNotificationManager {
return false; return false;
} }
logger.info('获取到设备令牌:', token);
// 检查是否需要注册令牌 // 检查是否需要注册令牌
await this.checkAndRegisterToken(token); await this.checkAndRegisterToken(token);

View File

@@ -5,10 +5,13 @@
*/ */
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
import { NativeEventEmitter, NativeModules } from 'react-native'; import { NativeEventEmitter, NativeModules } from 'react-native';
import { analyzeSleepAndSendNotification } from './sleepNotificationService'; import { analyzeSleepAndSendNotification } from './sleepNotificationService';
const { HealthKitManager } = NativeModules; const { HealthKitManager } = NativeModules;
const SLEEP_ANALYSIS_SENT_KEY = '@sleep_analysis_sent';
// 睡眠阶段类型 // 睡眠阶段类型
@@ -138,6 +141,12 @@ class SleepMonitorService {
this.lastProcessedTime = now; this.lastProcessedTime = now;
try { try {
const alreadySentToday = await this.hasSentSleepAnalysisToday();
if (alreadySentToday) {
console.log('[SleepMonitor] Sleep analysis already sent today, skipping notification');
return;
}
// 分析最近的睡眠数据 // 分析最近的睡眠数据
const analysis = await this.analyzeSleepData(); const analysis = await this.analyzeSleepData();
@@ -150,6 +159,7 @@ class SleepMonitorService {
// 发送睡眠分析通知 // 发送睡眠分析通知
await this.notifySleepAnalysis(analysis); await this.notifySleepAnalysis(analysis);
await this.markSleepAnalysisSent();
} }
} catch (error) { } catch (error) {
console.error('[SleepMonitor] Failed to analyze sleep data:', error); console.error('[SleepMonitor] Failed to analyze sleep data:', error);
@@ -507,6 +517,29 @@ class SleepMonitorService {
return recommendations; return recommendations;
} }
private getTodayKey(): string {
return dayjs().format('YYYY-MM-DD');
}
private async hasSentSleepAnalysisToday(): Promise<boolean> {
try {
const todayKey = this.getTodayKey();
const value = await AsyncStorage.getItem(SLEEP_ANALYSIS_SENT_KEY);
return value === todayKey;
} catch (error) {
logger.warn('[SleepMonitor] Failed to check sleep analysis sent flag:', error);
return false;
}
}
private async markSleepAnalysisSent(): Promise<void> {
try {
await AsyncStorage.setItem(SLEEP_ANALYSIS_SENT_KEY, this.getTodayKey());
} catch (error) {
logger.warn('[SleepMonitor] Failed to mark sleep analysis as sent:', error);
}
}
} }
// 导出单例 // 导出单例

View File

@@ -32,8 +32,25 @@ export const fetchAvailableBadges = createAsyncThunk<BadgeDto[], void, { rejectV
{ {
condition: (_, { getState }) => { condition: (_, { getState }) => {
const state = getState() as RootState; const state = getState() as RootState;
const { loading } = state.badges ?? {}; const { loading, lastFetched } = state.badges ?? {};
return !loading;
// 如果正在加载,阻止重复请求
if (loading) {
return false;
}
// 如果没有缓存数据,允许请求
if (!lastFetched) {
return true;
}
// 如果缓存时间超过 5 分钟,允许请求
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟
const now = Date.now();
const lastFetchedTime = new Date(lastFetched).getTime();
const isExpired = now - lastFetchedTime > CACHE_DURATION;
return isExpired;
}, },
} }
); );

View File

@@ -16,21 +16,23 @@ interface LogEntry {
* 3. 改进的错误处理和重试机制 * 3. 改进的错误处理和重试机制
* 4. 优化的 ID 生成 - 确保唯一性 * 4. 优化的 ID 生成 - 确保唯一性
* 5. 写入确认机制 - 返回 Promise 让调用者知道日志是否成功保存 * 5. 写入确认机制 - 返回 Promise 让调用者知道日志是否成功保存
* 6. 修复日志丢失问题 - 确保所有日志都能被正确保存
*/ */
class Logger { class Logger {
private static instance: Logger; private static instance: Logger;
private readonly maxLogs = 1000; // 最多保存1000条日志 private readonly maxLogs = 2000; // 最多保存2000条日志
private readonly storageKey = '@app_logs'; private readonly storageKey = '@app_logs';
// 内存队列相关 // 内存队列相关
private memoryQueue: LogEntry[] = []; private memoryQueue: LogEntry[] = [];
private readonly queueMaxSize = 50; // 达到50条日志时触发批量写入 private readonly queueMaxSize = 10; // 达到10条日志时触发批量写入
private readonly flushInterval = 5000; // 5秒自动刷新一次 private readonly flushInterval = 5000; // 5秒自动刷新一次
private flushTimer: ReturnType<typeof setInterval> | null = null; private flushTimer: ReturnType<typeof setInterval> | null = null;
// 写入锁相关 // 写入锁相关
private isWriting = false; private isWriting = false;
private writePromise: Promise<void> | null = null; private writePromise: Promise<void> | null = null;
private pendingFlushes: Array<() => void> = []; // 等待刷新完成的回调队列
// ID 生成相关 // ID 生成相关
private idCounter = 0; private idCounter = 0;
@@ -40,6 +42,9 @@ class Logger {
private readonly maxRetries = 3; private readonly maxRetries = 3;
private readonly retryDelay = 1000; // 1秒 private readonly retryDelay = 1000; // 1秒
// 应用退出标志
private isShuttingDown = false;
static getInstance(): Logger { static getInstance(): Logger {
if (!Logger.instance) { if (!Logger.instance) {
Logger.instance = new Logger(); Logger.instance = new Logger();
@@ -81,16 +86,38 @@ class Logger {
private setupAppExitHandler(): void { private setupAppExitHandler(): void {
// 这是一个最佳努力的清理,不是所有场景都能捕获 // 这是一个最佳努力的清理,不是所有场景都能捕获
if (typeof process !== 'undefined' && process.on) { if (typeof process !== 'undefined' && process.on) {
const cleanup = () => { const cleanup = async () => {
this.isShuttingDown = true;
if (this.memoryQueue.length > 0) { if (this.memoryQueue.length > 0) {
// 同步刷新(应用退出时) console.log('[Logger] App exiting, flushing remaining logs...');
this.flushQueueSync(); try {
// 在退出时等待刷新完成
await this.flushQueue();
console.log('[Logger] Logs flushed successfully on exit');
} catch (error) {
console.error('[Logger] Failed to flush logs on exit:', error);
}
} }
}; };
process.on('exit', cleanup); process.on('exit', () => {
process.on('SIGINT', cleanup); // exit 事件不能异步,只能尝试
process.on('SIGTERM', cleanup); if (this.memoryQueue.length > 0) {
console.warn('[Logger] Process exiting with unflushed logs');
}
});
// 这些信号可以异步处理
process.on('SIGINT', async () => {
await cleanup();
process.exit(0);
});
process.on('SIGTERM', async () => {
await cleanup();
process.exit(0);
});
} }
} }
@@ -207,12 +234,12 @@ class Logger {
/** /**
* 刷新队列到存储(异步,带锁) * 刷新队列到存储(异步,带锁)
* 修复:确保在写入失败时不丢失日志,并正确处理并发刷新请求
*/ */
private async flushQueue(): Promise<void> { private async flushQueue(): Promise<void> {
// 如果正在写入,等待当前写入完成 // 如果正在写入,返回当前写入 Promise
if (this.isWriting && this.writePromise) { if (this.isWriting && this.writePromise) {
await this.writePromise; return this.writePromise;
return;
} }
// 如果队列为空,直接返回 // 如果队列为空,直接返回
@@ -223,9 +250,8 @@ class Logger {
// 设置写入锁 // 设置写入锁
this.isWriting = true; this.isWriting = true;
// 保存要写入的日志(避免在写入过程中队列被修改 // 复制要写入的日志(但不立即清空队列,避免失败时丢失
const logsToWrite = [...this.memoryQueue]; const logsToWrite = [...this.memoryQueue];
this.memoryQueue = [];
this.writePromise = (async () => { this.writePromise = (async () => {
try { try {
@@ -238,19 +264,27 @@ class Logger {
// 保存到存储 // 保存到存储
await this.saveLogs(allLogs); await this.saveLogs(allLogs);
console.log(`[Logger] Successfully flushed ${logsToWrite.length} logs to storage`); // 写入成功后才从队列中移除这些日志
// 使用 filter 而不是直接赋值,因为在写入过程中可能有新日志添加
const writtenIds = new Set(logsToWrite.map(log => log.id));
this.memoryQueue = this.memoryQueue.filter(log => !writtenIds.has(log.id));
console.log(`[Logger] Successfully flushed ${logsToWrite.length} logs to storage, remaining: ${this.memoryQueue.length}`);
// 通知等待的刷新请求
this.notifyPendingFlushes();
} catch (error) { } catch (error) {
console.error('[Logger] Failed to flush queue:', error); console.error('[Logger] Failed to flush queue:', error);
// 写入失败,将日志放回队列(保留在内存中) // 写入失败时,日志已经在队列中,无需重新添加
this.memoryQueue.unshift(...logsToWrite); // 但要限制队列大小,避免内存溢出
// 限制队列大小,避免内存溢出
if (this.memoryQueue.length > this.maxLogs) { if (this.memoryQueue.length > this.maxLogs) {
const overflow = this.memoryQueue.length - this.maxLogs; const overflow = this.memoryQueue.length - this.maxLogs;
console.warn(`[Logger] Queue overflow, dropping ${overflow} oldest logs`); console.warn(`[Logger] Queue overflow, dropping ${overflow} oldest logs`);
this.memoryQueue = this.memoryQueue.slice(-this.maxLogs); this.memoryQueue = this.memoryQueue.slice(-this.maxLogs);
} }
throw error; // 重新抛出错误,让调用者知道刷新失败
} finally { } finally {
// 释放写入锁 // 释放写入锁
this.isWriting = false; this.isWriting = false;
@@ -258,37 +292,34 @@ class Logger {
} }
})(); })();
await this.writePromise; return this.writePromise;
} }
/** /**
* 同步刷新队列(应用退出时使用) * 通知等待刷新完成的回调
*/ */
private flushQueueSync(): void { private notifyPendingFlushes(): void {
if (this.memoryQueue.length === 0) { const callbacks = [...this.pendingFlushes];
this.pendingFlushes = [];
callbacks.forEach(callback => callback());
}
/**
* 等待当前刷新完成
*/
private async waitForFlush(): Promise<void> {
if (!this.isWriting) {
return; return;
} }
try { return new Promise<void>((resolve) => {
// 注意:这是一个阻塞操作,仅在应用退出时使用 this.pendingFlushes.push(resolve);
const logsToWrite = [...this.memoryQueue];
this.memoryQueue = [];
// 这里我们无法使用异步操作,只能尝试
console.log(`[Logger] Attempting to flush ${logsToWrite.length} logs synchronously`);
// 实际上在 React Native 中很难做到真正的同步保存
// 这里只是一个最佳努力的尝试
this.flushQueue().catch(error => {
console.error('[Logger] Sync flush failed:', error);
}); });
} catch (error) {
console.error('[Logger] Failed to flush queue synchronously:', error);
}
} }
/** /**
* 添加日志到队列 * 添加日志到队列
* 修复:确保在达到阈值时正确处理刷新,避免日志丢失
*/ */
private async addLog(level: LogEntry['level'], message: string, data?: any): Promise<void> { private async addLog(level: LogEntry['level'], message: string, data?: any): Promise<void> {
// 序列化数据 // 序列化数据
@@ -320,10 +351,13 @@ class Logger {
// 检查是否需要刷新队列 // 检查是否需要刷新队列
if (this.memoryQueue.length >= this.queueMaxSize) { if (this.memoryQueue.length >= this.queueMaxSize) {
// 不等待刷新完成,避免阻塞调用者 try {
this.flushQueue().catch(error => { // 等待刷新完成,确保日志不会丢失
await this.flushQueue();
} catch (error) {
// 刷新失败时,日志仍在队列中,会在下次刷新时重试
console.error('[Logger] Failed to flush queue after size threshold:', error); console.error('[Logger] Failed to flush queue after size threshold:', error);
}); }
} }
} }
@@ -351,10 +385,21 @@ class Logger {
*/ */
async getAllLogs(): Promise<LogEntry[]> { async getAllLogs(): Promise<LogEntry[]> {
// 先刷新队列 // 先刷新队列
try {
await this.flushQueue(); await this.flushQueue();
} catch (error) {
console.warn('[Logger] Failed to flush before getting logs:', error);
}
// 然后获取存储中的日志 // 然后获取存储中的日志
return await this.getLogs(); const storedLogs = await this.getLogs();
// 如果还有未刷新的日志(刷新失败的情况),也包含进来
if (this.memoryQueue.length > 0) {
return [...storedLogs, ...this.memoryQueue];
}
return storedLogs;
} }
/** /**
@@ -365,6 +410,9 @@ class Logger {
// 清空内存队列 // 清空内存队列
this.memoryQueue = []; this.memoryQueue = [];
// 等待当前写入完成
await this.waitForFlush();
// 清除存储 // 清除存储
await AsyncStorage.removeItem(this.storageKey); await AsyncStorage.removeItem(this.storageKey);
@@ -379,11 +427,8 @@ class Logger {
* 导出日志 * 导出日志
*/ */
async exportLogs(): Promise<string> { async exportLogs(): Promise<string> {
// 先刷新队列 // 获取所有日志(包括未刷新的)
await this.flushQueue(); const logs = await this.getAllLogs();
// 然后获取并导出日志
const logs = await this.getLogs();
return JSON.stringify(logs, null, 2); return JSON.stringify(logs, null, 2);
} }
@@ -397,25 +442,36 @@ class Logger {
/** /**
* 获取队列状态(用于调试) * 获取队列状态(用于调试)
*/ */
getQueueStatus(): { queueSize: number; isWriting: boolean } { getQueueStatus(): { queueSize: number; isWriting: boolean; isShuttingDown: boolean } {
return { return {
queueSize: this.memoryQueue.length, queueSize: this.memoryQueue.length,
isWriting: this.isWriting isWriting: this.isWriting,
isShuttingDown: this.isShuttingDown
}; };
} }
/** /**
* 清理资源 * 清理资源
*/ */
destroy(): void { async destroy(): Promise<void> {
// 设置关闭标志
this.isShuttingDown = true;
// 停止定时器
if (this.flushTimer) { if (this.flushTimer) {
clearInterval(this.flushTimer); clearInterval(this.flushTimer);
this.flushTimer = null; this.flushTimer = null;
} }
// 最后刷新一次 // 最后刷新一次,确保所有日志都被保存
if (this.memoryQueue.length > 0) { if (this.memoryQueue.length > 0) {
this.flushQueueSync(); try {
console.log('[Logger] Destroying logger, flushing remaining logs...');
await this.flushQueue();
console.log('[Logger] Logger destroyed successfully');
} catch (error) {
console.error('[Logger] Failed to flush logs on destroy:', error);
}
} }
} }
} }
@@ -433,6 +489,10 @@ export const log = {
// 额外的工具函数 // 额外的工具函数
flush: () => logger.flush(), flush: () => logger.flush(),
getQueueStatus: () => logger.getQueueStatus(), getQueueStatus: () => logger.getQueueStatus(),
getAllLogs: () => logger.getAllLogs(),
clearLogs: () => logger.clearLogs(),
exportLogs: () => logger.exportLogs(),
destroy: () => logger.destroy(),
}; };
export type { LogEntry }; export type { LogEntry };

65
utils/stress.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* HRV 与压力指数转换工具
*
* 提供统一的 HRV -> 压力指数映射及压力等级判定
*/
export type StressLevel = 'low' | 'moderate' | 'high';
export interface StressLevelInfo {
level: StressLevel;
label: string;
description: string;
}
const MIN_HRV = 30;
const MAX_HRV = 130;
/**
* 将 HRVms转换为压力指数0-100数值越高表示压力越大
*/
export function convertHrvToStressIndex(hrv: number | null | undefined): number | null {
if (hrv == null || hrv <= 0) {
return null;
}
const clamped = Math.max(MIN_HRV, Math.min(MAX_HRV, hrv));
const normalized = 100 - ((clamped - MIN_HRV) / (MAX_HRV - MIN_HRV)) * 100;
return Math.round(normalized);
}
/**
* 根据压力指数获取压力等级信息
*/
export function getStressLevelInfo(stressIndex: number | null): StressLevelInfo {
if (stressIndex == null) {
return {
level: 'moderate',
label: '压力未知',
description: '暂无有效的 HRV 数据',
};
}
if (stressIndex >= 70) {
return {
level: 'high',
label: '压力偏高',
description: '建议立即放松,关注呼吸与休息',
};
}
if (stressIndex >= 40) {
return {
level: 'moderate',
label: '压力适中',
description: '保持当前节奏,注意劳逸结合',
};
}
return {
level: 'low',
label: '状态放松',
description: '身心良好,继续保持',
};
}