feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
@@ -68,6 +68,16 @@ class HealthKitManager: RCTEventEmitter {
|
||||
static var dateOfBirth: HKCharacteristicType {
|
||||
return HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!
|
||||
}
|
||||
static var menstrualFlow: HKCategoryType? {
|
||||
return HKObjectType.categoryType(forIdentifier: .menstrualFlow)
|
||||
}
|
||||
static var appleSleepingWristTemperature: HKQuantityType? {
|
||||
if #available(iOS 16.0, *) {
|
||||
return HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static var all: Set<HKObjectType> {
|
||||
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
||||
@@ -83,6 +93,8 @@ class HealthKitManager: RCTEventEmitter {
|
||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||
if let height = height { types.insert(height) }
|
||||
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
||||
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
||||
if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) }
|
||||
return types
|
||||
}
|
||||
|
||||
@@ -111,6 +123,9 @@ class HealthKitManager: RCTEventEmitter {
|
||||
static var dietaryCarbohydrates: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
|
||||
}
|
||||
static var menstrualFlow: HKCategoryType? {
|
||||
return HKObjectType.categoryType(forIdentifier: .menstrualFlow)
|
||||
}
|
||||
|
||||
static var all: Set<HKSampleType> {
|
||||
var types: Set<HKSampleType> = []
|
||||
@@ -120,6 +135,7 @@ class HealthKitManager: RCTEventEmitter {
|
||||
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
|
||||
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
|
||||
if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) }
|
||||
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
||||
return types
|
||||
}
|
||||
}
|
||||
@@ -852,6 +868,86 @@ class HealthKitManager: RCTEventEmitter {
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
@objc
|
||||
func getWristTemperatureSamples(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let tempType = ReadTypes.appleSleepingWristTemperature else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Wrist temperature type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
startDate = Calendar.current.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||
endDate = d
|
||||
} else {
|
||||
endDate = Date()
|
||||
}
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||||
|
||||
let query = HKSampleQuery(sampleType: tempType,
|
||||
predicate: predicate,
|
||||
limit: limit,
|
||||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query wrist temperature: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let tempSamples = samples as? [HKQuantitySample] else {
|
||||
resolver([
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let tempData = tempSamples.map { sample in
|
||||
[
|
||||
"id": sample.uuid.uuidString,
|
||||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||||
"value": sample.quantity.doubleValue(for: HKUnit.degreeCelsius()),
|
||||
"source": [
|
||||
"name": sample.sourceRevision.source.name,
|
||||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||||
],
|
||||
"metadata": sample.metadata ?? [:]
|
||||
] as [String : Any]
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"data": tempData,
|
||||
"count": tempData.count,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
resolver(result)
|
||||
}
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
@objc
|
||||
func getHeartRateSamples(
|
||||
_ options: NSDictionary,
|
||||
@@ -2548,6 +2644,210 @@ func saveWeight(
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menstrual Cycle Methods
|
||||
|
||||
@objc
|
||||
func getMenstrualFlowSamples(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let menstrualType = ReadTypes.menstrualFlow else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
startDate = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||
endDate = d
|
||||
} else {
|
||||
endDate = Date()
|
||||
}
|
||||
|
||||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||
|
||||
let query = HKSampleQuery(sampleType: menstrualType,
|
||||
predicate: predicate,
|
||||
limit: limit,
|
||||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query menstrual flow: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let flowSamples = samples as? [HKCategorySample] else {
|
||||
resolver([
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let flowData = flowSamples.map { sample in
|
||||
[
|
||||
"id": sample.uuid.uuidString,
|
||||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||||
"value": sample.value,
|
||||
"isStart": sample.metadata?[HKMetadataKeyMenstrualCycleStart] as? Bool ?? false,
|
||||
"source": [
|
||||
"name": sample.sourceRevision.source.name,
|
||||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||||
],
|
||||
"metadata": sample.metadata ?? [:]
|
||||
] as [String : Any]
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"data": flowData,
|
||||
"count": flowData.count,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
resolver(result)
|
||||
}
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
@objc
|
||||
func saveMenstrualFlow(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let date: Date
|
||||
if let dateString = options["date"] as? String, let d = parseDate(from: dateString) {
|
||||
date = d
|
||||
} else {
|
||||
rejecter("INVALID_PARAMETERS", "Date is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to unspecified (1) if not provided.
|
||||
// HKCategoryValueMenstrualFlow: unspecified=1, light=2, medium=3, heavy=4, none=5
|
||||
let value = options["value"] as? Int ?? HKCategoryValueMenstrualFlow.unspecified.rawValue
|
||||
let isStart = options["isStart"] as? Bool ?? false
|
||||
|
||||
guard let menstrualType = WriteTypes.menstrualFlow else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize date to start of day and end of day for the sample
|
||||
let calendar = Calendar.current
|
||||
let startOfDay = calendar.startOfDay(for: date)
|
||||
// HealthKit docs suggest menstrual samples should represent the day.
|
||||
// Often recorded as start of day to next day or specific time.
|
||||
// Standard practice for cycle tracking is usually per-day samples.
|
||||
guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else {
|
||||
rejecter("DATE_ERROR", "Failed to calculate end of day", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var metadata: [String: Any] = [:]
|
||||
// HKMetadataKeyMenstrualCycleStart is REQUIRED for HKCategoryTypeIdentifierMenstrualFlow
|
||||
// It indicates whether this sample represents the start of a menstrual cycle.
|
||||
metadata[HKMetadataKeyMenstrualCycleStart] = isStart
|
||||
metadata[HKMetadataKeyWasUserEntered] = true
|
||||
|
||||
let sample = HKCategorySample(
|
||||
type: menstrualType,
|
||||
value: value,
|
||||
start: startOfDay,
|
||||
end: endOfDay, // Using full day duration
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
healthStore.save(sample) { [weak self] (success, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("SAVE_ERROR", "Failed to save menstrual flow: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
resolver(["success": true])
|
||||
} else {
|
||||
rejecter("SAVE_FAILED", "Failed to save menstrual flow", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func deleteMenstrualFlow(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
rejecter("INVALID_PARAMETERS", "Start date is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||
endDate = d
|
||||
} else {
|
||||
rejecter("INVALID_PARAMETERS", "End date is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let menstrualType = WriteTypes.menstrualFlow else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
|
||||
healthStore.deleteObjects(of: menstrualType, predicate: predicate) { (success, count, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("DELETE_ERROR", "Failed to delete menstrual flow: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
resolver(["success": true, "count": count])
|
||||
} else {
|
||||
rejecter("DELETE_FAILED", "Failed to delete menstrual flow", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RCTEventEmitter Overrides
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
|
||||
Reference in New Issue
Block a user