Switch to HKWorkoutEvent

This commit is contained in:
Christoph Hagen 2024-02-01 15:50:28 +01:00
parent dbe088a402
commit c36ee29afb
13 changed files with 371 additions and 177 deletions

24
EventMetadata.proto Normal file
View File

@ -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;
}
}
}

View File

@ -58,6 +58,8 @@
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */; }; 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 */; }; E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */; };
E2FDFF162B6AFD990080A7B3 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF152B6AFD990080A7B3 /* BinaryCodable */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -108,6 +110,7 @@
E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = "<group>"; }; E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = "<group>"; };
E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = "<group>"; }; E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = "<group>"; };
E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; }; E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMetadata.pb.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -120,6 +123,7 @@
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */, 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */,
885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */, 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */,
885002A82B5D296700E7D4DB /* DequeModule in Frameworks */, 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */,
E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -198,6 +202,7 @@
885002812B5C37B700E7D4DB /* Model */ = { 885002812B5C37B700E7D4DB /* Model */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */,
E201EC802B631708005B83D3 /* Goal.swift */, E201EC802B631708005B83D3 /* Goal.swift */,
E27BC6792B5D99AC003A8873 /* LocationSample.swift */, E27BC6792B5D99AC003A8873 /* LocationSample.swift */,
E201EC7A2B6275CA005B83D3 /* Metadata.swift */, E201EC7A2B6275CA005B83D3 /* Metadata.swift */,
@ -235,6 +240,16 @@
path = Support; path = Support;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E2FDFF1D2B6BD1F00080A7B3 /* API */ = {
isa = PBXGroup;
children = (
E2FDFF172B6BB61D0080A7B3 /* HKHealthStoreInterface.swift */,
E2FDFF192B6BB6A40080A7B3 /* HKHealthStore+Interface.swift */,
E2FDFF1B2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift */,
);
path = API;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -257,6 +272,7 @@
885002A72B5D296700E7D4DB /* DequeModule */, 885002A72B5D296700E7D4DB /* DequeModule */,
885002A92B5D296700E7D4DB /* OrderedCollections */, 885002A92B5D296700E7D4DB /* OrderedCollections */,
E2FDFF152B6AFD990080A7B3 /* BinaryCodable */, E2FDFF152B6AFD990080A7B3 /* BinaryCodable */,
E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */,
); );
productName = HealthImport; productName = HealthImport;
productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */; productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */;
@ -290,6 +306,7 @@
885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */, 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */,
885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */, 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */,
E2FDFF142B6AFD990080A7B3 /* XCRemoteSwiftPackageReference "BinaryCodable" */, E2FDFF142B6AFD990080A7B3 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
); );
productRefGroup = 885002582B5C273C00E7D4DB /* Products */; productRefGroup = 885002582B5C273C00E7D4DB /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -336,6 +353,7 @@
E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */, E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */,
E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */, E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */,
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */,
E2FDFF222B6BE35B0080A7B3 /* EventMetadata.pb.swift in Sources */,
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */, 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */,
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
@ -591,6 +609,14 @@
minimumVersion = 2.0.3; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -619,6 +645,11 @@
package = E2FDFF142B6AFD990080A7B3 /* XCRemoteSwiftPackageReference "BinaryCodable" */; package = E2FDFF142B6AFD990080A7B3 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
productName = BinaryCodable; productName = BinaryCodable;
}; };
E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */ = {
isa = XCSwiftPackageProductDependency;
package = E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */;
productName = SwiftProtobuf;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 8850024F2B5C273C00E7D4DB /* Project object */; rootObject = 8850024F2B5C273C00E7D4DB /* Project object */;

View File

@ -26,6 +26,15 @@
"revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192",
"version" : "1.0.6" "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 "version" : 2

View File

@ -1,20 +1,21 @@
import SwiftUI import SwiftUI
import HealthKit
struct EventDetailView: View { struct EventDetailView: View {
let event: WorkoutEvent let event: HKWorkoutEvent
var metadata: [(key: String, value: Any)] { var metadata: [(key: String, value: Any)] {
event.metadata.sorted { $0.key < $1.key } event.metadata?.sorted { $0.key < $1.key } ?? []
} }
var body: some View { var body: some View {
List { List {
DetailRow("Date", date: event.date) DetailRow("Date", date: event.dateInterval.start)
DetailRow("Type", value: event.type) DetailRow("Type", value: event.type)
DetailRow("Duration", duration: event.duration) DetailRow("Duration", duration: event.dateInterval.duration)
DetailRow("Session UUID", value: event.sessionUUID) //DetailRow("Session UUID", value: event.sessionUUID)
DetailRow("Error", value: event.error) //DetailRow("Error", value: event.error)
Section("Metadata") { Section("Metadata") {
ForEach(metadata, id: \.key) { (key, value) in ForEach(metadata, id: \.key) { (key, value) in
DetailRow(key, value: "\(value)") DetailRow(key, value: "\(value)")

View File

@ -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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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
}
}

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import SQLite import SQLite
import HealthKit
extension Workout { extension Workout {
@ -29,7 +30,7 @@ extension Workout {
return try database.prepare(table).map { row in return try database.prepare(table).map { row in
let id = row[columnDataId] 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 activities = try WorkoutActivity.activities(for: id, in: database)
let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys) let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys)
return .init( return .init(

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import Collections import Collections
import HealthKit
private let df: DateFormatter = { private let df: DateFormatter = {
let df = DateFormatter() let df = DateFormatter()
@ -22,7 +23,7 @@ struct Workout {
let condenserDate: Date? let condenserDate: Date?
let events: [WorkoutEvent] let events: [HKWorkoutEvent]
let activities: [WorkoutActivity] let activities: [WorkoutActivity]
@ -33,7 +34,7 @@ struct Workout {
} }
var firstEventDate: Date? { var firstEventDate: Date? {
events.map { $0.date }.min() events.map { $0.dateInterval.start }.min()
} }
var firstAvailableDate: Date? { var firstAvailableDate: Date? {
@ -51,7 +52,7 @@ struct Workout {
activities.first?.activityType.description ?? "Unknown activity" 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.id = id
self.totalDistance = totalDistance self.totalDistance = totalDistance
self.goal = .init(goalType: goalType, goal: goal) self.goal = .init(goalType: goalType, goal: goal)

View File

@ -1,7 +1,8 @@
import Foundation import Foundation
import SQLite import SQLite
import HealthKit
extension WorkoutEvent { enum HKWorkoutEventTable {
private static let table = Table("workout_events") private static let table = Table("workout_events")
@ -29,22 +30,22 @@ extension WorkoutEvent {
// error BLOB // error BLOB
private static let columnError = Expression<Data?>("error") private static let columnError = Expression<Data?>("error")
static func readAll(in database: Connection) throws -> [Self] { static func readAll(in database: Connection) throws -> [HKWorkoutEvent] {
try database.prepare(table).map(from) try database.prepare(table).map(event)
} }
static func events(for workoutId: Int, in database: Connection) throws -> [Self] { static func events(for workoutId: Int, in database: Connection) throws -> [HKWorkoutEvent] {
try database.prepare(table.filter(columnOwnerId == workoutId)).map(from) try database.prepare(table.filter(columnOwnerId == workoutId)).map(event)
} }
private static func from(row: Row) -> WorkoutEvent { private static func event(from row: Row) -> HKWorkoutEvent {
.init( let start = Date(timeIntervalSinceReferenceDate: row[columnDate])
date: Date(timeIntervalSinceReferenceDate: row[columnDate]), let interval = DateInterval(start: start, duration: row[columnDuration])
type: .init(rawValue: row[columnType])!, let metadata = metadata(row[columnMetadata])
duration: row[columnDuration], let type = HKWorkoutEventType(rawValue: row[columnType])!
metadata: metadata(row[columnMetadata]), // let sessionUUID = row[columnSessionUUID]
sessionUUID: row[columnSessionUUID], // let error = row[columnError]
error: row[columnError]) return .init(type: type, dateInterval: interval, metadata: metadata)
} }
private static func metadata(_ data: Data?) -> [String : Any] { private static func metadata(_ data: Data?) -> [String : Any] {
@ -54,7 +55,7 @@ extension WorkoutEvent {
return decode(metadata: data) 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.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 try database.run(table.create { t in
t.column(columnRowId, primaryKey: .autoincrement) t.column(columnRowId, primaryKey: .autoincrement)
@ -68,19 +69,22 @@ extension WorkoutEvent {
}) })
} }
func insert(in database: Database, dataId: Int) throws { static func insert(_ element: HKWorkoutEvent, dataId: Int, in database: Database) throws {
try WorkoutEvent.insert(self, dataId: dataId, in: database)
}
private static func insert(_ element: WorkoutEvent, dataId: Int, in database: Database) throws {
try database.run(table.insert( try database.run(table.insert(
columnOwnerId <- dataId, columnOwnerId <- dataId,
columnDate <- element.date.timeIntervalSinceReferenceDate, columnDate <- element.dateInterval.start.timeIntervalSinceReferenceDate,
columnType <- element.type.rawValue, columnType <- element.type.rawValue,
columnDuration <- element.duration, columnDuration <- element.dateInterval.duration,
columnMetadata <- encode(metadata: element.metadata), columnMetadata <- encode(metadata: element.metadata ?? [:]))
columnSessionUUID <- element.sessionUUID, // columnSessionUUID <- element.sessionUUID
columnError <- element.error) // columnError <- element.error)
) )
} }
} }
extension HKWorkoutEvent {
func insert(in database: Database, dataId: Int) throws {
try HKWorkoutEventTable.insert(self, dataId: dataId, in: database)
}
}

View File

@ -1,59 +1,20 @@
import Foundation import Foundation
import HealthKit import HealthKit
import BinaryCodable import SwiftProtobuf
struct WorkoutEvent { extension HKWorkoutEvent: Identifiable {
let date: Date public var id: Double {
dateInterval.start.timeIntervalSinceReferenceDate * Double(type.rawValue) * dateInterval.duration
let type: HKWorkoutEventType
let duration: TimeInterval
let metadata: [String : Any]
let sessionUUID: Data?
let error: Data?
}
extension WorkoutEvent: Equatable {
static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool {
lhs.date == rhs.date && lhs.type == rhs.type && lhs.duration == rhs.duration
} }
} }
extension WorkoutEvent: Comparable { extension HKWorkoutEventTable {
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 {
static func decode(metadata data: Data) -> [String : Any] { static func decode(metadata data: Data) -> [String : Any] {
let metadata: WorkoutEventMetadata let metadata: WorkoutEventMetadata
do { do {
metadata = try ProtobufDecoder.decode(WorkoutEventMetadata.self, from: data) metadata = try WorkoutEventMetadata(serializedData: data)
} catch { } catch {
print("Failed to decode event metadata: \(error)") print("Failed to decode event metadata: \(error)")
print(data.hex) print(data.hex)
@ -71,14 +32,14 @@ extension WorkoutEvent {
} }
static func encode(metadata: [String : Any]) -> Data? { static func encode(metadata: [String : Any]) -> Data? {
let wrapper = WorkoutEventMetadata(elements: metadata.compactMap { let wrapper = WorkoutEventMetadata.with {
.init(key: $0.key, value: $0.value) $0.elements = metadata.compactMap { .from(key: $0.key, value: $0.value) }
}) }
guard !wrapper.elements.isEmpty else { guard !wrapper.elements.isEmpty else {
return nil return nil
} }
do { do {
return try ProtobufEncoder().encode(wrapper) return try wrapper.serializedData()
} catch { } catch {
print("Failed to encode event metadata: \(error)") print("Failed to encode event metadata: \(error)")
return nil return nil
@ -86,90 +47,49 @@ extension WorkoutEvent {
} }
} }
private struct WorkoutEventMetadata { private extension WorkoutEventMetadata.Element {
let elements: [Element]
}
extension WorkoutEventMetadata.Element {
var value: Any? { var value: Any? {
if let unsignedValue { if hasUnsignedValue {
return unsignedValue return unsignedValue
} }
if let quantity { if hasQuantity {
return HKQuantity(unit: .init(from: quantity.unit), doubleValue: quantity.value) return HKQuantity(unit: .init(from: quantity.unit), doubleValue: quantity.value)
} }
return UInt(0) return UInt(0)
} }
init?(key: String, value: Any) { static func from(key: String, value: Any) -> Self? {
self.key = key
if let value = value as? UInt { if let value = value as? UInt {
self.unsignedValue = value return .with {
self.quantity = nil $0.key = key
return $0.unsignedValue = UInt64(value)
}
} }
guard let value = value as? HKQuantity else { guard let value = value as? HKQuantity else {
print("Unknown value type for metadata key \(key): \(value)") print("Unknown value type for metadata key \(key): \(value)")
return nil return nil
} }
self.unsignedValue = nil
let number: Double
let unit: String
if value.is(compatibleWith: .meter()) { 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()) { } else if value.is(compatibleWith: .second()) {
self.quantity = .init(value: value.doubleValue(for: .second()), unit: "s") number = value.doubleValue(for: .second())
unit = "s"
} else { } else {
print("Unhandled quantity type for metadata key \(key): \(value)") print("Unhandled quantity type for metadata key \(key): \(value)")
return nil return nil
} }
return .with { el in
el.key = key
el.quantity = .with {
$0.value = number
$0.unit = unit
} }
} }
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
} }
} }

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import SQLite import SQLite
import HealthKit
extension HealthDatabase { extension HealthDatabase {
@ -18,7 +19,7 @@ extension HealthDatabase {
let database = try Connection(.inMemory) let database = try Connection(.inMemory)
try Workout.createTable(in: database) try Workout.createTable(in: database)
try WorkoutEvent.createTable(in: database) try HKWorkoutEventTable.create(in: database)
try WorkoutActivity.createTable(in: database) try WorkoutActivity.createTable(in: database)
try Metadata.createTables(in: database) try Metadata.createTables(in: database)

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import HealthKit
extension Workout { extension Workout {
@ -9,7 +10,7 @@ extension Workout {
goal: 19800.0, goal: 19800.0,
condenserVersion: 3, condenserVersion: 3,
condenserDate: Date(timeIntervalSinceReferenceDate: 716801471.790011), condenserDate: Date(timeIntervalSinceReferenceDate: 716801471.790011),
events: WorkoutEvent.mock1, events: HKWorkoutEvent.mock1,
activities: [.mock1], activities: [.mock1],
metadata: Metadata.mock1) metadata: Metadata.mock1)
} }

View File

@ -1,34 +1,26 @@
import Foundation import Foundation
import HealthKit import HealthKit
extension WorkoutEvent { extension HKWorkoutEvent {
static var mock1: [WorkoutEvent] { static var mock1: [HKWorkoutEvent] {
[ [
.init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307), .init(type: .init(rawValue: 7)!,
type: .init(rawValue: 7)!, dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702107518.84307),
duration: 1114.56374406815, duration: 1114.56374406815),
metadata: WorkoutEvent.decode(metadata: .init(hex: mock1Event1Metadata)!), metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event1Metadata)!)),
sessionUUID: nil, .init(type: .init(rawValue: 7)!,
error: nil), dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702107518.84307),
.init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307), duration: 1972.17168283463),
type: .init(rawValue: 7)!, metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event2Metadata)!)),
duration: 1972.17168283463, .init(type: .init(rawValue: 1)!,
metadata: WorkoutEvent.decode(metadata: .init(hex: mock1Event2Metadata)!), dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702112942.707113),
sessionUUID: nil, duration: 0.0),
error: nil), metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event2Metadata)!)),
.init(date: .init(timeIntervalSinceReferenceDate: 702112942.707113), .init(type: .init(rawValue: 2)!,
type: .init(rawValue: 1)!, dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702113161.221132),
duration: 0.0, duration: 0.0),
metadata: [:], metadata: [:])
sessionUUID: nil,
error: nil),
.init(date: .init(timeIntervalSinceReferenceDate: 702113161.221132),
type: .init(rawValue: 2)!,
duration: 0.0,
metadata: [:],
sessionUUID: nil,
error: nil),
] ]
} }
} }

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import Collections import Collections
import HealthKit
struct WorkoutDetailView: View { struct WorkoutDetailView: View {
@ -32,7 +33,7 @@ struct WorkoutDetailView: View {
Section("Events") { Section("Events") {
ForEach(workout.events) { event in ForEach(workout.events) { event in
NavigationLink(value: event) { 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) ActivityDetailView(activity: activity)
.environmentObject(database) .environmentObject(database)
} }
.navigationDestination(for: WorkoutEvent.self) { event in .navigationDestination(for: HKWorkoutEvent.self) { event in
EventDetailView(event: event) EventDetailView(event: event)
} }
} }