feat(health): 重构营养卡片数据获取逻辑,支持基础代谢与运动消耗分离

- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据
- NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新
- BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略
- StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞
- HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit
- 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
This commit is contained in:
richarjiang
2025-09-23 10:01:50 +08:00
parent d082c66b72
commit e6dfd4d59a
11 changed files with 1115 additions and 203 deletions

View File

@@ -68,4 +68,13 @@ RCT_EXTERN_METHOD(getHourlyStandHours:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Water Intake Methods
RCT_EXTERN_METHOD(saveWaterIntakeToHealthKit:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

View File

@@ -36,18 +36,20 @@ class HealthKitManager: NSObject, RCTBridgeModule {
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 var all: Set<HKObjectType> {
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary]
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater]
}
}
/// For writing (if needed)
private struct WriteTypes {
static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)!
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
static var all: Set<HKSampleType> {
return [bodyMass]
return [bodyMass, dietaryWater]
}
}
@@ -1333,4 +1335,150 @@ class HealthKitManager: NSObject, RCTBridgeModule {
healthStore.execute(query)
}
// MARK: - Water Intake Methods
@objc
func saveWaterIntakeToHealthKit(
_ 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
}
// Parse parameters
guard let amount = options["amount"] as? Double else {
rejecter("INVALID_PARAMETERS", "Amount is required", nil)
return
}
let recordedAt: Date
if let recordedAtString = options["recordedAt"] as? String,
let date = parseDate(from: recordedAtString) {
recordedAt = date
} else {
recordedAt = Date()
}
let waterType = WriteTypes.dietaryWater
// Create quantity sample
let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount)
let sample = HKQuantitySample(
type: waterType,
quantity: quantity,
start: recordedAt,
end: recordedAt,
metadata: nil
)
// Save to HealthKit
healthStore.save(sample) { [weak self] (success, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("SAVE_ERROR", "Failed to save water intake: \(error.localizedDescription)", error)
return
}
if success {
let result: [String: Any] = [
"success": true,
"amount": amount,
"recordedAt": self?.dateToISOString(recordedAt) ?? ""
]
resolver(result)
} else {
rejecter("SAVE_FAILED", "Failed to save water intake", nil)
}
}
}
}
@objc
func getWaterIntakeFromHealthKit(
_ 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 waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
// Parse date range
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 limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: waterType,
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 water intake: \(error.localizedDescription)", error)
return
}
guard let waterSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"totalAmount": 0,
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let waterData = waterSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli)),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let totalAmount = waterSamples.reduce(0.0) { total, sample in
return total + sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli))
}
let result: [String: Any] = [
"data": waterData,
"totalAmount": totalAmount,
"count": waterData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
} // end class