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

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);
// 跳转到锻炼历史页面
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;
// 便捷方法

View File

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

View File

@@ -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();