diff --git a/example/App.tsx b/example/App.tsx index 656963be..f66284f4 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -16,6 +16,8 @@ import RNHealthKit, { Interval, WorkoutActivityType, WorkoutMetadataKey, + WorkoutSessionLocationType, + WorkoutSwimmingLocationType, } from 'react-native-health'; RNHealthKit.initHealthKit( @@ -63,16 +65,45 @@ async function runWorkoutQuery() { const result = await RNHealthKit.getWorkouts({ startDate: new Date(2023, 7, 1).toISOString(), endDate: new Date().toISOString(), - activityTypes: [WorkoutActivityType.Pickleball], + activityTypes: [WorkoutActivityType.SwimBikeRun], }); console.log(result); } async function saveWorkout() { + const conf1 = { + workoutActivityType: WorkoutActivityType.Swimming, + workoutLocationType: WorkoutSessionLocationType.Outdoor, + workoutSwimmingLocationType: WorkoutSwimmingLocationType.OpenWater, + }; + + const conf2 = { + workoutActivityType: WorkoutActivityType.Running, + workoutLocationType: WorkoutSessionLocationType.Outdoor, + workoutSwimmingLocationType: WorkoutSwimmingLocationType.Unknown, + }; + + const activity1 = { + workoutConfiguration: conf1, + startDate: new Date(2023, 8, 8, 4, 0).toISOString(), + endDate: new Date(2023, 8, 8, 4, 6).toISOString(), + metadata: null, + }; + + const activity2 = { + workoutConfiguration: conf2, + startDate: new Date(2023, 8, 8, 4, 10).toISOString(), + endDate: new Date(2023, 8, 8, 4, 15).toISOString(), + metadata: null, + }; + + const activities = [activity1, activity2]; + const result = await RNHealthKit.saveWorkout({ - activityType: WorkoutActivityType.Pickleball, + activityType: WorkoutActivityType.SwimBikeRun, startDate: new Date(2023, 8, 8, 4).toISOString(), endDate: new Date(2023, 8, 8, 5).toISOString(), + activities: activities, metadata: { [WorkoutMetadataKey.IndoorWorkout]: false, [WorkoutMetadataKey.FitnessMachineDuration]: { diff --git a/index.ts b/index.ts index c1a6466a..56be9d5b 100644 --- a/index.ts +++ b/index.ts @@ -32,6 +32,7 @@ interface RNHealthKit { totalEnergyBurned?: number; totalDistance?: number; metadata?: WorkoutMetadata; + activities?: WorkoutActivity[] | null; } ): Promise; } @@ -377,11 +378,38 @@ export enum WorkoutActivityType { SocialDance = 78, // Dances done in social settings like swing, salsa and folk dances from different world regions. Pickleball = 79, Cooldown = 80, // Low intensity stretching and mobility exercises following a more vigorous workout type - SwimBikeRun = 81, - Transition = 82, + SwimBikeRun = 82, + Transition = 83, + UnderwaterDiving = 84, Other = 3000, } +export enum WorkoutSessionLocationType { + Unknown = 1, + Indoor = 2, + Outdoor = 3 +} + +export enum WorkoutSwimmingLocationType { + Unknown = 0, + Pool = 1, + OpenWater = 2 +} + +export interface WorkoutConfiguration { + workoutActivityType?: WorkoutActivityType | null; + workoutLocationType?: WorkoutSessionLocationType | null; + workoutSwimmingLocationType?: WorkoutSwimmingLocationType | null; + workoutLapLength?: QuantityType | null; +} + +export interface WorkoutActivity { + workoutConfiguration: WorkoutConfiguration; + startDate: Date; + endDate?: Date | null; + metadata?: { [key: string]: any } | null; +} + export enum WorkoutMetadataKey { ActivityType = "HKActivityType", AppleFitnessPlusSession = "HKAppleFitnessPlusSession", @@ -413,8 +441,8 @@ export type QuantityType = { } export enum WaterSalinityType { - freshWater = 0, - saltWater = 1, + FreshWater = 0, + SaltWater = 1, } export type WorkoutMetadata = { diff --git a/v2/RNHealthKitCore/HealthKitCore.xcodeproj/project.pbxproj b/v2/RNHealthKitCore/HealthKitCore.xcodeproj/project.pbxproj index 4747819e..068c124a 100644 --- a/v2/RNHealthKitCore/HealthKitCore.xcodeproj/project.pbxproj +++ b/v2/RNHealthKitCore/HealthKitCore.xcodeproj/project.pbxproj @@ -18,6 +18,10 @@ 7E9A84F52A544A1A004622E5 /* QuantityQueriesParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9A84F42A544A1A004622E5 /* QuantityQueriesParameters.swift */; }; 7E9A84F72A548A39004622E5 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9A84F62A548A39004622E5 /* Utils.swift */; }; D31BF2482B10FC7C00005EE7 /* WorkoutHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BF2472B10FC7C00005EE7 /* WorkoutHelper.swift */; }; + D3785B392B16494A00B8C30A /* WorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3785B382B16494A00B8C30A /* WorkoutActivity.swift */; }; + D3785B3D2B16558800B8C30A /* Quantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3785B3C2B16558800B8C30A /* Quantity.swift */; }; + D3785B412B17996E00B8C30A /* WorkoutConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3785B402B17996E00B8C30A /* WorkoutConfiguration.swift */; }; + D3785B432B18FF3000B8C30A /* Statistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3785B422B18FF3000B8C30A /* Statistics.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -48,6 +52,10 @@ 7EB70C892A61ABAF003EE217 /* RNHealthKitWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNHealthKitWrapper.m; sourceTree = ""; }; 7EE4AA502A669CA200CC9EEF /* RNHealthKitWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNHealthKitWrapper.swift; sourceTree = ""; }; D31BF2472B10FC7C00005EE7 /* WorkoutHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutHelper.swift; sourceTree = ""; }; + D3785B382B16494A00B8C30A /* WorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivity.swift; sourceTree = ""; }; + D3785B3C2B16558800B8C30A /* Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quantity.swift; sourceTree = ""; }; + D3785B402B17996E00B8C30A /* WorkoutConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WorkoutConfiguration.swift; sourceTree = ""; }; + D3785B422B18FF3000B8C30A /* Statistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -74,10 +82,14 @@ 7E4BFFE42A9FC0D500FB1383 /* Workout */ = { isa = PBXGroup; children = ( + D3785B402B17996E00B8C30A /* WorkoutConfiguration.swift */, 7E4BFFE92AAA423100FB1383 /* WorkoutSample.swift */, 7E4BFFE52A9FC0ED00FB1383 /* WorkoutQueries.swift */, 7E4BFFE72AAA421200FB1383 /* WorkoutQueryParameters.swift */, D31BF2472B10FC7C00005EE7 /* WorkoutHelper.swift */, + D3785B382B16494A00B8C30A /* WorkoutActivity.swift */, + D3785B3C2B16558800B8C30A /* Quantity.swift */, + D3785B422B18FF3000B8C30A /* Statistics.swift */, ); path = Workout; sourceTree = ""; @@ -183,13 +195,17 @@ 7E9A84F52A544A1A004622E5 /* QuantityQueriesParameters.swift in Sources */, 7E9A84F32A5449C2004622E5 /* HealthKitTypes.swift in Sources */, 7E9A84CC2A5312D7004622E5 /* HealthKitCore.swift in Sources */, + D3785B3D2B16558800B8C30A /* Quantity.swift in Sources */, D31BF2482B10FC7C00005EE7 /* WorkoutHelper.swift in Sources */, + D3785B392B16494A00B8C30A /* WorkoutActivity.swift in Sources */, + D3785B412B17996E00B8C30A /* WorkoutConfiguration.swift in Sources */, 7E5AEBB32A5EAF2800F74829 /* QuantityQueries.swift in Sources */, 7E4BFFEE2AAA6D6D00FB1383 /* QueryParameters.swift in Sources */, 7E4BFFEA2AAA423100FB1383 /* WorkoutSample.swift in Sources */, 7E4BFFE82AAA421200FB1383 /* WorkoutQueryParameters.swift in Sources */, 7E5AEBB12A5EA8EA00F74829 /* QuantitySample.swift in Sources */, 7E4BFFE62A9FC0ED00FB1383 /* WorkoutQueries.swift in Sources */, + D3785B432B18FF3000B8C30A /* Statistics.swift in Sources */, 7E9A84F72A548A39004622E5 /* Utils.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/v2/RNHealthKitCore/HealthKitCore/HealthKitTypes.swift b/v2/RNHealthKitCore/HealthKitCore/HealthKitTypes.swift index 4d628c5f..c3d693e3 100644 --- a/v2/RNHealthKitCore/HealthKitCore/HealthKitTypes.swift +++ b/v2/RNHealthKitCore/HealthKitCore/HealthKitTypes.swift @@ -134,6 +134,22 @@ public enum QuantityType: String, HealthKitType { case HeadphoneAudioExposure // Pressure, DiscreteEquivalentContinuousLevel } +extension QuantityType { + /// Initializes a `QuantityType` with an `HKQuantityType`. + /// + /// - Parameter hkQuantityType: The `HKQuantityType` to convert. + /// - Returns: A corresponding `QuantityType` if a match is found, otherwise `nil`. + public static func from(_ hkQuantityType: HKQuantityType) -> QuantityType? { + let identifier = hkQuantityType.identifier + guard identifier.hasPrefix(hkQuantityTypePrefix) else { return nil } + + let rawValue = String(identifier.dropFirst(hkQuantityTypePrefix.count)) + return QuantityType(rawValue: rawValue) + } +} + +extension QuantityType: Codable {} + public enum WorkoutType: String, HealthKitType { public var type: HKSampleType { return .workoutType() diff --git a/v2/RNHealthKitCore/HealthKitCore/Quantity/QuantityQueriesParameters.swift b/v2/RNHealthKitCore/HealthKitCore/Quantity/QuantityQueriesParameters.swift index ddaeec4f..11bff98d 100644 --- a/v2/RNHealthKitCore/HealthKitCore/Quantity/QuantityQueriesParameters.swift +++ b/v2/RNHealthKitCore/HealthKitCore/Quantity/QuantityQueriesParameters.swift @@ -15,7 +15,14 @@ public class QuantityQuery: QueryParameters { /// - isUserEntered: Specifies whether to include/exclude manually entered data. /// - limit: The maximum number of results to retrieve in a query (default is HKObjectQueryNoLimit). /// - unit: The unit of measurement for the queried health data. - public init(startDate: Date?, endDate: Date?, ids: [String]?, isUserEntered: Bool? = nil, limit: Int = HKObjectQueryNoLimit, unit: HKUnit) { + public init( + startDate: Date?, + endDate: Date?, + ids: [String]?, + isUserEntered: Bool? = nil, + limit: Int = HKObjectQueryNoLimit, + unit: HKUnit + ) { self.unit = unit super.init(startDate: startDate, endDate: endDate, isUserEntered: isUserEntered, limit: limit, ids: ids) } diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/Quantity.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/Quantity.swift new file mode 100644 index 00000000..4b5325b3 --- /dev/null +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/Quantity.swift @@ -0,0 +1,37 @@ +import Foundation +import HealthKit + +public struct Quantity: Codable { + public let unit: HKUnit + public let doubleValue: Double + + private enum CodingKeys: String, CodingKey { + case unit + case doubleValue + } + + public init(unit: HKUnit, doubleValue: Double) { + self.unit = unit + self.doubleValue = doubleValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let unitString = try container.decode(String.self, forKey: .unit) + let doubleValue = try container.decode(Double.self, forKey: .doubleValue) + self.unit = HKUnit(from: unitString) + self.doubleValue = doubleValue + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(unit.unitString, forKey: .unit) + try container.encode(doubleValue, forKey: .doubleValue) + } +} + +extension HKQuantity { + public convenience init(quantity: Quantity) { + self.init(unit: quantity.unit, doubleValue: quantity.doubleValue) + } +} diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/Statistics.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/Statistics.swift new file mode 100644 index 00000000..5f6b459e --- /dev/null +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/Statistics.swift @@ -0,0 +1,44 @@ +import Foundation +import HealthKit + +public struct Statistics: Codable { + public let quantityType: QuantityType + public let startDate: String + public let endDate: String + public let averageQuantity: Quantity? + public let minimumQuantity: Quantity? + public let maximumQuantity: Quantity? + public let mostRecentQuantity: Quantity? + public let sumQuantity: Quantity? + public let duration: Double? + + public init?(from hkStatistics: HKStatistics) { + guard let quantityType = QuantityType.from(hkStatistics.quantityType), + let startDate = hkStatistics.startDate.toIsoString(), + let endDate = hkStatistics.endDate.toIsoString() + else { + return nil + } + self.quantityType = quantityType + + self.startDate = startDate + self.endDate = endDate + self.averageQuantity = Self.parseToQuantity(quantityString: hkStatistics.averageQuantity()?.description) + self.minimumQuantity = Self.parseToQuantity(quantityString: hkStatistics.minimumQuantity()?.description) + self.maximumQuantity = Self.parseToQuantity(quantityString: hkStatistics.maximumQuantity()?.description) + self.mostRecentQuantity = Self.parseToQuantity(quantityString: hkStatistics.mostRecentQuantity()?.description) + self.sumQuantity = Self.parseToQuantity(quantityString: hkStatistics.sumQuantity()?.description) + self.duration = hkStatistics.duration()?.doubleValue(for: .second()) + } + + private static func parseToQuantity(quantityString: String?) -> Quantity? { + guard let quantityParts = quantityString?.components(separatedBy: " "), + quantityParts.count == 2, + let doubleValue = Double(quantityParts[0]) else { + return nil + } + + let unit = HKUnit(from: quantityParts[1]) + return Quantity(unit: unit, doubleValue: doubleValue) + } +} diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutActivity.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutActivity.swift new file mode 100644 index 00000000..81f637f0 --- /dev/null +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutActivity.swift @@ -0,0 +1,69 @@ +import Foundation +import HealthKit + +public struct WorkoutActivity: Codable { + public let workoutConfiguration: WorkoutConfiguration + public let startDate: String + public let endDate: String? + public let metadata: [String: Any]? + + private enum CodingKeys: String, CodingKey { + case workoutConfiguration + case startDate + case endDate + case metadata + } + + public init( + workoutConfiguration: WorkoutConfiguration, + startDate: String, + endDate: String?, + metadata: [String: Any]? = nil + ) { + self.workoutConfiguration = workoutConfiguration + self.startDate = startDate + self.endDate = endDate + self.metadata = metadata + } + + @available(iOS 16.0, *) + public init(activity: HKWorkoutActivity) { + let configuration = activity.workoutConfiguration + self.init( + workoutConfiguration: .init( + workoutActivityType: configuration.activityType, + workoutLocationType: configuration.locationType, + workoutSwimmingLocationType: configuration.swimmingLocationType + ), + startDate: activity.startDate.toIsoString()!, + endDate: activity.endDate?.toIsoString(), + metadata: activity.metadata + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(workoutConfiguration, forKey: .workoutConfiguration) + try container.encode(startDate, forKey: .startDate) + try container.encodeIfPresent(endDate, forKey: .endDate) + + if let metadata { + let jsonData = try JSONSerialization.data(withJSONObject: metadata, options: []) + try container.encode(jsonData, forKey: .metadata) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + workoutConfiguration = try container.decode(WorkoutConfiguration.self, forKey: .workoutConfiguration) + startDate = try container.decode(String.self, forKey: .startDate) + endDate = try container.decodeIfPresent(String.self, forKey: .endDate) + + if let metadataData = try container.decodeIfPresent(Data.self, forKey: .metadata) { + metadata = try JSONSerialization.jsonObject(with: metadataData, options: []) as? [String: Any] + } else { + metadata = nil + } + } +} diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutConfiguration.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutConfiguration.swift new file mode 100644 index 00000000..5fb4e5a6 --- /dev/null +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutConfiguration.swift @@ -0,0 +1,64 @@ +import Foundation +import HealthKit + +public struct WorkoutConfiguration: Codable { + public let workoutActivityType: HKWorkoutActivityType? + public let workoutLocationType: HKWorkoutSessionLocationType? + public let workoutSwimmingLocationType: HKWorkoutSwimmingLocationType? + public let workoutLapLength: Quantity? + + private enum CodingKeys: String, CodingKey { + case workoutActivityType + case workoutLocationType + case workoutSwimmingLocationType + case workoutLapLength + } + + public init( + workoutActivityType: HKWorkoutActivityType? = .other, + workoutLocationType: HKWorkoutSessionLocationType = .unknown, + workoutSwimmingLocationType: HKWorkoutSwimmingLocationType? = nil, + workoutLapLength: Quantity? = nil + ) { + self.workoutActivityType = workoutActivityType + self.workoutLocationType = workoutLocationType + self.workoutSwimmingLocationType = workoutSwimmingLocationType + self.workoutLapLength = workoutLapLength + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(workoutActivityType?.rawValue, forKey: .workoutActivityType) + try container.encodeIfPresent(workoutLocationType?.rawValue, forKey: .workoutLocationType) + try container.encodeIfPresent(workoutSwimmingLocationType?.rawValue, forKey: .workoutSwimmingLocationType) + try container.encodeIfPresent(workoutLapLength, forKey: .workoutLapLength) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let workoutActivityTypeRawValue = try container.decodeIfPresent(Int.self, forKey: .workoutActivityType) + let locationTypeRawValue = try container.decodeIfPresent(Int.self, forKey: .workoutLocationType) + let swimmingLocationTypeRawValue = try container.decodeIfPresent(Int.self, forKey: .workoutSwimmingLocationType) + let lapLength = try container.decodeIfPresent(Quantity.self, forKey: .workoutLapLength) + + if let rawActivityRawValue = workoutActivityTypeRawValue { + self.workoutActivityType = HKWorkoutActivityType(rawValue: UInt(rawActivityRawValue)) + } else { + self.workoutActivityType = nil + } + + if let rawLocationTypeValue = locationTypeRawValue { + self.workoutLocationType = HKWorkoutSessionLocationType(rawValue: rawLocationTypeValue) + } else { + self.workoutLocationType = .unknown + } + + if let rawSwimmingLocationTypeValue = swimmingLocationTypeRawValue { + self.workoutSwimmingLocationType = HKWorkoutSwimmingLocationType(rawValue: rawSwimmingLocationTypeValue) + } else { + self.workoutSwimmingLocationType = nil + } + + self.workoutLapLength = lapLength + } +} diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueries.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueries.swift index f058fb2d..5efd5d49 100644 --- a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueries.swift +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueries.swift @@ -44,6 +44,88 @@ public extension HealthKitCore { totalEnergyBurned: Double?, totalDistance: Double?, metadata: [String: Any]? + ) async throws -> HKWorkout? { + try await handleSaveCompletedWorkout( + activityType: activityType, + startDate: startDate, + endDate: endDate, + totalEnergyBurned: totalEnergyBurned, + totalDistance: totalDistance, + metadata: metadata, + completion: nil + ) + } + + /// Saves a completed workout to HealthKit with specified parameters and additional activities. + /// - Parameters: + /// - activityType: The type of physical activity for the workout. + /// - startDate: The start date and time of the workout. + /// - endDate: The end date and time of the workout. + /// - totalEnergyBurned: The total energy burned during the workout (in kilocalories). + /// - totalDistance: The total distance covered during the workout (in kilometers). + /// - activities: Optional array of `WorkoutActivity` to include in the workout. + /// - metadata: Optional metadata for the workout. + /// - Throws: An error if the workout data cannot be saved to HealthKit. + @available(iOS 16.0, *) + func saveCompletedWorkout( + activityType: HKWorkoutActivityType, + startDate: Date, + endDate: Date, + totalEnergyBurned: Double?, + totalDistance: Double?, + activities: [WorkoutActivity]?, + metadata: [String: Any]? + ) async throws -> HKWorkout? { + let handleActivities: ((HKWorkoutConfiguration, HKWorkoutBuilder) async throws -> Void)? = activities != nil ? { configuration, builder in + if let activities { + for activity in activities { + let workoutConfiguration = activity.workoutConfiguration + if let activityType = workoutConfiguration.workoutActivityType { + configuration.activityType = activityType + } + + if let locationType = workoutConfiguration.workoutLocationType { + configuration.locationType = locationType + } + + if let swimmingLocationType = workoutConfiguration.workoutSwimmingLocationType { + configuration.swimmingLocationType = swimmingLocationType + } + + if let lapLength = workoutConfiguration.workoutLapLength { + configuration.lapLength = HKQuantity(quantity: lapLength) + } + try await builder.addWorkoutActivity( + HKWorkoutActivity( + workoutConfiguration: configuration, + start: activity.startDate.fromIsoStringToDate(), + end: activity.endDate?.fromIsoStringToDate(), + metadata: activity.metadata + ) + ) + } + } + } : nil + + return try await handleSaveCompletedWorkout( + activityType: activityType, + startDate: startDate, + endDate: endDate, + totalEnergyBurned: totalEnergyBurned, + totalDistance: totalDistance, + metadata: metadata, + completion: handleActivities + ) + } + + private func handleSaveCompletedWorkout( + activityType: HKWorkoutActivityType, + startDate: Date, + endDate: Date, + totalEnergyBurned: Double?, + totalDistance: Double?, + metadata: [String: Any]?, + completion: ((HKWorkoutConfiguration, HKWorkoutBuilder) async throws -> Void)? ) async throws -> HKWorkout? { let configuration = HKWorkoutConfiguration() configuration.activityType = activityType @@ -74,6 +156,8 @@ public extension HealthKitCore { samples.append(distanceSample) } + try await completion?(configuration, workoutBuilder) + if let metadata { try await workoutBuilder.addMetadata(metadata) } diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueryParameters.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueryParameters.swift index 87477613..0e37d6b3 100644 --- a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueryParameters.swift +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutQueryParameters.swift @@ -24,7 +24,14 @@ public class WorkoutQueryParameters: QueryParameters { /// - ids: An array of UUIDs to filter data by specific IDs. /// - isUserEntered: Specifies whether to include/exclude manually entered data. /// - limit: The maximum number of results to retrieve in a query. - public init(startDate: Date? = nil, endDate: Date? = nil, activityTypes: [UInt]? = nil, ids: [String]? = nil, isUserEntered: Bool? = nil, limit: Int = HKObjectQueryNoLimit) { + public init( + startDate: Date? = nil, + endDate: Date? = nil, + activityTypes: [UInt]? = nil, + ids: [String]? = nil, + isUserEntered: Bool? = nil, + limit: Int = HKObjectQueryNoLimit + ) { self.activityTypes = activityTypes super.init(startDate: startDate, endDate: endDate, isUserEntered: isUserEntered, limit: limit, ids: ids) } diff --git a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutSample.swift b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutSample.swift index 3399d709..4d17c650 100644 --- a/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutSample.swift +++ b/v2/RNHealthKitCore/HealthKitCore/Workout/WorkoutSample.swift @@ -18,12 +18,14 @@ public struct WorkoutSample: Encodable { /// The activity type of the workout as a raw unsigned integer value. public let activityType: UInt + // Represents additional activities that are part of the workout. + public let workoutActivities: [WorkoutActivity]? + /// Additional metadata associated with the workout sample. public let metadata: [String: String]? - // TODO: Add activities | var workoutActivities: [HKWorkoutActivity] - // TODO: Add workout events | var workoutEvents: [HKWorkoutEvent]? - // TODO: Add statistics public var statistics: [QuantityType : QuantitySample] + /// Represents statistical data associated with the workout. + public let statistics: [Statistics]? /// Initializes a `WorkoutSample` object based on an `HKWorkout` instance. /// @@ -34,6 +36,19 @@ public struct WorkoutSample: Encodable { self.endDate = workout.endDate.toIsoString() ?? "" self.duration = workout.duration self.activityType = workout.workoutActivityType.rawValue + + if #available(iOS 16.0, *) { + self.workoutActivities = workout.workoutActivities.compactMap({ WorkoutActivity(activity: $0) }) + self.statistics = workout.allStatistics.compactMap { key, value -> Statistics? in + guard let statistics = Statistics(from: value) else { + return nil + } + return statistics + } + } else { + self.workoutActivities = nil + self.statistics = nil + } self.metadata = workout.metadata?.mapValues(String.init(describing:)) ?? [:] } } diff --git a/v2/RNHealthKitCore/ReactNativeBridge/RNHealthKitWrapper.swift b/v2/RNHealthKitCore/ReactNativeBridge/RNHealthKitWrapper.swift index 9822160a..eec60bd7 100644 --- a/v2/RNHealthKitCore/ReactNativeBridge/RNHealthKitWrapper.swift +++ b/v2/RNHealthKitCore/ReactNativeBridge/RNHealthKitWrapper.swift @@ -224,14 +224,33 @@ class RNHealthKitWrapper: NSObject { processedMetadata = try WorkoutHelper.processWorkoutMetadata(metadata) } - try await core?.saveCompletedWorkout( - activityType: activityType, - startDate: startDate, - endDate: endDate, - totalEnergyBurned: workout["totalEnergyBurned"] as? Double, - totalDistance: workout["totalDistance"] as? Double, - metadata: processedMetadata - ) + var workoutActivities: [WorkoutActivity]? + if let activities = workout["activities"] as? [String: Any] { + let activityData = try JSONSerialization.data(withJSONObject: activities, options: []) + workoutActivities = try JSONDecoder().decode([WorkoutActivity].self, from: activityData) + } + + if #available(iOS 16.0, *) { + try await core?.saveCompletedWorkout( + activityType: activityType, + startDate: startDate, + endDate: endDate, + totalEnergyBurned: workout["totalEnergyBurned"] as? Double, + totalDistance: workout["totalDistance"] as? Double, + activities: workoutActivities, + metadata: processedMetadata + ) + } else { + try await core?.saveCompletedWorkout( + activityType: activityType, + startDate: startDate, + endDate: endDate, + totalEnergyBurned: workout["totalEnergyBurned"] as? Double, + totalDistance: workout["totalDistance"] as? Double, + metadata: processedMetadata + ) + } + resolve(true) } catch { reject("saveWorkout", error.localizedDescription, error) diff --git a/v2/RNHealthKitCoreApp/RNHealthKitCoreApp/ContentView.swift b/v2/RNHealthKitCoreApp/RNHealthKitCoreApp/ContentView.swift index 48a8daa5..4e7f5754 100644 --- a/v2/RNHealthKitCoreApp/RNHealthKitCoreApp/ContentView.swift +++ b/v2/RNHealthKitCoreApp/RNHealthKitCoreApp/ContentView.swift @@ -3,12 +3,11 @@ import HealthKitCore import HealthKit struct ContentView: View { + @State private var workoutDetails: String = "" + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") + ScrollView { + Text(workoutDetails) } .padding() .onAppear { @@ -19,8 +18,8 @@ struct ContentView: View { write: [WorkoutType.workout] ) if HKHealthStore.isHealthDataAvailable() { -// await saveWorkoutWithMetadata(core: core) - dump(try await core.getCompletedWorkouts(queryParameters: .init())) +// try await saveWorkoutWithMetadata(core: core) + workoutDetails = (try await core.getCompletedWorkouts(queryParameters: .init(startDate: Calendar.current.date(byAdding: .day, value: -15, to: .now)!, endDate: .now))).debugDescription } } catch { print("Error occurred: \(error)") @@ -30,23 +29,54 @@ struct ContentView: View { } } - func saveWorkoutWithMetadata(core: HealthKitCore) async { - let unit = HKUnit(from: "min") - let rawMetadata: [String: Any] = [ - "HKIndoorWorkout": true, - "HKFitnessMachineDuration": ["unit": "min", "doubleValue": 30.0], - ] - - let processedMetadata = try! WorkoutHelper.processWorkoutMetadata(rawMetadata) - - dump(try! await core.saveCompletedWorkout( - activityType: .coreTraining, - startDate: Calendar(identifier: .gregorian).date(byAdding: .minute, value: -2, to: .now)!, - endDate: Date(), - totalEnergyBurned: 120, - totalDistance: 2000, - metadata: processedMetadata - )) + private func saveWorkoutWithMetadata(core: HealthKitCore) async throws { + let startDate = Calendar(identifier: .gregorian).date(byAdding: .minute, value: -15, to: .now)! + let activityStartTime = Calendar(identifier: .gregorian).date(byAdding: .minute, value: -14, to: .now)! + + let conf1 = WorkoutConfiguration( + workoutActivityType: .swimming, + workoutLocationType: .outdoor, + workoutSwimmingLocationType: .openWater + ) + let conf2 = WorkoutConfiguration( + workoutActivityType: .running, + workoutLocationType: .outdoor, + workoutSwimmingLocationType: .unknown + ) + + let activity1 = WorkoutActivity( + workoutConfiguration: conf1, + startDate: activityStartTime.toIsoString()!, + endDate: Calendar(identifier: .gregorian).date(byAdding: .minute, value: -10, to: .now)!.toIsoString(), + metadata: nil + ) + let activity2 = WorkoutActivity( + workoutConfiguration: conf2, + startDate: Calendar(identifier: .gregorian).date(byAdding: .minute, value: -5, to: .now)!.toIsoString()!, + endDate: Calendar(identifier: .gregorian).date(byAdding: .minute, value: -1, to: .now)!.toIsoString(), + metadata: nil + ) + + if #available(iOS 16.0, *) { + print(try await core.saveCompletedWorkout( + activityType: .swimBikeRun, + startDate: startDate, endDate: Date(), + totalEnergyBurned: 120, + totalDistance: 2000, + activities: [activity1, activity2], + metadata: [ + "Testing": "1235" + ] + )) + } else { + print(try await core.saveCompletedWorkout( + activityType: .running, + startDate: startDate, endDate: Date(), + totalEnergyBurned: 120, + totalDistance: 2000, + metadata: nil + )) + } } } @@ -55,3 +85,11 @@ struct ContentView_Previews: PreviewProvider { ContentView() } } + +private extension Date { + func toIsoString() -> String? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + return formatter.string(from: self) + } +}