diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index 975fe3a..a058679 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -23,21 +23,56 @@ class HealthKitManager: RCTEventEmitter { /// For reading private struct ReadTypes { - static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)! - static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)! - static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)! - static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)! - static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)! - static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)! - static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)! - static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)! - static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)! - static let activitySummary = HKObjectType.activitySummaryType() - static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! - static let workout = HKObjectType.workoutType() + static var sleep: HKCategoryType? { + return HKObjectType.categoryType(forIdentifier: .sleepAnalysis) + } + static var stepCount: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .stepCount) + } + static var heartRate: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .heartRate) + } + static var heartRateVariability: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) + } + static var activeEnergyBurned: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) + } + static var basalEnergyBurned: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) + } + static var appleExerciseTime: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .appleExerciseTime) + } + static var appleStandTime: HKCategoryType? { + return HKObjectType.categoryType(forIdentifier: .appleStandHour) + } + static var oxygenSaturation: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .oxygenSaturation) + } + static var activitySummary: HKActivitySummaryType { + return HKObjectType.activitySummaryType() + } + static var dietaryWater: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .dietaryWater) + } + static var workout: HKWorkoutType { + return HKObjectType.workoutType() + } static var all: Set { - return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout] + var types: Set = [activitySummary, workout] + if let sleep = sleep { types.insert(sleep) } + if let stepCount = stepCount { types.insert(stepCount) } + if let heartRate = heartRate { types.insert(heartRate) } + if let heartRateVariability = heartRateVariability { types.insert(heartRateVariability) } + if let activeEnergyBurned = activeEnergyBurned { types.insert(activeEnergyBurned) } + if let basalEnergyBurned = basalEnergyBurned { types.insert(basalEnergyBurned) } + if let appleExerciseTime = appleExerciseTime { types.insert(appleExerciseTime) } + if let appleStandTime = appleStandTime { types.insert(appleStandTime) } + if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) } + if let dietaryWater = dietaryWater { types.insert(dietaryWater) } + return types } static var workoutType: HKWorkoutType { @@ -47,11 +82,18 @@ class HealthKitManager: RCTEventEmitter { /// For writing (if needed) private struct WriteTypes { - static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)! - static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! + static var bodyMass: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .bodyMass) + } + static var dietaryWater: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .dietaryWater) + } static var all: Set { - return [bodyMass, dietaryWater] + var types: Set = [] + if let bodyMass = bodyMass { types.insert(bodyMass) } + if let dietaryWater = dietaryWater { types.insert(dietaryWater) } + return types } } @@ -147,15 +189,17 @@ class HealthKitManager: RCTEventEmitter { return } - let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)! - + guard let sleepType = ReadTypes.sleep else { + rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil) + return + } // Parse options let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { startDate = d } else { - startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())! + startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() } let endDate: Date @@ -231,7 +275,10 @@ class HealthKitManager: RCTEventEmitter { return } - let activeEnergyType = ReadTypes.activeEnergyBurned + guard let activeEnergyType = ReadTypes.activeEnergyBurned else { + rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -312,7 +359,10 @@ class HealthKitManager: RCTEventEmitter { return } - let basalEnergyType = ReadTypes.basalEnergyBurned + guard let basalEnergyType = ReadTypes.basalEnergyBurned else { + rejecter("TYPE_NOT_AVAILABLE", "Basal energy type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -373,7 +423,10 @@ class HealthKitManager: RCTEventEmitter { return } - let exerciseType = ReadTypes.appleExerciseTime + guard let exerciseType = ReadTypes.appleExerciseTime else { + rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -454,7 +507,10 @@ class HealthKitManager: RCTEventEmitter { return } - let standType = ReadTypes.appleStandTime + guard let standType = ReadTypes.appleStandTime else { + rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -571,9 +627,10 @@ class HealthKitManager: RCTEventEmitter { return } - let summaryData = summaries.map { summary in - // 获取对应日期的 DateComponents - let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate) + let summaryData = summaries.enumerated().map { (index, summary) in + // 为每个 summary 计算正确的日期 + let summaryDate = calendar.date(byAdding: .day, value: index, to: startDate) ?? startDate + let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: summaryDate) return [ "activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()), @@ -607,7 +664,10 @@ class HealthKitManager: RCTEventEmitter { return } - let hrvType = ReadTypes.heartRateVariability + guard let hrvType = ReadTypes.heartRateVariability else { + rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -696,7 +756,10 @@ class HealthKitManager: RCTEventEmitter { return } - let oxygenType = ReadTypes.oxygenSaturation + guard let oxygenType = ReadTypes.oxygenSaturation else { + rejecter("TYPE_NOT_AVAILABLE", "Oxygen saturation type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -773,7 +836,10 @@ class HealthKitManager: RCTEventEmitter { return } - let heartRateType = ReadTypes.heartRate + guard let heartRateType = ReadTypes.heartRate else { + rejecter("TYPE_NOT_AVAILABLE", "Heart rate type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -850,7 +916,10 @@ class HealthKitManager: RCTEventEmitter { return } - let stepType = ReadTypes.stepCount + guard let stepType = ReadTypes.stepCount else { + rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -911,7 +980,10 @@ class HealthKitManager: RCTEventEmitter { return } - let stepType = ReadTypes.stepCount + guard let stepType = ReadTypes.stepCount else { + rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -1182,7 +1254,10 @@ class HealthKitManager: RCTEventEmitter { return } - let activeEnergyType = ReadTypes.activeEnergyBurned + guard let activeEnergyType = ReadTypes.activeEnergyBurned else { + rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -1268,7 +1343,10 @@ class HealthKitManager: RCTEventEmitter { return } - let exerciseType = ReadTypes.appleExerciseTime + guard let exerciseType = ReadTypes.appleExerciseTime else { + rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -1354,7 +1432,10 @@ class HealthKitManager: RCTEventEmitter { return } - let standType = ReadTypes.appleStandTime + guard let standType = ReadTypes.appleStandTime else { + rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", nil) + return + } let startDate: Date if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { @@ -1399,8 +1480,11 @@ class HealthKitManager: RCTEventEmitter { // 为每个小时创建数据结构 for hour in 0..<24 { - let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))! - let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))! + guard let dayStart = calendar.startOfDay(for: startDate) as Date?, + let hourStart = calendar.date(byAdding: .hour, value: hour, to: dayStart), + let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: dayStart) else { + continue + } // 检查该小时是否有站立记录 let standSamplesInHour = standSamples.filter { sample in @@ -1460,7 +1544,10 @@ class HealthKitManager: RCTEventEmitter { recordedAt = Date() } - let waterType = WriteTypes.dietaryWater + guard let waterType = WriteTypes.dietaryWater else { + rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil) + return + } // Create quantity sample let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount) @@ -1505,7 +1592,10 @@ class HealthKitManager: RCTEventEmitter { return } - let waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)! + guard let waterType = ReadTypes.dietaryWater else { + rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil) + return + } // Parse date range let startDate: Date @@ -1600,7 +1690,7 @@ class HealthKitManager: RCTEventEmitter { startDate = d } else { // 默认获取最近30天的锻炼记录 - startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())! + startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date()) ?? Date() } let endDate: Date @@ -1802,7 +1892,10 @@ func startSleepObserver( } // 创建睡眠数据观察者 - let sleepType = ReadTypes.sleep + guard let sleepType = ReadTypes.sleep else { + rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil) + return + } sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in if let error = error { @@ -1827,7 +1920,11 @@ func startSleepObserver( } // 执行查询 - healthStore.execute(sleepObserverQuery!) + guard let query = sleepObserverQuery else { + rejecter("QUERY_ERROR", "Failed to create sleep observer", nil) + return + } + healthStore.execute(query) resolver(["success": true]) } @@ -1842,9 +1939,11 @@ func stopSleepObserver( sleepObserverQuery = nil // 禁用后台传递 - healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in - if let error = error { - print("Failed to disable background delivery for sleep: \(error.localizedDescription)") + if let sleepType = ReadTypes.sleep { + healthStore.disableBackgroundDelivery(for: sleepType) { (success, error) in + if let error = error { + print("Failed to disable background delivery for sleep: \(error.localizedDescription)") + } } } @@ -1879,7 +1978,11 @@ func startHRVObserver( hrvObserverQuery = nil } - let hrvType = ReadTypes.heartRateVariability + guard let hrvType = ReadTypes.heartRateVariability else { + rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil) + return + } + hrvObserverQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] (_, completionHandler, error) in if let error = error { print("HRV observer error: \(error.localizedDescription)") @@ -1892,17 +1995,21 @@ func startHRVObserver( 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 hrvType = ReadTypes.heartRateVariability { + 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) + guard let query = hrvObserverQuery else { + rejecter("QUERY_ERROR", "Failed to create HRV observer", nil) + return } + healthStore.execute(query) resolver(["success": true]) } @@ -1916,9 +2023,11 @@ func stopHRVObserver( 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)") + if let hrvType = ReadTypes.heartRateVariability { + healthStore.disableBackgroundDelivery(for: hrvType) { success, error in + if let error = error { + print("Failed to disable background delivery for HRV: \(error.localizedDescription)") + } } } } diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 4e6122e..7560b28 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -27,7 +27,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.26 + 1.0.27 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/hrvMonitor.ts b/services/hrvMonitor.ts index 1b63d1a..03f319f 100644 --- a/services/hrvMonitor.ts +++ b/services/hrvMonitor.ts @@ -1,16 +1,15 @@ -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 AsyncStorage from '@/utils/kvStore'; +import { logger } from '@/utils/logger'; import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress'; +import { NativeEventEmitter, NativeModules } from 'react-native'; 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; +const MIN_NOTIFICATION_INTERVAL_HOURS = 2; interface HrvEventData { timestamp: number; @@ -137,19 +136,13 @@ class HRVMonitorService { const elapsed = now - state.lastSentAt; const minIntervalMs = MIN_NOTIFICATION_INTERVAL_HOURS * 60 * 60 * 1000; + // 只检查最小间隔时间(2小时),不再限制每天只能发送一次 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);