feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情 - 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据 - 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息 - 完善锻炼数据处理工具,包含统计分析和格式化功能 - 优化后台任务,随机选择挑战发送鼓励通知 - 版本升级至1.0.16
This commit is contained in:
@@ -77,4 +77,9 @@ RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// Workout Data Methods
|
||||
RCT_EXTERN_METHOD(getRecentWorkouts:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
@@ -37,9 +37,14 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
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 all: Set<HKObjectType> {
|
||||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater]
|
||||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
|
||||
}
|
||||
|
||||
static var workoutType: HKWorkoutType {
|
||||
return HKObjectType.workoutType()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +562,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
|
||||
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents)
|
||||
|
||||
let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] (query, summaries, error) in
|
||||
let query = HKActivitySummaryQuery(predicate: predicate) { (query, summaries, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query activity summary: \(error.localizedDescription)", error)
|
||||
@@ -673,7 +678,7 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
let result: [String: Any] = [
|
||||
"data": hrvData,
|
||||
"count": hrvData.count,
|
||||
"bestQualityValue": bestQualityValue,
|
||||
"bestQualityValue": bestQualityValue ?? NSNull(),
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
@@ -1577,4 +1582,125 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Workout Data Methods
|
||||
|
||||
@objc
|
||||
func getRecentWorkouts(
|
||||
_ 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 workoutType = ReadTypes.workoutType
|
||||
|
||||
// Parse options
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
// 默认获取最近30天的锻炼记录
|
||||
startDate = Calendar.current.date(byAdding: .day, value: -30, to: 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 ?? 10 // 默认返回最近10条记录
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
||||
|
||||
let query = HKSampleQuery(sampleType: ReadTypes.workoutType,
|
||||
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 workouts: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let workoutSamples = samples as? [HKWorkout] else {
|
||||
resolver([
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let workoutData = workoutSamples.map { workout in
|
||||
var workoutDict: [String: Any] = [
|
||||
"id": workout.uuid.uuidString,
|
||||
"startDate": self?.dateToISOString(workout.startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(workout.endDate) ?? "",
|
||||
"duration": workout.duration,
|
||||
"workoutActivityType": workout.workoutActivityType.rawValue,
|
||||
"workoutActivityTypeString": self?.workoutActivityTypeToString(workout.workoutActivityType) ?? "unknown",
|
||||
"source": [
|
||||
"name": workout.sourceRevision.source.name,
|
||||
"bundleIdentifier": workout.sourceRevision.source.bundleIdentifier
|
||||
],
|
||||
"metadata": workout.metadata ?? [:]
|
||||
]
|
||||
|
||||
// 添加能量消耗信息(如果有)
|
||||
if let totalEnergyBurned = workout.totalEnergyBurned {
|
||||
workoutDict["totalEnergyBurned"] = totalEnergyBurned.doubleValue(for: HKUnit.kilocalorie())
|
||||
}
|
||||
|
||||
// 添加距离信息(如果有)
|
||||
if let totalDistance = workout.totalDistance {
|
||||
workoutDict["totalDistance"] = totalDistance.doubleValue(for: HKUnit.meter())
|
||||
}
|
||||
|
||||
// 添加平均心率信息(如果有)
|
||||
if let averageHeartRate = workout.metadata?["HKAverageHeartRate"] as? Double {
|
||||
workoutDict["averageHeartRate"] = averageHeartRate
|
||||
}
|
||||
|
||||
return workoutDict
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"data": workoutData,
|
||||
"count": workoutData.count,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
resolver(result)
|
||||
}
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Workout Helper Methods
|
||||
|
||||
// Normalizes the HealthKit enum case so JS receives a predictable camelCase identifier.
|
||||
private func workoutActivityTypeToString(_ workoutActivityType: HKWorkoutActivityType) -> String {
|
||||
let description = String(describing: workoutActivityType)
|
||||
let prefix = "HKWorkoutActivityType"
|
||||
|
||||
if description.hasPrefix(prefix) {
|
||||
let rawName = description.dropFirst(prefix.count)
|
||||
guard let first = rawName.first else {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
let normalized = String(first).lowercased() + rawName.dropFirst()
|
||||
return normalized
|
||||
}
|
||||
|
||||
return description.lowercased()
|
||||
}
|
||||
|
||||
} // end class
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.15</string>
|
||||
<string>1.0.16</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
Reference in New Issue
Block a user