feat(health): 新增手腕温度监测和经期双向同步功能

新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
richarjiang
2025-12-18 08:40:08 +08:00
parent 9b4a300380
commit 4836058d56
31 changed files with 2249 additions and 539 deletions

View File

@@ -43,6 +43,10 @@ RCT_EXTERN_METHOD(getOxygenSaturationSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getWristTemperatureSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@@ -135,4 +139,17 @@ RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Menstrual Cycle Methods
RCT_EXTERN_METHOD(getMenstrualFlowSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(saveMenstrualFlow:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(deleteMenstrualFlow:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

View File

@@ -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]! {