feat(workout): 新增锻炼结束监听和个性化通知功能
实现了iOS HealthKit锻炼数据实时监听,当用户完成锻炼时自动发送个性化鼓励通知。包括锻炼类型筛选、时间范围控制、用户偏好设置等完整功能,并提供了测试工具和详细文档。
This commit is contained in:
@@ -179,6 +179,11 @@ export class NotificationService {
|
||||
if (data?.url) {
|
||||
router.push(data.url as any);
|
||||
}
|
||||
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
||||
// 处理锻炼完成通知
|
||||
console.log('用户点击了锻炼完成通知', data);
|
||||
// 跳转到锻炼历史页面
|
||||
router.push('/workout/history' as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +516,7 @@ export const NotificationTypes = {
|
||||
WATER_REMINDER: 'water_reminder',
|
||||
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||||
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
||||
WORKOUT_COMPLETION: 'workout_completion',
|
||||
} as const;
|
||||
|
||||
// 便捷方法
|
||||
|
||||
173
services/workoutMonitor.ts
Normal file
173
services/workoutMonitor.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { fetchRecentWorkouts, WorkoutData } from '@/utils/health';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { analyzeWorkoutAndSendNotification } from './workoutNotificationService';
|
||||
|
||||
const { HealthKitManager } = NativeModules;
|
||||
const workoutEmitter = new NativeEventEmitter(HealthKitManager);
|
||||
|
||||
class WorkoutMonitorService {
|
||||
private static instance: WorkoutMonitorService;
|
||||
private isInitialized = false;
|
||||
private lastProcessedWorkoutId: string | null = null;
|
||||
private processingTimeout: any = null;
|
||||
private eventListenerSubscription: any = null;
|
||||
|
||||
static getInstance(): WorkoutMonitorService {
|
||||
if (!WorkoutMonitorService.instance) {
|
||||
WorkoutMonitorService.instance = new WorkoutMonitorService();
|
||||
}
|
||||
return WorkoutMonitorService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
console.log('锻炼监听服务已初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取上次处理的锻炼ID
|
||||
await this.loadLastProcessedWorkoutId();
|
||||
|
||||
// 启动 iOS 原生锻炼监听器
|
||||
await HealthKitManager.startWorkoutObserver();
|
||||
|
||||
// 监听锻炼更新事件
|
||||
this.eventListenerSubscription = workoutEmitter.addListener(
|
||||
'workoutUpdate',
|
||||
this.handleWorkoutUpdate.bind(this)
|
||||
);
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('锻炼监听服务初始化成功');
|
||||
} catch (error) {
|
||||
console.error('锻炼监听服务初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
// 停止原生监听器
|
||||
await HealthKitManager.stopWorkoutObserver();
|
||||
|
||||
// 移除事件监听器
|
||||
if (this.eventListenerSubscription) {
|
||||
this.eventListenerSubscription.remove();
|
||||
this.eventListenerSubscription = null;
|
||||
}
|
||||
|
||||
// 清理定时器
|
||||
if (this.processingTimeout) {
|
||||
clearTimeout(this.processingTimeout);
|
||||
this.processingTimeout = null;
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
console.log('锻炼监听服务已停止');
|
||||
} catch (error) {
|
||||
console.error('停止锻炼监听服务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLastProcessedWorkoutId(): Promise<void> {
|
||||
try {
|
||||
const storedId = await AsyncStorage.getItem('@last_processed_workout_id');
|
||||
this.lastProcessedWorkoutId = storedId;
|
||||
console.log('上次处理的锻炼ID:', this.lastProcessedWorkoutId);
|
||||
} catch (error) {
|
||||
console.error('加载上次处理的锻炼ID失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveLastProcessedWorkoutId(workoutId: string): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem('@last_processed_workout_id', workoutId);
|
||||
this.lastProcessedWorkoutId = workoutId;
|
||||
} catch (error) {
|
||||
console.error('保存上次处理的锻炼ID失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkoutUpdate(event: any): Promise<void> {
|
||||
console.log('收到锻炼更新事件:', event);
|
||||
|
||||
// 防抖处理,避免短时间内重复处理
|
||||
if (this.processingTimeout) {
|
||||
clearTimeout(this.processingTimeout);
|
||||
}
|
||||
|
||||
this.processingTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await this.checkForNewWorkouts();
|
||||
} catch (error) {
|
||||
console.error('检查新锻炼失败:', error);
|
||||
}
|
||||
}, 5000); // 5秒延迟,确保 HealthKit 数据已完全更新
|
||||
}
|
||||
|
||||
private async checkForNewWorkouts(): Promise<void> {
|
||||
try {
|
||||
console.log('检查新的锻炼记录...');
|
||||
|
||||
// 获取最近1小时的锻炼记录
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const recentWorkouts = await fetchRecentWorkouts({
|
||||
startDate: oneHourAgo.toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
limit: 10
|
||||
});
|
||||
|
||||
console.log(`找到 ${recentWorkouts.length} 条最近的锻炼记录`);
|
||||
|
||||
// 检查是否有新的锻炼记录
|
||||
for (const workout of recentWorkouts) {
|
||||
if (workout.id !== this.lastProcessedWorkoutId) {
|
||||
console.log('检测到新锻炼:', {
|
||||
id: workout.id,
|
||||
type: workout.workoutActivityTypeString,
|
||||
duration: workout.duration,
|
||||
startDate: workout.startDate
|
||||
});
|
||||
|
||||
await this.processNewWorkout(workout);
|
||||
await this.saveLastProcessedWorkoutId(workout.id);
|
||||
} else {
|
||||
console.log('锻炼已处理过,跳过:', workout.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查新锻炼失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async processNewWorkout(workout: WorkoutData): Promise<void> {
|
||||
try {
|
||||
console.log('开始处理新锻炼:', workout.id);
|
||||
|
||||
// 分析锻炼并发送通知
|
||||
await analyzeWorkoutAndSendNotification(workout);
|
||||
|
||||
console.log('新锻炼处理完成:', workout.id);
|
||||
} catch (error) {
|
||||
console.error('处理新锻炼失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发检查(用于测试)
|
||||
async manualCheck(): Promise<void> {
|
||||
console.log('手动触发锻炼检查...');
|
||||
await this.checkForNewWorkouts();
|
||||
}
|
||||
|
||||
// 获取服务状态
|
||||
getStatus(): { initialized: boolean; lastProcessedWorkoutId: string | null } {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
lastProcessedWorkoutId: this.lastProcessedWorkoutId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const workoutMonitorService = WorkoutMonitorService.getInstance();
|
||||
161
services/workoutNotificationService.ts
Normal file
161
services/workoutNotificationService.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { getWorkoutTypeDisplayName, WorkoutData } from '@/utils/health';
|
||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||
import {
|
||||
getWorkoutNotificationEnabled,
|
||||
isNotificationTimeAllowed,
|
||||
isWorkoutTypeEnabled
|
||||
} from '@/utils/workoutPreferences';
|
||||
import { notificationService, NotificationTypes } from './notifications';
|
||||
import { getWorkoutDetailMetrics } from './workoutDetail';
|
||||
|
||||
interface WorkoutEncouragementMessage {
|
||||
title: string;
|
||||
body: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): Promise<void> {
|
||||
try {
|
||||
// 检查用户是否启用了通用通知
|
||||
const notificationsEnabled = await getNotificationEnabled();
|
||||
if (!notificationsEnabled) {
|
||||
console.log('用户已禁用通知,跳过锻炼结束通知');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查用户是否启用了锻炼通知
|
||||
const workoutNotificationsEnabled = await getWorkoutNotificationEnabled();
|
||||
if (!workoutNotificationsEnabled) {
|
||||
console.log('用户已禁用锻炼通知,跳过锻炼结束通知');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查时间限制(避免深夜打扰)
|
||||
const timeAllowed = await isNotificationTimeAllowed();
|
||||
if (!timeAllowed) {
|
||||
console.log('当前时间不适合发送通知,跳过锻炼结束通知');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查特定锻炼类型是否启用了通知
|
||||
const workoutTypeEnabled = await isWorkoutTypeEnabled(workout.workoutActivityTypeString || '');
|
||||
if (!workoutTypeEnabled) {
|
||||
console.log('该锻炼类型已禁用通知,跳过锻炼结束通知:', workout.workoutActivityTypeString);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取详细的锻炼指标
|
||||
const workoutMetrics = await getWorkoutDetailMetrics(workout);
|
||||
|
||||
// 生成个性化鼓励消息
|
||||
const message = generateEncouragementMessage(workout, workoutMetrics);
|
||||
|
||||
// 发送通知
|
||||
await notificationService.sendImmediateNotification({
|
||||
title: message.title,
|
||||
body: message.body,
|
||||
data: {
|
||||
type: NotificationTypes.WORKOUT_COMPLETION,
|
||||
workoutId: workout.id,
|
||||
workoutType: workout.workoutActivityTypeString,
|
||||
duration: workout.duration,
|
||||
calories: workout.totalEnergyBurned,
|
||||
...message.data
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high'
|
||||
});
|
||||
|
||||
console.log('锻炼结束通知已发送:', message.title);
|
||||
} catch (error) {
|
||||
console.error('发送锻炼结束通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function generateEncouragementMessage(
|
||||
workout: WorkoutData,
|
||||
metrics: any
|
||||
): WorkoutEncouragementMessage {
|
||||
const workoutType = getWorkoutTypeDisplayName(workout.workoutActivityTypeString);
|
||||
const durationMinutes = Math.round(workout.duration / 60);
|
||||
const calories = workout.totalEnergyBurned ? Math.round(workout.totalEnergyBurned) : 0;
|
||||
|
||||
// 基于锻炼类型和指标生成个性化消息
|
||||
let title = '锻炼完成!';
|
||||
let body = '';
|
||||
let data: Record<string, any> = {};
|
||||
|
||||
switch (workout.workoutActivityTypeString?.toLowerCase()) {
|
||||
case 'running':
|
||||
title = '🏃♂️ 跑步完成!';
|
||||
body = `太棒了!您刚刚完成了${durationMinutes}分钟的跑步,消耗了约${calories}千卡热量。`;
|
||||
if (metrics.averageHeartRate) {
|
||||
body += `平均心率${metrics.averageHeartRate}次/分。`;
|
||||
}
|
||||
body += '坚持运动让身体更健康!💪';
|
||||
break;
|
||||
|
||||
case 'cycling':
|
||||
title = '🚴♂️ 骑行完成!';
|
||||
body = `骑行${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
if (workout.totalDistance) {
|
||||
const distanceKm = (workout.totalDistance / 1000).toFixed(2);
|
||||
body += `骑行距离${distanceKm}公里。`;
|
||||
}
|
||||
body += '享受骑行的自由吧!🌟';
|
||||
break;
|
||||
|
||||
case 'swimming':
|
||||
title = '🏊♂️ 游泳完成!';
|
||||
body = `游泳${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
body += '全身运动效果极佳,继续保持!💦';
|
||||
break;
|
||||
|
||||
case 'yoga':
|
||||
title = '🧘♀️ 瑜伽完成!';
|
||||
body = `${durationMinutes}分钟的瑜伽练习完成!提升了柔韧性和内心平静。`;
|
||||
body += '继续保持这份宁静!🌸';
|
||||
break;
|
||||
|
||||
case 'functionalstrengthtraining':
|
||||
case 'traditionalstrengthtraining':
|
||||
title = '💪 力量训练完成!';
|
||||
body = `力量训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
if (metrics.mets && metrics.mets > 6) {
|
||||
body += '高强度训练,效果显著!🔥';
|
||||
}
|
||||
body += '肌肉正在变得更强壮!';
|
||||
break;
|
||||
|
||||
case 'highintensityintervaltraining':
|
||||
title = '🔥 HIIT训练完成!';
|
||||
body = `高强度间歇训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
body += '心肺功能得到有效提升,您的努力值得称赞!⚡';
|
||||
break;
|
||||
|
||||
default:
|
||||
title = '🎯 锻炼完成!';
|
||||
body = `${workoutType}${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
body += '坚持运动,健康生活!🌟';
|
||||
break;
|
||||
}
|
||||
|
||||
// 添加心率区间分析(如果有心率数据)
|
||||
if (metrics.heartRateZones && metrics.heartRateZones.length > 0) {
|
||||
const dominantZone = metrics.heartRateZones.reduce((prev: any, current: any) =>
|
||||
current.durationMinutes > prev.durationMinutes ? current : prev
|
||||
);
|
||||
|
||||
if (dominantZone.durationMinutes > 5) {
|
||||
data.heartRateZone = dominantZone.key;
|
||||
data.heartRateZoneLabel = dominantZone.label;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加锻炼强度评估
|
||||
if (metrics.mets) {
|
||||
data.intensity = metrics.mets < 3 ? 'low' : metrics.mets < 6 ? 'moderate' : 'high';
|
||||
}
|
||||
|
||||
return { title, body, data };
|
||||
}
|
||||
Reference in New Issue
Block a user