feat(hrv): 添加心率变异性监控和压力评估功能
- 新增 HRV 监听服务,实时监控心率变异性数据 - 实现 HRV 到压力指数的转换算法和压力等级评估 - 添加智能通知服务,在压力偏高时推送健康建议 - 优化日志系统,修复日志丢失问题并增强刷新机制 - 改进个人页面下拉刷新,支持并行数据加载 - 优化勋章数据缓存策略,减少不必要的网络请求 - 重构应用初始化流程,优化权限服务和健康监听服务的启动顺序 - 移除冗余日志输出,提升应用性能
This commit is contained in:
173
services/hrvMonitor.ts
Normal file
173
services/hrvMonitor.ts
Normal 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();
|
||||
73
services/hrvNotificationService.ts
Normal file
73
services/hrvNotificationService.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -204,6 +204,10 @@ export class NotificationService {
|
||||
console.log('用户点击了锻炼完成通知', data);
|
||||
// 跳转到锻炼历史页面
|
||||
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) {
|
||||
// 处理药品提醒通知
|
||||
console.log('用户点击了药品提醒通知', data);
|
||||
@@ -551,6 +555,7 @@ export const NotificationTypes = {
|
||||
FASTING_START: 'fasting_start',
|
||||
FASTING_END: 'fasting_end',
|
||||
MEDICATION_REMINDER: 'medication_reminder',
|
||||
HRV_STRESS_ALERT: 'hrv_stress_alert',
|
||||
} as const;
|
||||
|
||||
// 便捷方法
|
||||
|
||||
@@ -87,8 +87,6 @@ export class PushNotificationManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('获取到设备令牌:', token);
|
||||
|
||||
// 检查是否需要注册令牌
|
||||
await this.checkAndRegisterToken(token);
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
*/
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import dayjs from 'dayjs';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { analyzeSleepAndSendNotification } from './sleepNotificationService';
|
||||
|
||||
const { HealthKitManager } = NativeModules;
|
||||
const SLEEP_ANALYSIS_SENT_KEY = '@sleep_analysis_sent';
|
||||
|
||||
|
||||
// 睡眠阶段类型
|
||||
@@ -138,6 +141,12 @@ class SleepMonitorService {
|
||||
this.lastProcessedTime = now;
|
||||
|
||||
try {
|
||||
const alreadySentToday = await this.hasSentSleepAnalysisToday();
|
||||
if (alreadySentToday) {
|
||||
console.log('[SleepMonitor] Sleep analysis already sent today, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// 分析最近的睡眠数据
|
||||
const analysis = await this.analyzeSleepData();
|
||||
|
||||
@@ -150,6 +159,7 @@ class SleepMonitorService {
|
||||
|
||||
// 发送睡眠分析通知
|
||||
await this.notifySleepAnalysis(analysis);
|
||||
await this.markSleepAnalysisSent();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SleepMonitor] Failed to analyze sleep data:', error);
|
||||
@@ -507,7 +517,30 @@ class SleepMonitorService {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const sleepMonitorService = new SleepMonitorService();
|
||||
export const sleepMonitorService = new SleepMonitorService();
|
||||
|
||||
Reference in New Issue
Block a user