# 方案总结
基于提供的 Git diff,我将生成以下 conventional commit message: ## 变更分析: 1. **核心功能**: - 新增睡眠监控服务(`services/sleepMonitor.ts`) - 新增睡眠通知服务(`services/sleepNotificationService.ts`) - iOS 原生端增加睡眠观察者方法 2. **应用启动优化**: - 重构 `app/_layout.tsx` 中的初始化流程,按优先级分阶段加载服务 3. **药品功能改进**: - 优化语音识别交互(实时预览、可取消) - Widget 增加 URL scheme 支持 4. **路由配置**: - 新增药品管理路由常量 ## 提交信息类型: - **主类型**:`feat` (新增睡眠监控功能) - **作用域**:`health` (健康相关功能) --- 请确认方案后,我将生成最终的 commit message。 --- **最终 Commit Message:** feat(health): 添加睡眠监控和通知服务,优化应用启动流程 - 新增睡眠监控服务,支持实时监听 HealthKit 睡眠数据更新 - 实现睡眠质量分析算法,计算睡眠评分和各阶段占比 - 新增睡眠通知服务,分析完成后自动推送质量评估和建议 - iOS 原生端实现睡眠数据观察者,支持后台数据传递 - 重构应用启动初始化流程,按优先级分阶段加载服务(关键/次要/后台/空闲) - 优化药品录入页面语音识别交互,支持实时预览和取消操作 - 药品 Widget 增加 deeplink 支持,点击跳转到应用 - 新增药品管理路由常量配置
This commit is contained in:
326
app/_layout.tsx
326
app/_layout.tsx
@@ -1,10 +1,10 @@
|
|||||||
|
import '@/i18n';
|
||||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
import { useFonts } from 'expo-font';
|
import { useFonts } from 'expo-font';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
import '@/i18n';
|
|
||||||
|
|
||||||
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
@@ -12,6 +12,7 @@ import { useQuickActions } from '@/hooks/useQuickActions';
|
|||||||
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||||
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 { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
import { WaterRecordSource } from '@/services/waterRecords';
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
import { workoutMonitorService } from '@/services/workoutMonitor';
|
import { workoutMonitorService } from '@/services/workoutMonitor';
|
||||||
@@ -94,181 +95,276 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadUserData = async () => {
|
// ==================== 第一优先级:立即执行(关键路径,0-500ms)====================
|
||||||
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
const initializeCriticalServices = async () => {
|
||||||
await dispatch(fetchMyProfile());
|
try {
|
||||||
|
logger.info('🚀 开始初始化关键服务...');
|
||||||
|
|
||||||
|
// 1. 加载用户数据(首屏展示需要)
|
||||||
|
await dispatch(fetchMyProfile());
|
||||||
|
logger.info('✅ 用户数据加载完成');
|
||||||
|
|
||||||
|
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
|
||||||
|
initializeHealthPermissions();
|
||||||
|
logger.info('✅ HealthKit 权限系统初始化完成');
|
||||||
|
|
||||||
|
// 3. 初始化通知服务基础功能
|
||||||
|
await notificationService.initialize();
|
||||||
|
logger.info('✅ 通知服务初始化完成');
|
||||||
|
|
||||||
|
// 4. 初始化快捷动作(用户可能立即使用)
|
||||||
|
await setupQuickActions();
|
||||||
|
logger.info('✅ 快捷动作初始化完成');
|
||||||
|
|
||||||
|
// 5. 清空 AI 教练会话缓存(轻量操作)
|
||||||
|
clearAiCoachSessionCache();
|
||||||
|
logger.info('✅ AI 教练缓存清理完成');
|
||||||
|
|
||||||
|
logger.info('🎉 关键服务初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 关键服务初始化失败:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initHealthPermissions = async () => {
|
// ==================== 第二优先级:短延迟(1-2秒后执行)====================
|
||||||
// 初始化 HealthKit 权限管理系统
|
const initializeSecondaryServices = () => {
|
||||||
try {
|
setTimeout(async () => {
|
||||||
logger.info('初始化 HealthKit 权限管理系统...');
|
try {
|
||||||
initializeHealthPermissions();
|
logger.info('⏰ 开始初始化次要服务...');
|
||||||
|
|
||||||
// 延迟请求权限,避免应用启动时弹窗
|
// 1. 请求 HealthKit 权限(延迟到 3 秒,避免打断用户浏览)
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await ensureHealthPermissions();
|
||||||
|
logger.info('✅ HealthKit 权限请求完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// 2. 初始化喝水记录 Bridge
|
||||||
|
initializeWaterRecordBridge();
|
||||||
|
logger.info('✅ 喝水记录 Bridge 初始化完成');
|
||||||
|
|
||||||
|
// 3. 异步同步 Widget 数据(不阻塞主流程)
|
||||||
|
syncWidgetDataInBackground();
|
||||||
|
|
||||||
|
logger.info('🎉 次要服务初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 次要服务初始化失败:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 第三优先级:中延迟(3-5秒后或交互完成后执行)====================
|
||||||
|
const initializeBackgroundServices = () => {
|
||||||
|
// 使用 InteractionManager 确保在所有交互和动画完成后再执行
|
||||||
|
const { InteractionManager } = require('react-native');
|
||||||
|
|
||||||
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await ensureHealthPermissions();
|
logger.info('📅 开始初始化后台服务...');
|
||||||
logger.info('HealthKit 权限请求完成');
|
|
||||||
|
// 1. 批量注册所有通知提醒
|
||||||
|
await registerAllNotifications();
|
||||||
|
|
||||||
|
// 2. 初始化后台任务管理器
|
||||||
|
await initializeBackgroundTaskManager();
|
||||||
|
|
||||||
|
// 3. 初始化健康监听服务
|
||||||
|
await initializeHealthMonitoring();
|
||||||
|
|
||||||
|
logger.info('🎉 后台服务初始化完成');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
logger.error('❌ 后台服务初始化失败:', error);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 3000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
logger.info('HealthKit 权限管理初始化完成');
|
// ==================== 第四优先级:空闲时执行(不影响用户体验)====================
|
||||||
} catch (error) {
|
const initializeIdleServices = () => {
|
||||||
logger.warn('HealthKit 权限管理初始化失败:', error);
|
setTimeout(async () => {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeNotifications = async () => {
|
|
||||||
try {
|
|
||||||
try {
|
try {
|
||||||
logger.info('初始化后台任务管理器...');
|
logger.info('🔄 开始初始化空闲服务...');
|
||||||
|
|
||||||
await BackgroundTaskManager.getInstance().initialize();
|
// 1. 后台任务详细状态检查
|
||||||
|
await checkBackgroundTaskStatus();
|
||||||
|
|
||||||
logger.info('后台任务管理器初始化成功');
|
// 2. 开发环境调试工具
|
||||||
|
if (__DEV__ && BackgroundTaskDebugger) {
|
||||||
// 检查后台任务状态并恢复
|
|
||||||
await checkAndRestoreBackgroundTasks();
|
|
||||||
|
|
||||||
// 在开发环境中初始化调试工具
|
|
||||||
if (__DEV__) {
|
|
||||||
BackgroundTaskDebugger.getInstance().initialize();
|
BackgroundTaskDebugger.getInstance().initialize();
|
||||||
logger.info('后台任务调试工具已初始化(开发环境)');
|
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
|
||||||
}
|
}
|
||||||
} catch (backgroundError) {
|
|
||||||
logger.error('后台任务管理器初始化失败,将跳过后台任务:', backgroundError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化通知服务
|
logger.info('🎉 空闲服务初始化完成');
|
||||||
await notificationService.initialize();
|
|
||||||
logger.info('通知服务初始化成功');
|
|
||||||
|
|
||||||
// 注册午餐提醒(12:00)
|
|
||||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
|
||||||
logger.info('午餐提醒已注册');
|
|
||||||
|
|
||||||
// 注册晚餐提醒(18:00)
|
|
||||||
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
|
|
||||||
logger.info('晚餐提醒已注册');
|
|
||||||
|
|
||||||
// 注册心情提醒(21:00)
|
|
||||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
|
|
||||||
logger.info('心情提醒已注册');
|
|
||||||
|
|
||||||
// 注册默认喝水提醒(9:00-21:00,每2小时一次)
|
|
||||||
await WaterNotificationHelpers.scheduleRegularWaterReminders(profile.name || '用户');
|
|
||||||
logger.info('默认喝水提醒已注册');
|
|
||||||
|
|
||||||
// 安排断食通知(如果存在活跃的断食计划)
|
|
||||||
try {
|
|
||||||
const fastingSchedule = store.getState().fasting.activeSchedule;
|
|
||||||
if (fastingSchedule) {
|
|
||||||
const fastingPlan = store.getState().fasting.activeSchedule ? null : null;
|
|
||||||
// 断食通知将通过 useFastingNotifications hook 在页面加载时自动安排
|
|
||||||
logger.info('检测到活跃的断食计划,将通过页面 hook 自动安排通知');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('安排断食通知失败:', error);
|
logger.error('❌ 空闲服务初始化失败:', error);
|
||||||
}
|
}
|
||||||
|
}, 8000); // 8秒后执行,确保不影响用户体验
|
||||||
|
};
|
||||||
|
|
||||||
// 初始化快捷动作
|
// ==================== 辅助函数 ====================
|
||||||
await setupQuickActions();
|
|
||||||
logger.info('快捷动作初始化成功');
|
|
||||||
|
|
||||||
// 初始化喝水记录 bridge
|
// 异步同步 Widget 数据(不阻塞主流程)
|
||||||
initializeWaterRecordBridge();
|
const syncWidgetDataInBackground = async () => {
|
||||||
logger.info('喝水记录 Bridge 初始化成功');
|
try {
|
||||||
|
|
||||||
// 初始化锻炼监听服务
|
|
||||||
const initializeWorkoutMonitoring = async () => {
|
|
||||||
try {
|
|
||||||
await workoutMonitorService.initialize();
|
|
||||||
logger.info('锻炼监听服务初始化成功');
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('锻炼监听服务初始化失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeWorkoutMonitoring();
|
|
||||||
|
|
||||||
// 检查并同步Widget数据更改
|
|
||||||
const widgetSync = await syncPendingWidgetChanges();
|
const widgetSync = await syncPendingWidgetChanges();
|
||||||
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
||||||
logger.info(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
logger.info(`🔄 检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||||
|
|
||||||
// 将待同步的记录添加到 Redux store
|
// 异步处理每条记录
|
||||||
for (const record of widgetSync.pendingRecords) {
|
for (const record of widgetSync.pendingRecords) {
|
||||||
try {
|
try {
|
||||||
await store.dispatch(createWaterRecordAction({
|
await store.dispatch(createWaterRecordAction({
|
||||||
amount: record.amount,
|
amount: record.amount,
|
||||||
recordedAt: record.recordedAt,
|
recordedAt: record.recordedAt,
|
||||||
source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget)
|
source: WaterRecordSource.Auto,
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
|
|
||||||
logger.info(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
logger.info(`✅ 成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('同步水记录失败:', error);
|
logger.error('❌ 同步水记录失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除已同步的记录
|
// 清除已同步的记录
|
||||||
await clearPendingWaterRecords();
|
await clearPendingWaterRecords();
|
||||||
logger.info('所有待同步的水记录已处理完成');
|
logger.info('✅ 所有待同步的水记录已处理完成');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('通知服务、后台任务管理器或快捷动作初始化失败:', error);
|
logger.error('❌ Widget 数据同步失败:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查并恢复后台任务
|
// 批量注册所有通知提醒
|
||||||
const checkAndRestoreBackgroundTasks = async () => {
|
const registerAllNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
|
logger.info('📢 开始批量注册通知提醒...');
|
||||||
|
|
||||||
|
// 并行注册所有通知,提高效率
|
||||||
|
await Promise.all([
|
||||||
|
// 营养提醒
|
||||||
|
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
|
||||||
|
logger.info('✅ 午餐提醒已注册')
|
||||||
|
),
|
||||||
|
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
|
||||||
|
logger.info('✅ 晚餐提醒已注册')
|
||||||
|
),
|
||||||
|
|
||||||
|
// 心情提醒
|
||||||
|
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
|
||||||
|
logger.info('✅ 心情提醒已注册')
|
||||||
|
),
|
||||||
|
|
||||||
|
// 喝水提醒
|
||||||
|
WaterNotificationHelpers.scheduleRegularWaterReminders(profile.name || '用户').then(() =>
|
||||||
|
logger.info('✅ 喝水提醒已注册')
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 检查断食通知(如果有活跃计划)
|
||||||
|
const fastingSchedule = store.getState().fasting.activeSchedule;
|
||||||
|
if (fastingSchedule) {
|
||||||
|
logger.info('✅ 检测到活跃的断食计划,将通过页面 hook 自动安排通知');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('🎉 所有通知提醒注册完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 通知提醒注册失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化后台任务管理器
|
||||||
|
const initializeBackgroundTaskManager = async () => {
|
||||||
|
try {
|
||||||
|
logger.info('⚙️ 初始化后台任务管理器...');
|
||||||
|
|
||||||
|
await BackgroundTaskManager.getInstance().initialize();
|
||||||
|
logger.info('✅ 后台任务管理器初始化成功');
|
||||||
|
|
||||||
|
// 简单的任务调度检查
|
||||||
|
const taskManager = BackgroundTaskManager.getInstance();
|
||||||
|
const status = await taskManager.getStatus();
|
||||||
|
|
||||||
|
if (status === 'available') {
|
||||||
|
const pendingRequests = await taskManager.getPendingRequests();
|
||||||
|
if (pendingRequests.length === 0) {
|
||||||
|
await taskManager.scheduleNextTask();
|
||||||
|
logger.info('✅ 已调度新的后台任务');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 后台任务管理器初始化失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化健康监听服务(锻炼 + 睡眠)
|
||||||
|
const initializeHealthMonitoring = async () => {
|
||||||
|
try {
|
||||||
|
logger.info('💪 初始化健康监听服务...');
|
||||||
|
|
||||||
|
// 并行初始化锻炼和睡眠监听
|
||||||
|
await Promise.allSettled([
|
||||||
|
workoutMonitorService.initialize().then(() =>
|
||||||
|
logger.info('✅ 锻炼监听服务初始化成功')
|
||||||
|
),
|
||||||
|
sleepMonitorService.initialize().then(() =>
|
||||||
|
logger.info('✅ 睡眠监听服务初始化成功')
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('🎉 健康监听服务初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 健康监听服务初始化失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 后台任务详细状态检查(空闲时执行)
|
||||||
|
const checkBackgroundTaskStatus = async () => {
|
||||||
|
try {
|
||||||
|
logger.info('🔍 检查后台任务详细状态...');
|
||||||
|
|
||||||
const taskManager = BackgroundTaskManager.getInstance();
|
const taskManager = BackgroundTaskManager.getInstance();
|
||||||
const status = await taskManager.getStatus();
|
const status = await taskManager.getStatus();
|
||||||
const statusText = await taskManager.checkStatus();
|
const statusText = await taskManager.checkStatus();
|
||||||
|
|
||||||
logger.info(`后台任务状态检查: ${status} (${statusText})`);
|
logger.info(`📊 后台任务状态: ${status} (${statusText})`);
|
||||||
|
|
||||||
// 检查是否有待处理的任务请求
|
// 检查上次执行时间
|
||||||
const pendingRequests = await taskManager.getPendingRequests();
|
|
||||||
logger.info(`当前待处理的任务请求数量: ${pendingRequests.length}`);
|
|
||||||
|
|
||||||
// 如果没有待处理的任务请求,且状态可用,则调度一个新任务
|
|
||||||
if (pendingRequests.length === 0 && status === 'available') {
|
|
||||||
logger.info('没有待处理的任务请求,调度新的后台任务');
|
|
||||||
await taskManager.scheduleNextTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查上次后台任务执行时间
|
|
||||||
const lastCheckTime = await taskManager.getLastBackgroundCheckTime();
|
const lastCheckTime = await taskManager.getLastBackgroundCheckTime();
|
||||||
if (lastCheckTime) {
|
if (lastCheckTime) {
|
||||||
const timeSinceLastCheck = Date.now() - lastCheckTime;
|
const timeSinceLastCheck = Date.now() - lastCheckTime;
|
||||||
const hoursSinceLastCheck = timeSinceLastCheck / (1000 * 60 * 60);
|
const hoursSinceLastCheck = timeSinceLastCheck / (1000 * 60 * 60);
|
||||||
logger.info(`上次后台任务执行时间: ${new Date(lastCheckTime).toLocaleString()} (${hoursSinceLastCheck.toFixed(1)}小时前)`);
|
logger.info(`⏱️ 上次执行: ${new Date(lastCheckTime).toLocaleString()} (${hoursSinceLastCheck.toFixed(1)}小时前)`);
|
||||||
|
|
||||||
// 如果超过24小时没有执行后台任务,可能需要手动触发一次
|
|
||||||
if (hoursSinceLastCheck > 24) {
|
if (hoursSinceLastCheck > 24) {
|
||||||
logger.warn('超过24小时未执行后台任务,可能需要检查系统设置');
|
logger.warn('⚠️ 超过24小时未执行后台任务,请检查系统设置');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('✅ 后台任务状态检查完成');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('检查后台任务状态失败:', error);
|
logger.error('❌ 后台任务状态检查失败:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadUserData();
|
// ==================== 执行初始化流程 ====================
|
||||||
initHealthPermissions();
|
|
||||||
initializeNotifications();
|
|
||||||
|
|
||||||
|
// 立即执行关键服务
|
||||||
|
initializeCriticalServices();
|
||||||
|
|
||||||
// 冷启动时清空 AI 教练会话缓存
|
// 延迟执行次要服务
|
||||||
clearAiCoachSessionCache();
|
initializeSecondaryServices();
|
||||||
|
|
||||||
}, [dispatch]);
|
// 交互完成后执行后台服务
|
||||||
|
initializeBackgroundServices();
|
||||||
|
|
||||||
|
// 空闲时执行非关键服务
|
||||||
|
initializeIdleServices();
|
||||||
|
|
||||||
|
}, [dispatch, profile.name]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ export default function AddMedicationScreen() {
|
|||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [dictationActive, setDictationActive] = useState(false);
|
const [dictationActive, setDictationActive] = useState(false);
|
||||||
const [dictationLoading, setDictationLoading] = useState(false);
|
const [dictationLoading, setDictationLoading] = useState(false);
|
||||||
|
// 临时存储当前语音识别的结果,用于实时预览
|
||||||
|
const [currentDictationText, setCurrentDictationText] = useState('');
|
||||||
|
// 记录语音识别开始前的文本,用于取消时恢复
|
||||||
|
const [noteBeforeDictation, setNoteBeforeDictation] = useState('');
|
||||||
const isDictationSupported = Platform.OS === 'ios';
|
const isDictationSupported = Platform.OS === 'ios';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,20 +185,44 @@ export default function AddMedicationScreen() {
|
|||||||
});
|
});
|
||||||
}, [timesPerDay]);
|
}, [timesPerDay]);
|
||||||
|
|
||||||
const appendDictationResult = useCallback(
|
// 实时更新语音识别结果(替换式,不是追加)
|
||||||
|
const updateDictationResult = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
const clean = text.trim();
|
const clean = text.trim();
|
||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
|
|
||||||
|
// 实时更新:用识别的新文本替换当前识别文本
|
||||||
|
setCurrentDictationText(clean);
|
||||||
|
|
||||||
|
// 同步更新到 note 中,以便用户能看到实时效果
|
||||||
setNote((prev) => {
|
setNote((prev) => {
|
||||||
if (!prev) {
|
// 移除之前的语音识别文本,添加新的识别文本
|
||||||
|
const baseText = noteBeforeDictation;
|
||||||
|
if (!baseText) {
|
||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
return `${prev}${prev.endsWith('\n') ? '' : '\n'}${clean}`;
|
// 在原文本后追加,确保格式正确
|
||||||
|
return `${baseText}${baseText.endsWith('\n') ? '' : '\n'}${clean}`;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setNote]
|
[noteBeforeDictation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 确认语音识别结果
|
||||||
|
const confirmDictationResult = useCallback(() => {
|
||||||
|
// 语音识别结束,确认当前文本
|
||||||
|
setCurrentDictationText('');
|
||||||
|
setNoteBeforeDictation('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 取消语音识别
|
||||||
|
const cancelDictationResult = useCallback(() => {
|
||||||
|
// 恢复到语音识别前的文本
|
||||||
|
setNote(noteBeforeDictation);
|
||||||
|
setCurrentDictationText('');
|
||||||
|
setNoteBeforeDictation('');
|
||||||
|
}, [noteBeforeDictation]);
|
||||||
|
|
||||||
const stepTitle = STEP_TITLES[currentStep] ?? STEP_TITLES[0];
|
const stepTitle = STEP_TITLES[currentStep] ?? STEP_TITLES[0];
|
||||||
const stepDescription = STEP_DESCRIPTIONS[currentStep] ?? '';
|
const stepDescription = STEP_DESCRIPTIONS[currentStep] ?? '';
|
||||||
|
|
||||||
@@ -224,19 +252,25 @@ export default function AddMedicationScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Voice.onSpeechEnd = () => {
|
Voice.onSpeechEnd = () => {
|
||||||
|
// 语音识别结束,确认识别结果
|
||||||
|
confirmDictationResult();
|
||||||
setDictationActive(false);
|
setDictationActive(false);
|
||||||
setDictationLoading(false);
|
setDictationLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
Voice.onSpeechResults = (event: any) => {
|
Voice.onSpeechResults = (event: any) => {
|
||||||
|
// 获取最新的识别结果(这是累积的结果,包含之前说过的内容)
|
||||||
const recognized = event?.value?.[0];
|
const recognized = event?.value?.[0];
|
||||||
if (recognized) {
|
if (recognized) {
|
||||||
appendDictationResult(recognized);
|
// 实时更新识别结果,替换式而非追加式
|
||||||
|
updateDictationResult(recognized);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Voice.onSpeechError = (error: any) => {
|
Voice.onSpeechError = (error: any) => {
|
||||||
console.log('[MEDICATION] voice error', error);
|
console.log('[MEDICATION] voice error', error);
|
||||||
|
// 发生错误时取消识别
|
||||||
|
cancelDictationResult();
|
||||||
setDictationActive(false);
|
setDictationActive(false);
|
||||||
setDictationLoading(false);
|
setDictationLoading(false);
|
||||||
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
|
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
|
||||||
@@ -249,7 +283,7 @@ export default function AddMedicationScreen() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
}, [appendDictationResult, isDictationSupported]);
|
}, [updateDictationResult, confirmDictationResult, cancelDictationResult, isDictationSupported]);
|
||||||
|
|
||||||
const handleNext = useCallback(async () => {
|
const handleNext = useCallback(async () => {
|
||||||
if (!canProceed) return;
|
if (!canProceed) return;
|
||||||
@@ -359,25 +393,34 @@ export default function AddMedicationScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (dictationActive) {
|
if (dictationActive) {
|
||||||
|
// 停止录音
|
||||||
setDictationLoading(true);
|
setDictationLoading(true);
|
||||||
await Voice.stop();
|
await Voice.stop();
|
||||||
|
// Voice.onSpeechEnd 会自动确认结果
|
||||||
setDictationLoading(false);
|
setDictationLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开始录音前,保存当前的文本内容
|
||||||
|
setNoteBeforeDictation(note);
|
||||||
|
setCurrentDictationText('');
|
||||||
|
|
||||||
setDictationLoading(true);
|
setDictationLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// 确保之前的录音已停止
|
||||||
await Voice.stop();
|
await Voice.stop();
|
||||||
} catch {
|
} catch {
|
||||||
// no-op: safe to ignore if not already recording
|
// 忽略错误:如果之前没有录音,stop 会抛出异常
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开始语音识别
|
||||||
await Voice.start('zh-CN');
|
await Voice.start('zh-CN');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[MEDICATION] unable to start dictation', error);
|
console.log('[MEDICATION] unable to start dictation', error);
|
||||||
setDictationLoading(false);
|
setDictationLoading(false);
|
||||||
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
|
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
|
||||||
}
|
}
|
||||||
}, [dictationActive, dictationLoading, isDictationSupported]);
|
}, [dictationActive, dictationLoading, isDictationSupported, note]);
|
||||||
|
|
||||||
// 处理图片选择(拍照或相册)
|
// 处理图片选择(拍照或相册)
|
||||||
const handleSelectPhoto = useCallback(() => {
|
const handleSelectPhoto = useCallback(() => {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const ROUTES = {
|
|||||||
|
|
||||||
// 药品相关路由
|
// 药品相关路由
|
||||||
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
|
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
|
||||||
|
MEDICATION_MANAGE: '/medications/manage-medications',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 路由参数常量
|
// 路由参数常量
|
||||||
|
|||||||
@@ -89,4 +89,11 @@ RCT_EXTERN_METHOD(startWorkoutObserver:(RCTPromiseResolveBlock)resolver
|
|||||||
RCT_EXTERN_METHOD(stopWorkoutObserver:(RCTPromiseResolveBlock)resolver
|
RCT_EXTERN_METHOD(stopWorkoutObserver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
// Sleep Observer Methods
|
||||||
|
RCT_EXTERN_METHOD(startSleepObserver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
RCT_EXTERN_METHOD(stopSleepObserver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
@end
|
@end
|
||||||
@@ -1700,9 +1700,10 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
return description.lowercased()
|
return description.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Workout Observer Methods
|
// MARK: - Observer Methods
|
||||||
|
|
||||||
private var workoutObserverQuery: HKObserverQuery?
|
private var workoutObserverQuery: HKObserverQuery?
|
||||||
|
private var sleepObserverQuery: HKObserverQuery?
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func startWorkoutObserver(
|
func startWorkoutObserver(
|
||||||
@@ -1781,10 +1782,89 @@ private func sendWorkoutUpdateEvent() {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sleep Observer Methods
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func startSleepObserver(
|
||||||
|
_ 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 = sleepObserverQuery {
|
||||||
|
healthStore.stop(existingQuery)
|
||||||
|
sleepObserverQuery = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建睡眠数据观察者
|
||||||
|
let sleepType = ReadTypes.sleep
|
||||||
|
|
||||||
|
sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in
|
||||||
|
if let error = error {
|
||||||
|
print("Sleep observer error: \(error.localizedDescription)")
|
||||||
|
completionHandler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Sleep data updated, sending event to React Native")
|
||||||
|
// 发送简单的更新事件到 React Native,让 RN 层处理数据分析
|
||||||
|
self?.sendSleepUpdateEvent()
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用后台传递
|
||||||
|
healthStore.enableBackgroundDelivery(for: sleepType, frequency: .immediate) { (success, error) in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to enable background delivery for sleep: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
print("Background delivery for sleep enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
healthStore.execute(sleepObserverQuery!)
|
||||||
|
|
||||||
|
resolver(["success": true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func stopSleepObserver(
|
||||||
|
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||||
|
rejecter: @escaping RCTPromiseRejectBlock
|
||||||
|
) {
|
||||||
|
if let query = sleepObserverQuery {
|
||||||
|
healthStore.stop(query)
|
||||||
|
sleepObserverQuery = nil
|
||||||
|
|
||||||
|
// 禁用后台传递
|
||||||
|
healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver(["success": true])
|
||||||
|
} else {
|
||||||
|
resolver(["success": true]) // 即使没有查询在运行也返回成功
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送睡眠更新事件到 React Native(简单通知,不包含分析数据)
|
||||||
|
private func sendSleepUpdateEvent() {
|
||||||
|
sendEvent(withName: "sleepUpdate", body: [
|
||||||
|
"timestamp": Date().timeIntervalSince1970,
|
||||||
|
"type": "sleep_data_updated"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - RCTEventEmitter Overrides
|
// MARK: - RCTEventEmitter Overrides
|
||||||
|
|
||||||
override func supportedEvents() -> [String]! {
|
override func supportedEvents() -> [String]! {
|
||||||
return ["workoutUpdate"]
|
return ["workoutUpdate", "sleepUpdate"]
|
||||||
}
|
}
|
||||||
|
|
||||||
override static func requiresMainQueueSetup() -> Bool {
|
override static func requiresMainQueueSetup() -> Bool {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ struct medicineEntryView: View {
|
|||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(Color.secondary)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.widgetURL(URL(string: "digitalpilates://medications"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +175,11 @@ struct medicine: Widget {
|
|||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||||
medicineEntryView(entry: entry)
|
medicineEntryView(entry: entry)
|
||||||
.containerBackground(.fill.tertiary, for: .widget)
|
.containerBackground(.white, for: .widget)
|
||||||
}
|
}
|
||||||
.configurationDisplayName("用药计划")
|
.configurationDisplayName("用药计划")
|
||||||
.description("显示今日用药计划和服药状态")
|
.description("显示今日用药计划和服药状态")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
513
services/sleepMonitor.ts
Normal file
513
services/sleepMonitor.ts
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
/**
|
||||||
|
* 睡眠监控服务
|
||||||
|
*
|
||||||
|
* 负责监听睡眠数据更新事件,分析睡眠质量并提供反馈
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||||
|
import { analyzeSleepAndSendNotification } from './sleepNotificationService';
|
||||||
|
|
||||||
|
const { HealthKitManager } = NativeModules;
|
||||||
|
|
||||||
|
|
||||||
|
// 睡眠阶段类型
|
||||||
|
export type SleepStage = 'inBed' | 'asleep' | 'awake' | 'core' | 'deep' | 'rem' | 'unknown';
|
||||||
|
|
||||||
|
// 睡眠样本数据
|
||||||
|
export interface SleepSample {
|
||||||
|
id: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
value: number;
|
||||||
|
categoryType: SleepStage;
|
||||||
|
duration: number;
|
||||||
|
source: {
|
||||||
|
name: string;
|
||||||
|
bundleIdentifier: string;
|
||||||
|
};
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 睡眠分析结果
|
||||||
|
export interface SleepAnalysisData {
|
||||||
|
sessionStart: string;
|
||||||
|
sessionEnd: string;
|
||||||
|
totalSleepDuration: number; // 秒
|
||||||
|
totalSleepHours: number; // 小时
|
||||||
|
deepSleepDuration: number; // 秒
|
||||||
|
deepSleepHours: number; // 小时
|
||||||
|
deepSleepPercentage: number; // 百分比
|
||||||
|
remSleepDuration: number; // 秒
|
||||||
|
remSleepHours: number; // 小时
|
||||||
|
remSleepPercentage: number; // 百分比
|
||||||
|
coreSleepDuration: number; // 秒
|
||||||
|
coreSleepHours: number; // 小时
|
||||||
|
coreSleepPercentage: number; // 百分比
|
||||||
|
awakeDuration: number; // 秒
|
||||||
|
awakeMinutes: number; // 分钟
|
||||||
|
sleepEfficiency: number; // 百分比
|
||||||
|
sleepScore: number; // 0-100分
|
||||||
|
quality: 'excellent' | 'good' | 'fair' | 'poor' | 'very_poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 睡眠事件数据
|
||||||
|
export interface SleepEventData {
|
||||||
|
timestamp: number;
|
||||||
|
type: 'sleep_data_updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠监控服务类
|
||||||
|
*/
|
||||||
|
class SleepMonitorService {
|
||||||
|
private eventEmitter: NativeEventEmitter | null = null;
|
||||||
|
private eventSubscription: any = null;
|
||||||
|
private isInitialized = false;
|
||||||
|
private lastProcessedTime = 0;
|
||||||
|
private debounceDelay = 3000; // 3秒防抖延迟
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化睡眠监控服务
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<boolean> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
console.log('[SleepMonitor] Already initialized');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建事件发射器
|
||||||
|
this.eventEmitter = new NativeEventEmitter(HealthKitManager);
|
||||||
|
|
||||||
|
// 订阅睡眠更新事件
|
||||||
|
this.eventSubscription = this.eventEmitter.addListener(
|
||||||
|
'sleepUpdate',
|
||||||
|
this.handleSleepUpdate.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 启动睡眠观察者
|
||||||
|
await HealthKitManager.startSleepObserver();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('[SleepMonitor] Initialized successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SleepMonitor] Initialization failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止睡眠监控服务
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 取消事件订阅
|
||||||
|
if (this.eventSubscription) {
|
||||||
|
this.eventSubscription.remove();
|
||||||
|
this.eventSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止睡眠观察者
|
||||||
|
await HealthKitManager.stopSleepObserver();
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('[SleepMonitor] Stopped successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SleepMonitor] Stop failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理睡眠更新事件
|
||||||
|
*/
|
||||||
|
private async handleSleepUpdate(event: SleepEventData): Promise<void> {
|
||||||
|
console.log('[SleepMonitor] Sleep data updated:', event);
|
||||||
|
|
||||||
|
// 防抖处理:避免短时间内重复处理
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastProcessedTime < this.debounceDelay) {
|
||||||
|
console.log('[SleepMonitor] Debouncing, skipping this update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastProcessedTime = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 分析最近的睡眠数据
|
||||||
|
const analysis = await this.analyzeSleepData();
|
||||||
|
|
||||||
|
if (analysis) {
|
||||||
|
logger.info('[SleepMonitor] Sleep analysis completed:', {
|
||||||
|
score: analysis.sleepScore,
|
||||||
|
quality: analysis.quality,
|
||||||
|
duration: `${analysis.totalSleepHours.toFixed(1)}h`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送睡眠分析通知
|
||||||
|
await this.notifySleepAnalysis(analysis);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SleepMonitor] Failed to analyze sleep data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析睡眠数据
|
||||||
|
*/
|
||||||
|
async analyzeSleepData(): Promise<SleepAnalysisData | null> {
|
||||||
|
try {
|
||||||
|
// 获取最近24小时的睡眠数据
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(endDate.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const result = await HealthKitManager.getSleepData({
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data || result.data.length === 0) {
|
||||||
|
console.log('[SleepMonitor] No sleep data found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleepSamples: SleepSample[] = result.data;
|
||||||
|
|
||||||
|
// 找到最近一次完整的睡眠会话
|
||||||
|
const session = this.findLatestCompleteSleepSession(sleepSamples);
|
||||||
|
if (!session) {
|
||||||
|
console.log('[SleepMonitor] No complete sleep session found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析睡眠样本
|
||||||
|
const analysis = this.analyzeSleepSamples(session.samples, session.startDate, session.endDate);
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SleepMonitor] Failed to analyze sleep data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找到最近一次完整的睡眠会话
|
||||||
|
*/
|
||||||
|
private findLatestCompleteSleepSession(samples: SleepSample[]): {
|
||||||
|
samples: SleepSample[];
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
} | null {
|
||||||
|
if (samples.length === 0) return null;
|
||||||
|
|
||||||
|
// 按开始时间排序
|
||||||
|
const sortedSamples = [...samples].sort((a, b) =>
|
||||||
|
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 从最新的样本开始往前查找
|
||||||
|
const reversedSamples = [...sortedSamples].reverse();
|
||||||
|
const sessionSamples: SleepSample[] = [];
|
||||||
|
let sessionEnd: Date | null = null;
|
||||||
|
|
||||||
|
for (const sample of reversedSamples) {
|
||||||
|
// 如果是第一个样本,记录结束时间
|
||||||
|
if (!sessionEnd) {
|
||||||
|
sessionEnd = new Date(sample.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集连续的睡眠样本(时间间隔不超过30分钟)
|
||||||
|
if (sessionSamples.length > 0) {
|
||||||
|
const lastSample = sessionSamples[sessionSamples.length - 1];
|
||||||
|
const gap = new Date(lastSample.startDate).getTime() - new Date(sample.endDate).getTime();
|
||||||
|
if (gap > 30 * 60 * 1000) { // 间隔超过30分钟
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionSamples.push(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反转回正常顺序
|
||||||
|
sessionSamples.reverse();
|
||||||
|
|
||||||
|
if (sessionSamples.length === 0) return null;
|
||||||
|
|
||||||
|
const sessionStart = new Date(sessionSamples[0].startDate);
|
||||||
|
const sessionEndDate = sessionEnd || new Date(sessionSamples[sessionSamples.length - 1].endDate);
|
||||||
|
|
||||||
|
// 确保会话至少有2小时的数据
|
||||||
|
const duration = sessionEndDate.getTime() - sessionStart.getTime();
|
||||||
|
if (duration < 2 * 60 * 60 * 1000) { // 至少2小时
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
samples: sessionSamples,
|
||||||
|
startDate: sessionStart,
|
||||||
|
endDate: sessionEndDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析睡眠样本数据
|
||||||
|
*/
|
||||||
|
private analyzeSleepSamples(
|
||||||
|
samples: SleepSample[],
|
||||||
|
sessionStart: Date,
|
||||||
|
sessionEnd: Date
|
||||||
|
): SleepAnalysisData {
|
||||||
|
// 计算各阶段睡眠时长
|
||||||
|
let totalSleepDuration = 0;
|
||||||
|
let deepSleepDuration = 0;
|
||||||
|
let remSleepDuration = 0;
|
||||||
|
let coreSleepDuration = 0;
|
||||||
|
let awakeDuration = 0;
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const duration = sample.duration;
|
||||||
|
|
||||||
|
switch (sample.categoryType) {
|
||||||
|
case 'deep':
|
||||||
|
deepSleepDuration += duration;
|
||||||
|
totalSleepDuration += duration;
|
||||||
|
break;
|
||||||
|
case 'rem':
|
||||||
|
remSleepDuration += duration;
|
||||||
|
totalSleepDuration += duration;
|
||||||
|
break;
|
||||||
|
case 'core':
|
||||||
|
coreSleepDuration += duration;
|
||||||
|
totalSleepDuration += duration;
|
||||||
|
break;
|
||||||
|
case 'asleep':
|
||||||
|
totalSleepDuration += duration;
|
||||||
|
break;
|
||||||
|
case 'awake':
|
||||||
|
awakeDuration += duration;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算睡眠效率
|
||||||
|
const totalInBedDuration = (sessionEnd.getTime() - sessionStart.getTime()) / 1000;
|
||||||
|
const sleepEfficiency = totalInBedDuration > 0
|
||||||
|
? (totalSleepDuration / totalInBedDuration) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 计算各阶段睡眠占比
|
||||||
|
const deepSleepPercentage = totalSleepDuration > 0
|
||||||
|
? (deepSleepDuration / totalSleepDuration) * 100
|
||||||
|
: 0;
|
||||||
|
const remSleepPercentage = totalSleepDuration > 0
|
||||||
|
? (remSleepDuration / totalSleepDuration) * 100
|
||||||
|
: 0;
|
||||||
|
const coreSleepPercentage = totalSleepDuration > 0
|
||||||
|
? (coreSleepDuration / totalSleepDuration) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 计算睡眠评分
|
||||||
|
const sleepScore = this.calculateSleepScore(
|
||||||
|
totalSleepDuration,
|
||||||
|
deepSleepDuration,
|
||||||
|
remSleepDuration,
|
||||||
|
sleepEfficiency,
|
||||||
|
awakeDuration
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionStart: sessionStart.toISOString(),
|
||||||
|
sessionEnd: sessionEnd.toISOString(),
|
||||||
|
totalSleepDuration,
|
||||||
|
totalSleepHours: totalSleepDuration / 3600,
|
||||||
|
deepSleepDuration,
|
||||||
|
deepSleepHours: deepSleepDuration / 3600,
|
||||||
|
deepSleepPercentage,
|
||||||
|
remSleepDuration,
|
||||||
|
remSleepHours: remSleepDuration / 3600,
|
||||||
|
remSleepPercentage,
|
||||||
|
coreSleepDuration,
|
||||||
|
coreSleepHours: coreSleepDuration / 3600,
|
||||||
|
coreSleepPercentage,
|
||||||
|
awakeDuration,
|
||||||
|
awakeMinutes: awakeDuration / 60,
|
||||||
|
sleepEfficiency,
|
||||||
|
sleepScore,
|
||||||
|
quality: this.getSleepQualityLabel(sleepScore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算睡眠评分(0-100分)
|
||||||
|
*/
|
||||||
|
private calculateSleepScore(
|
||||||
|
totalSleepDuration: number,
|
||||||
|
deepSleepDuration: number,
|
||||||
|
remSleepDuration: number,
|
||||||
|
sleepEfficiency: number,
|
||||||
|
awakeDuration: number
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// 1. 总睡眠时长评分(40分)
|
||||||
|
const sleepHours = totalSleepDuration / 3600;
|
||||||
|
if (sleepHours >= 7 && sleepHours <= 9) {
|
||||||
|
score += 40; // 理想睡眠时长
|
||||||
|
} else if (sleepHours >= 6 && sleepHours < 7) {
|
||||||
|
score += 35; // 稍短
|
||||||
|
} else if (sleepHours >= 9 && sleepHours < 10) {
|
||||||
|
score += 35; // 稍长
|
||||||
|
} else if (sleepHours >= 5 && sleepHours < 6) {
|
||||||
|
score += 25; // 较短
|
||||||
|
} else if (sleepHours >= 10 && sleepHours < 11) {
|
||||||
|
score += 30; // 较长
|
||||||
|
} else if (sleepHours >= 4 && sleepHours < 5) {
|
||||||
|
score += 15; // 很短
|
||||||
|
} else if (sleepHours >= 11) {
|
||||||
|
score += 20; // 过长
|
||||||
|
} else {
|
||||||
|
score += 10; // 极短
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 深度睡眠评分(25分)
|
||||||
|
const deepSleepPercentage = totalSleepDuration > 0
|
||||||
|
? (deepSleepDuration / totalSleepDuration) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (deepSleepPercentage >= 13 && deepSleepPercentage <= 23) {
|
||||||
|
score += 25; // 理想的深度睡眠占比(13-23%)
|
||||||
|
} else if (deepSleepPercentage >= 10 && deepSleepPercentage < 13) {
|
||||||
|
score += 20;
|
||||||
|
} else if (deepSleepPercentage >= 23 && deepSleepPercentage < 30) {
|
||||||
|
score += 20;
|
||||||
|
} else if (deepSleepPercentage >= 7 && deepSleepPercentage < 10) {
|
||||||
|
score += 15;
|
||||||
|
} else if (deepSleepPercentage >= 30) {
|
||||||
|
score += 15;
|
||||||
|
} else {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. REM睡眠评分(20分)
|
||||||
|
const remSleepPercentage = totalSleepDuration > 0
|
||||||
|
? (remSleepDuration / totalSleepDuration) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (remSleepPercentage >= 20 && remSleepPercentage <= 25) {
|
||||||
|
score += 20; // 理想的REM睡眠占比(20-25%)
|
||||||
|
} else if (remSleepPercentage >= 15 && remSleepPercentage < 20) {
|
||||||
|
score += 18;
|
||||||
|
} else if (remSleepPercentage >= 25 && remSleepPercentage < 30) {
|
||||||
|
score += 18;
|
||||||
|
} else if (remSleepPercentage >= 10 && remSleepPercentage < 15) {
|
||||||
|
score += 12;
|
||||||
|
} else if (remSleepPercentage >= 30) {
|
||||||
|
score += 15;
|
||||||
|
} else {
|
||||||
|
score += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 睡眠效率评分(15分)
|
||||||
|
if (sleepEfficiency >= 85) {
|
||||||
|
score += 15; // 优秀的睡眠效率
|
||||||
|
} else if (sleepEfficiency >= 75) {
|
||||||
|
score += 12;
|
||||||
|
} else if (sleepEfficiency >= 65) {
|
||||||
|
score += 8;
|
||||||
|
} else {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.round(score), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据评分获取睡眠质量标签
|
||||||
|
*/
|
||||||
|
private getSleepQualityLabel(score: number): 'excellent' | 'good' | 'fair' | 'poor' | 'very_poor' {
|
||||||
|
if (score >= 90) return 'excellent';
|
||||||
|
if (score >= 75) return 'good';
|
||||||
|
if (score >= 60) return 'fair';
|
||||||
|
if (score >= 40) return 'poor';
|
||||||
|
return 'very_poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知睡眠分析结果
|
||||||
|
*/
|
||||||
|
private async notifySleepAnalysis(analysis: SleepAnalysisData): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 发送睡眠分析通知
|
||||||
|
await analyzeSleepAndSendNotification(analysis);
|
||||||
|
|
||||||
|
logger.info('[SleepMonitor] Sleep analysis notification sent:', {
|
||||||
|
score: analysis.sleepScore,
|
||||||
|
quality: analysis.quality,
|
||||||
|
duration: `${analysis.totalSleepHours.toFixed(1)}h`,
|
||||||
|
efficiency: `${analysis.sleepEfficiency.toFixed(0)}%`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 可以在这里更新 Redux store
|
||||||
|
// store.dispatch(updateSleepAnalysis(analysis));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SleepMonitor] Failed to send sleep notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取睡眠质量描述
|
||||||
|
*/
|
||||||
|
getQualityDescription(quality: string): string {
|
||||||
|
const descriptions = {
|
||||||
|
excellent: '优秀 - 您的睡眠质量非常好!',
|
||||||
|
good: '良好 - 您的睡眠质量不错',
|
||||||
|
fair: '一般 - 您的睡眠质量还可以改善',
|
||||||
|
poor: '较差 - 建议改善睡眠习惯',
|
||||||
|
very_poor: '很差 - 强烈建议关注睡眠健康',
|
||||||
|
};
|
||||||
|
return descriptions[quality as keyof typeof descriptions] || '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取睡眠建议
|
||||||
|
*/
|
||||||
|
getSleepRecommendations(analysis: SleepAnalysisData): string[] {
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
// 基于总睡眠时长的建议
|
||||||
|
if (analysis.totalSleepHours < 7) {
|
||||||
|
recommendations.push('建议增加睡眠时间,成年人每晚需要7-9小时睡眠');
|
||||||
|
} else if (analysis.totalSleepHours > 9) {
|
||||||
|
recommendations.push('睡眠时间偏长,可能影响睡眠质量');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于深度睡眠的建议
|
||||||
|
if (analysis.deepSleepPercentage < 13) {
|
||||||
|
recommendations.push('深度睡眠不足,建议睡前避免使用电子设备');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于REM睡眠的建议
|
||||||
|
if (analysis.remSleepPercentage < 20) {
|
||||||
|
recommendations.push('REM睡眠不足,建议保持规律的作息时间');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于睡眠效率的建议
|
||||||
|
if (analysis.sleepEfficiency < 85) {
|
||||||
|
recommendations.push('睡眠效率较低,建议改善睡眠环境');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于清醒时间的建议
|
||||||
|
if (analysis.awakeMinutes > 30) {
|
||||||
|
recommendations.push('夜间醒来时间较长,建议睡前放松身心');
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const sleepMonitorService = new SleepMonitorService();
|
||||||
191
services/sleepNotificationService.ts
Normal file
191
services/sleepNotificationService.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* 睡眠通知服务
|
||||||
|
*
|
||||||
|
* 负责在睡眠分析完成后发送通知,提供睡眠质量评估和建议
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { SleepAnalysisData } from './sleepMonitor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析睡眠数据并发送通知
|
||||||
|
*/
|
||||||
|
export async function analyzeSleepAndSendNotification(
|
||||||
|
analysis: SleepAnalysisData
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('开始分析睡眠并发送通知:', {
|
||||||
|
score: analysis.sleepScore,
|
||||||
|
quality: analysis.quality,
|
||||||
|
duration: analysis.totalSleepHours,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建通知内容
|
||||||
|
const notification = buildSleepNotification(analysis);
|
||||||
|
|
||||||
|
// 发送通知
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: notification,
|
||||||
|
trigger: null, // 立即发送
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('睡眠分析通知已发送');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('发送睡眠分析通知失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建睡眠通知内容
|
||||||
|
*/
|
||||||
|
function buildSleepNotification(analysis: SleepAnalysisData): Notifications.NotificationContentInput {
|
||||||
|
const { sleepScore, quality, totalSleepHours, sleepEfficiency } = analysis;
|
||||||
|
|
||||||
|
// 根据质量等级选择emoji和标题
|
||||||
|
const qualityConfig = getQualityConfig(quality);
|
||||||
|
|
||||||
|
// 构建通知标题
|
||||||
|
const title = `${qualityConfig.emoji} ${qualityConfig.title}`;
|
||||||
|
|
||||||
|
// 构建通知正文
|
||||||
|
const sleepDuration = formatSleepDuration(totalSleepHours);
|
||||||
|
const efficiencyText = `睡眠效率 ${sleepEfficiency.toFixed(0)}%`;
|
||||||
|
const body = `您昨晚睡了 ${sleepDuration},${efficiencyText}。评分:${sleepScore}分`;
|
||||||
|
|
||||||
|
// 获取建议
|
||||||
|
const suggestion = getSleepSuggestion(analysis);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
body: `${body}\n${suggestion}`,
|
||||||
|
data: {
|
||||||
|
type: 'sleep_analysis',
|
||||||
|
score: sleepScore,
|
||||||
|
quality,
|
||||||
|
analysis: JSON.stringify(analysis),
|
||||||
|
url: '/sleep-detail', // 点击通知跳转到睡眠详情页
|
||||||
|
},
|
||||||
|
sound: 'default',
|
||||||
|
badge: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取质量配置
|
||||||
|
*/
|
||||||
|
function getQualityConfig(quality: string): {
|
||||||
|
emoji: string;
|
||||||
|
title: string;
|
||||||
|
} {
|
||||||
|
const configs = {
|
||||||
|
excellent: {
|
||||||
|
emoji: '😴',
|
||||||
|
title: '睡眠质量优秀',
|
||||||
|
},
|
||||||
|
good: {
|
||||||
|
emoji: '😊',
|
||||||
|
title: '睡眠质量良好',
|
||||||
|
},
|
||||||
|
fair: {
|
||||||
|
emoji: '😐',
|
||||||
|
title: '睡眠质量一般',
|
||||||
|
},
|
||||||
|
poor: {
|
||||||
|
emoji: '😟',
|
||||||
|
title: '睡眠质量较差',
|
||||||
|
},
|
||||||
|
very_poor: {
|
||||||
|
emoji: '😰',
|
||||||
|
title: '睡眠质量很差',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return configs[quality as keyof typeof configs] || {
|
||||||
|
emoji: '💤',
|
||||||
|
title: '睡眠分析完成',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化睡眠时长
|
||||||
|
*/
|
||||||
|
function formatSleepDuration(hours: number): string {
|
||||||
|
const h = Math.floor(hours);
|
||||||
|
const m = Math.round((hours - h) * 60);
|
||||||
|
|
||||||
|
if (m === 0) {
|
||||||
|
return `${h}小时`;
|
||||||
|
}
|
||||||
|
return `${h}小时${m}分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取睡眠建议
|
||||||
|
*/
|
||||||
|
function getSleepSuggestion(analysis: SleepAnalysisData): string {
|
||||||
|
const { quality, totalSleepHours, deepSleepPercentage, remSleepPercentage, sleepEfficiency } = analysis;
|
||||||
|
|
||||||
|
// 优秀或良好的睡眠
|
||||||
|
if (quality === 'excellent' || quality === 'good') {
|
||||||
|
const tips = [
|
||||||
|
'继续保持良好的睡眠习惯!',
|
||||||
|
'坚持规律作息,身体会感谢你!',
|
||||||
|
'优质睡眠让你精力充沛!',
|
||||||
|
];
|
||||||
|
return tips[Math.floor(Math.random() * tips.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据具体问题给出建议
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
|
||||||
|
if (totalSleepHours < 7) {
|
||||||
|
suggestions.push('建议增加睡眠时间至7-9小时');
|
||||||
|
} else if (totalSleepHours > 9) {
|
||||||
|
suggestions.push('睡眠时间偏长,注意睡眠质量');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deepSleepPercentage < 13) {
|
||||||
|
suggestions.push('深度睡眠不足,睡前避免使用电子设备');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remSleepPercentage < 20) {
|
||||||
|
suggestions.push('REM睡眠不足,保持规律的作息时间');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sleepEfficiency < 85) {
|
||||||
|
suggestions.push('睡眠效率较低,改善睡眠环境');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有具体建议,返回第一条;否则返回通用建议
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
return `💡 ${suggestions[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '建议关注睡眠质量,保持良好作息';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送简单的睡眠提醒(用于测试)
|
||||||
|
*/
|
||||||
|
export async function sendSimpleSleepReminder(userName: string = '朋友'): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: '😴 睡眠质量分析',
|
||||||
|
body: `${userName},您的睡眠数据已更新,点击查看详细分析`,
|
||||||
|
data: {
|
||||||
|
type: 'sleep_reminder',
|
||||||
|
url: '/sleep-detail',
|
||||||
|
},
|
||||||
|
sound: 'default',
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('简单睡眠提醒已发送');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('发送简单睡眠提醒失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user