diff --git a/EventMetadata.proto b/EventMetadata.proto new file mode 100644 index 0000000..dd225fb --- /dev/null +++ b/EventMetadata.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +// Wrapper for workout event metadata +message WorkoutEventMetadata { + + // All metadata elements + repeated Element elements = 1; + + message Element { + + string key = 1; + + optional uint64 unsignedValue = 4; + + Quantity quantity = 6; + + message Quantity { + + double value = 1; + + string unit = 2; + } + } +} diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index dceb555..0097159 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */; }; E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */; }; E2FDFF162B6AFD990080A7B3 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF152B6AFD990080A7B3 /* BinaryCodable */; }; + E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; }; + E2FDFF222B6BE35B0080A7B3 /* EventMetadata.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -108,6 +110,7 @@ E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = ""; }; E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = ""; }; E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; + E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMetadata.pb.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,6 +123,7 @@ 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */, 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */, 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */, + E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -198,6 +202,7 @@ 885002812B5C37B700E7D4DB /* Model */ = { isa = PBXGroup; children = ( + E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */, E201EC802B631708005B83D3 /* Goal.swift */, E27BC6792B5D99AC003A8873 /* LocationSample.swift */, E201EC7A2B6275CA005B83D3 /* Metadata.swift */, @@ -235,6 +240,16 @@ path = Support; sourceTree = ""; }; + E2FDFF1D2B6BD1F00080A7B3 /* API */ = { + isa = PBXGroup; + children = ( + E2FDFF172B6BB61D0080A7B3 /* HKHealthStoreInterface.swift */, + E2FDFF192B6BB6A40080A7B3 /* HKHealthStore+Interface.swift */, + E2FDFF1B2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift */, + ); + path = API; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -257,6 +272,7 @@ 885002A72B5D296700E7D4DB /* DequeModule */, 885002A92B5D296700E7D4DB /* OrderedCollections */, E2FDFF152B6AFD990080A7B3 /* BinaryCodable */, + E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */, ); productName = HealthImport; productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */; @@ -290,6 +306,7 @@ 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */, 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */, E2FDFF142B6AFD990080A7B3 /* XCRemoteSwiftPackageReference "BinaryCodable" */, + E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */, ); productRefGroup = 885002582B5C273C00E7D4DB /* Products */; projectDirPath = ""; @@ -336,6 +353,7 @@ E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */, E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */, + E2FDFF222B6BE35B0080A7B3 /* EventMetadata.pb.swift in Sources */, 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */, E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, @@ -591,6 +609,14 @@ minimumVersion = 2.0.3; }; }; + E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-protobuf.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.25.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -619,6 +645,11 @@ package = E2FDFF142B6AFD990080A7B3 /* XCRemoteSwiftPackageReference "BinaryCodable" */; productName = BinaryCodable; }; + E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */ = { + isa = XCSwiftPackageProductDependency; + package = E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */; + productName = SwiftProtobuf; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 8850024F2B5C273C00E7D4DB /* Project object */; diff --git a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c602876..c0f89b5 100644 --- a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -26,6 +26,15 @@ "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", "version" : "1.0.6" } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "65e8f29b2d63c4e38e736b25c27b83e012159be8", + "version" : "1.25.2" + } } ], "version" : 2 diff --git a/HealthImport/EventDetailView.swift b/HealthImport/EventDetailView.swift index 573da07..f4b968a 100644 --- a/HealthImport/EventDetailView.swift +++ b/HealthImport/EventDetailView.swift @@ -1,20 +1,21 @@ import SwiftUI +import HealthKit struct EventDetailView: View { - let event: WorkoutEvent + let event: HKWorkoutEvent var metadata: [(key: String, value: Any)] { - event.metadata.sorted { $0.key < $1.key } + event.metadata?.sorted { $0.key < $1.key } ?? [] } var body: some View { List { - DetailRow("Date", date: event.date) + DetailRow("Date", date: event.dateInterval.start) DetailRow("Type", value: event.type) - DetailRow("Duration", duration: event.duration) - DetailRow("Session UUID", value: event.sessionUUID) - DetailRow("Error", value: event.error) + DetailRow("Duration", duration: event.dateInterval.duration) + //DetailRow("Session UUID", value: event.sessionUUID) + //DetailRow("Error", value: event.error) Section("Metadata") { ForEach(metadata, id: \.key) { (key, value) in DetailRow(key, value: "\(value)") diff --git a/HealthImport/Model/EventMetadata.pb.swift b/HealthImport/Model/EventMetadata.pb.swift new file mode 100644 index 0000000..0433058 --- /dev/null +++ b/HealthImport/Model/EventMetadata.pb.swift @@ -0,0 +1,208 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: EventMetadata.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +/// Wrapper for workout event metadata +struct WorkoutEventMetadata { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// All metadata elements + var elements: [WorkoutEventMetadata.Element] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + struct Element { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var key: String = String() + + var unsignedValue: UInt64 { + get {return _unsignedValue ?? 0} + set {_unsignedValue = newValue} + } + /// Returns true if `unsignedValue` has been explicitly set. + var hasUnsignedValue: Bool {return self._unsignedValue != nil} + /// Clears the value of `unsignedValue`. Subsequent reads from it will return its default value. + mutating func clearUnsignedValue() {self._unsignedValue = nil} + + var quantity: WorkoutEventMetadata.Element.Quantity { + get {return _quantity ?? WorkoutEventMetadata.Element.Quantity()} + set {_quantity = newValue} + } + /// Returns true if `quantity` has been explicitly set. + var hasQuantity: Bool {return self._quantity != nil} + /// Clears the value of `quantity`. Subsequent reads from it will return its default value. + mutating func clearQuantity() {self._quantity = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + struct Quantity { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var value: Double = 0 + + var unit: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + } + + init() {} + + fileprivate var _unsignedValue: UInt64? = nil + fileprivate var _quantity: WorkoutEventMetadata.Element.Quantity? = nil + } + + init() {} +} + +#if swift(>=5.5) && canImport(_Concurrency) +extension WorkoutEventMetadata: @unchecked Sendable {} +extension WorkoutEventMetadata.Element: @unchecked Sendable {} +extension WorkoutEventMetadata.Element.Quantity: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension WorkoutEventMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = "WorkoutEventMetadata" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "elements"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.elements) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.elements.isEmpty { + try visitor.visitRepeatedMessageField(value: self.elements, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: WorkoutEventMetadata, rhs: WorkoutEventMetadata) -> Bool { + if lhs.elements != rhs.elements {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension WorkoutEventMetadata.Element: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = WorkoutEventMetadata.protoMessageName + ".Element" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "key"), + 4: .same(proto: "unsignedValue"), + 6: .same(proto: "quantity"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.key) }() + case 4: try { try decoder.decodeSingularUInt64Field(value: &self._unsignedValue) }() + case 6: try { try decoder.decodeSingularMessageField(value: &self._quantity) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.key.isEmpty { + try visitor.visitSingularStringField(value: self.key, fieldNumber: 1) + } + try { if let v = self._unsignedValue { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 4) + } }() + try { if let v = self._quantity { + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: WorkoutEventMetadata.Element, rhs: WorkoutEventMetadata.Element) -> Bool { + if lhs.key != rhs.key {return false} + if lhs._unsignedValue != rhs._unsignedValue {return false} + if lhs._quantity != rhs._quantity {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension WorkoutEventMetadata.Element.Quantity: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = WorkoutEventMetadata.Element.protoMessageName + ".Quantity" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "value"), + 2: .same(proto: "unit"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularDoubleField(value: &self.value) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.unit) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.value != 0 { + try visitor.visitSingularDoubleField(value: self.value, fieldNumber: 1) + } + if !self.unit.isEmpty { + try visitor.visitSingularStringField(value: self.unit, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: WorkoutEventMetadata.Element.Quantity, rhs: WorkoutEventMetadata.Element.Quantity) -> Bool { + if lhs.value != rhs.value {return false} + if lhs.unit != rhs.unit {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/HealthImport/Model/Workout+SQLite.swift b/HealthImport/Model/Workout+SQLite.swift index 5e7d5b7..5bcb517 100644 --- a/HealthImport/Model/Workout+SQLite.swift +++ b/HealthImport/Model/Workout+SQLite.swift @@ -1,5 +1,6 @@ import Foundation import SQLite +import HealthKit extension Workout { @@ -29,7 +30,7 @@ extension Workout { return try database.prepare(table).map { row in let id = row[columnDataId] - let events = try WorkoutEvent.events(for: id, in: database) + let events = try HKWorkoutEventTable.events(for: id, in: database) let activities = try WorkoutActivity.activities(for: id, in: database) let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys) return .init( diff --git a/HealthImport/Model/Workout.swift b/HealthImport/Model/Workout.swift index d679076..ddd694b 100644 --- a/HealthImport/Model/Workout.swift +++ b/HealthImport/Model/Workout.swift @@ -1,5 +1,6 @@ import Foundation import Collections +import HealthKit private let df: DateFormatter = { let df = DateFormatter() @@ -22,8 +23,8 @@ struct Workout { let condenserDate: Date? - let events: [WorkoutEvent] - + let events: [HKWorkoutEvent] + let activities: [WorkoutActivity] let metadata: OrderedDictionary @@ -33,7 +34,7 @@ struct Workout { } var firstEventDate: Date? { - events.map { $0.date }.min() + events.map { $0.dateInterval.start }.min() } var firstAvailableDate: Date? { @@ -51,7 +52,7 @@ struct Workout { activities.first?.activityType.description ?? "Unknown activity" } - init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [Metadata.Key : Metadata.Value] = [:]) { + init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [HKWorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [Metadata.Key : Metadata.Value] = [:]) { self.id = id self.totalDistance = totalDistance self.goal = .init(goalType: goalType, goal: goal) diff --git a/HealthImport/Model/WorkoutEvent+SQLite.swift b/HealthImport/Model/WorkoutEvent+SQLite.swift index ab765ec..e918c5e 100644 --- a/HealthImport/Model/WorkoutEvent+SQLite.swift +++ b/HealthImport/Model/WorkoutEvent+SQLite.swift @@ -1,7 +1,8 @@ import Foundation import SQLite +import HealthKit -extension WorkoutEvent { +enum HKWorkoutEventTable { private static let table = Table("workout_events") @@ -29,22 +30,22 @@ extension WorkoutEvent { // error BLOB private static let columnError = Expression("error") - static func readAll(in database: Connection) throws -> [Self] { - try database.prepare(table).map(from) + static func readAll(in database: Connection) throws -> [HKWorkoutEvent] { + try database.prepare(table).map(event) } - static func events(for workoutId: Int, in database: Connection) throws -> [Self] { - try database.prepare(table.filter(columnOwnerId == workoutId)).map(from) + static func events(for workoutId: Int, in database: Connection) throws -> [HKWorkoutEvent] { + try database.prepare(table.filter(columnOwnerId == workoutId)).map(event) } - private static func from(row: Row) -> WorkoutEvent { - .init( - date: Date(timeIntervalSinceReferenceDate: row[columnDate]), - type: .init(rawValue: row[columnType])!, - duration: row[columnDuration], - metadata: metadata(row[columnMetadata]), - sessionUUID: row[columnSessionUUID], - error: row[columnError]) + private static func event(from row: Row) -> HKWorkoutEvent { + let start = Date(timeIntervalSinceReferenceDate: row[columnDate]) + let interval = DateInterval(start: start, duration: row[columnDuration]) + let metadata = metadata(row[columnMetadata]) + let type = HKWorkoutEventType(rawValue: row[columnType])! + // let sessionUUID = row[columnSessionUUID] + // let error = row[columnError] + return .init(type: type, dateInterval: interval, metadata: metadata) } private static func metadata(_ data: Data?) -> [String : Any] { @@ -54,7 +55,7 @@ extension WorkoutEvent { return decode(metadata: data) } - static func createTable(in database: Database) throws { + static func create(in database: Database) throws { // try database.execute("CREATE TABLE workout_events (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE, date REAL NOT NULL, type INTEGER NOT NULL, duration REAL NOT NULL, metadata BLOB, session_uuid BLOB, error BLOB)") try database.run(table.create { t in t.column(columnRowId, primaryKey: .autoincrement) @@ -68,19 +69,22 @@ extension WorkoutEvent { }) } - func insert(in database: Database, dataId: Int) throws { - try WorkoutEvent.insert(self, dataId: dataId, in: database) - } - - private static func insert(_ element: WorkoutEvent, dataId: Int, in database: Database) throws { + static func insert(_ element: HKWorkoutEvent, dataId: Int, in database: Database) throws { try database.run(table.insert( columnOwnerId <- dataId, - columnDate <- element.date.timeIntervalSinceReferenceDate, + columnDate <- element.dateInterval.start.timeIntervalSinceReferenceDate, columnType <- element.type.rawValue, - columnDuration <- element.duration, - columnMetadata <- encode(metadata: element.metadata), - columnSessionUUID <- element.sessionUUID, - columnError <- element.error) + columnDuration <- element.dateInterval.duration, + columnMetadata <- encode(metadata: element.metadata ?? [:])) + // columnSessionUUID <- element.sessionUUID + // columnError <- element.error) ) } } + +extension HKWorkoutEvent { + + func insert(in database: Database, dataId: Int) throws { + try HKWorkoutEventTable.insert(self, dataId: dataId, in: database) + } +} diff --git a/HealthImport/Model/WorkoutEvent.swift b/HealthImport/Model/WorkoutEvent.swift index 0840695..8f56c4a 100644 --- a/HealthImport/Model/WorkoutEvent.swift +++ b/HealthImport/Model/WorkoutEvent.swift @@ -1,59 +1,20 @@ import Foundation import HealthKit -import BinaryCodable +import SwiftProtobuf -struct WorkoutEvent { - - let date: Date - - let type: HKWorkoutEventType - - let duration: TimeInterval - - let metadata: [String : Any] - - let sessionUUID: Data? - - let error: Data? - -} +extension HKWorkoutEvent: Identifiable { -extension WorkoutEvent: Equatable { - - static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool { - lhs.date == rhs.date && lhs.type == rhs.type && lhs.duration == rhs.duration + public var id: Double { + dateInterval.start.timeIntervalSinceReferenceDate * Double(type.rawValue) * dateInterval.duration } } -extension WorkoutEvent: Comparable { - - static func < (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool { - lhs.date < rhs.date - } -} - -extension WorkoutEvent: Hashable { - - func hash(into hasher: inout Hasher) { - hasher.combine(date) - hasher.combine(type.rawValue) - hasher.combine(duration) - } -} - -extension WorkoutEvent: Identifiable { - - var id: Double { - date.timeIntervalSinceReferenceDate * Double(type.rawValue) * duration - } -} - -extension WorkoutEvent { +extension HKWorkoutEventTable { static func decode(metadata data: Data) -> [String : Any] { let metadata: WorkoutEventMetadata do { - metadata = try ProtobufDecoder.decode(WorkoutEventMetadata.self, from: data) + metadata = try WorkoutEventMetadata(serializedData: data) } catch { print("Failed to decode event metadata: \(error)") print(data.hex) @@ -71,14 +32,14 @@ extension WorkoutEvent { } static func encode(metadata: [String : Any]) -> Data? { - let wrapper = WorkoutEventMetadata(elements: metadata.compactMap { - .init(key: $0.key, value: $0.value) - }) + let wrapper = WorkoutEventMetadata.with { + $0.elements = metadata.compactMap { .from(key: $0.key, value: $0.value) } + } guard !wrapper.elements.isEmpty else { return nil } do { - return try ProtobufEncoder().encode(wrapper) + return try wrapper.serializedData() } catch { print("Failed to encode event metadata: \(error)") return nil @@ -86,90 +47,49 @@ extension WorkoutEvent { } } -private struct WorkoutEventMetadata { - - let elements: [Element] -} - -extension WorkoutEventMetadata.Element { +private extension WorkoutEventMetadata.Element { var value: Any? { - if let unsignedValue { + if hasUnsignedValue { return unsignedValue } - if let quantity { + if hasQuantity { return HKQuantity(unit: .init(from: quantity.unit), doubleValue: quantity.value) } return UInt(0) } - init?(key: String, value: Any) { - self.key = key - + static func from(key: String, value: Any) -> Self? { if let value = value as? UInt { - self.unsignedValue = value - self.quantity = nil - return + return .with { + $0.key = key + $0.unsignedValue = UInt64(value) + } } guard let value = value as? HKQuantity else { print("Unknown value type for metadata key \(key): \(value)") return nil } - self.unsignedValue = nil + let number: Double + let unit: String if value.is(compatibleWith: .meter()) { - self.quantity = .init(value: value.doubleValue(for: .meter()), unit: "m") + number = value.doubleValue(for: .meter()) + unit = "m" } else if value.is(compatibleWith: .second()) { - self.quantity = .init(value: value.doubleValue(for: .second()), unit: "s") + number = value.doubleValue(for: .second()) + unit = "s" } else { print("Unhandled quantity type for metadata key \(key): \(value)") return nil } - } -} - -extension WorkoutEventMetadata { - - struct Element { - - let key: String - - let unsignedValue: UInt? - - let quantity: Quantity? - } -} - -extension WorkoutEventMetadata.Element { - - struct Quantity { - - let value: Double - - let unit: String - } -} - -extension WorkoutEventMetadata: Codable { - - enum CodingKeys: Int, CodingKey { - case elements = 1 - } -} - -extension WorkoutEventMetadata.Element: Codable { - - enum CodingKeys: Int, CodingKey { - case key = 1 - case unsignedValue = 4 - case quantity = 6 - } -} - -extension WorkoutEventMetadata.Element.Quantity: Codable { - - enum CodingKeys: Int, CodingKey { - case value = 1 - case unit = 2 + + return .with { el in + el.key = key + el.quantity = .with { + $0.value = number + $0.unit = unit + } + } } } diff --git a/HealthImport/Preview Content/HealthDatabase+Mock.swift b/HealthImport/Preview Content/HealthDatabase+Mock.swift index 1f25692..51e0651 100644 --- a/HealthImport/Preview Content/HealthDatabase+Mock.swift +++ b/HealthImport/Preview Content/HealthDatabase+Mock.swift @@ -1,5 +1,6 @@ import Foundation import SQLite +import HealthKit extension HealthDatabase { @@ -18,7 +19,7 @@ extension HealthDatabase { let database = try Connection(.inMemory) try Workout.createTable(in: database) - try WorkoutEvent.createTable(in: database) + try HKWorkoutEventTable.create(in: database) try WorkoutActivity.createTable(in: database) try Metadata.createTables(in: database) diff --git a/HealthImport/Preview Content/Workout+Mock.swift b/HealthImport/Preview Content/Workout+Mock.swift index d54833b..9bfe12a 100644 --- a/HealthImport/Preview Content/Workout+Mock.swift +++ b/HealthImport/Preview Content/Workout+Mock.swift @@ -1,4 +1,5 @@ import Foundation +import HealthKit extension Workout { @@ -9,7 +10,7 @@ extension Workout { goal: 19800.0, condenserVersion: 3, condenserDate: Date(timeIntervalSinceReferenceDate: 716801471.790011), - events: WorkoutEvent.mock1, + events: HKWorkoutEvent.mock1, activities: [.mock1], metadata: Metadata.mock1) } diff --git a/HealthImport/Preview Content/WorkoutEvent+Mock.swift b/HealthImport/Preview Content/WorkoutEvent+Mock.swift index d2d3990..2b3fcf9 100644 --- a/HealthImport/Preview Content/WorkoutEvent+Mock.swift +++ b/HealthImport/Preview Content/WorkoutEvent+Mock.swift @@ -1,34 +1,26 @@ import Foundation import HealthKit -extension WorkoutEvent { +extension HKWorkoutEvent { - static var mock1: [WorkoutEvent] { + static var mock1: [HKWorkoutEvent] { [ - .init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307), - type: .init(rawValue: 7)!, - duration: 1114.56374406815, - metadata: WorkoutEvent.decode(metadata: .init(hex: mock1Event1Metadata)!), - sessionUUID: nil, - error: nil), - .init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307), - type: .init(rawValue: 7)!, - duration: 1972.17168283463, - metadata: WorkoutEvent.decode(metadata: .init(hex: mock1Event2Metadata)!), - sessionUUID: nil, - error: nil), - .init(date: .init(timeIntervalSinceReferenceDate: 702112942.707113), - type: .init(rawValue: 1)!, - duration: 0.0, - metadata: [:], - sessionUUID: nil, - error: nil), - .init(date: .init(timeIntervalSinceReferenceDate: 702113161.221132), - type: .init(rawValue: 2)!, - duration: 0.0, - metadata: [:], - sessionUUID: nil, - error: nil), + .init(type: .init(rawValue: 7)!, + dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702107518.84307), + duration: 1114.56374406815), + metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event1Metadata)!)), + .init(type: .init(rawValue: 7)!, + dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702107518.84307), + duration: 1972.17168283463), + metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event2Metadata)!)), + .init(type: .init(rawValue: 1)!, + dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702112942.707113), + duration: 0.0), + metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event2Metadata)!)), + .init(type: .init(rawValue: 2)!, + dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702113161.221132), + duration: 0.0), + metadata: [:]) ] } } diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index 3050770..386c6b2 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -1,5 +1,6 @@ import SwiftUI import Collections +import HealthKit struct WorkoutDetailView: View { @@ -32,7 +33,7 @@ struct WorkoutDetailView: View { Section("Events") { ForEach(workout.events) { event in NavigationLink(value: event) { - DetailRow(event.type.description, date: event.date) + DetailRow(event.type.description, date: event.dateInterval.start) } } } @@ -50,7 +51,7 @@ struct WorkoutDetailView: View { ActivityDetailView(activity: activity) .environmentObject(database) } - .navigationDestination(for: WorkoutEvent.self) { event in + .navigationDestination(for: HKWorkoutEvent.self) { event in EventDetailView(event: event) } }