Switch to HKWorkoutActivity

This commit is contained in:
Christoph Hagen 2024-02-01 21:46:08 +01:00
parent b8162e6cb9
commit 94fc10f204
12 changed files with 136 additions and 162 deletions

View File

@ -19,7 +19,6 @@
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027E2B5C36A700E7D4DB /* Workout.swift */; };
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */; };
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */; };
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */; };
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */; };
8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */; };
885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002902B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift */; };
@ -78,7 +77,6 @@
8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = "<group>"; };
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEvent.swift; sourceTree = "<group>"; };
885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Extensions.swift"; sourceTree = "<group>"; };
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivity.swift; sourceTree = "<group>"; };
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = "<group>"; };
8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
885002902B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutEventType+Extensions.swift"; sourceTree = "<group>"; };
@ -215,7 +213,6 @@
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */,
8850027E2B5C36A700E7D4DB /* Workout.swift */,
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */,
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */,
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */,
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */,
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */,
@ -367,7 +364,6 @@
E2FDFF1C2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift in Sources */,
885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */,
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */,
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */,
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */,
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,

View File

@ -1,11 +1,12 @@
import SwiftUI
import HealthKit
struct ActivityDetailView: View {
@EnvironmentObject
var database: HealthDatabase
let activity: WorkoutActivity
let activity: HKWorkoutActivity
@State var locations: [LocationSample] = []
@ -14,11 +15,10 @@ struct ActivityDetailView: View {
var body: some View {
List {
DetailRow("UUID", value: activity.uuid)
DetailRow("Primary Activity", value: activity.isPrimaryActivity)
DetailRow("Activity", value: activity.activityType)
DetailRow("Location", value: activity.locationType)
DetailRow("Swimming Location", value: activity.swimmingLocationType)
DetailRow("Lap Length", value: activity.lapLength)
DetailRow("Activity", value: activity.workoutConfiguration.activityType)
DetailRow("Location", value: activity.workoutConfiguration.locationType)
DetailRow("Swimming Location", value: activity.workoutConfiguration.swimmingLocationType)
DetailRow("Lap Length", value: activity.workoutConfiguration.lapLength)
DetailRow("Start", date: activity.startDate)
DetailRow("End", date: activity.endDate)
DetailRow("Duration", duration: activity.duration)

View File

@ -1,16 +1,17 @@
import SwiftUI
import OrderedCollections
import HealthKit
struct ActivitySamplesView: View {
@EnvironmentObject
var database: HealthDatabase
let activity: WorkoutActivity
let activity: HKWorkoutActivity
@State var samples: [(type: Sample.DataType, samples: [Sample])] = []
init(activity: WorkoutActivity) {
init(activity: HKWorkoutActivity) {
self.activity = activity
}

View File

@ -1,6 +1,7 @@
import Foundation
import SQLite
import CoreLocation
import HealthKit
typealias Database = Connection
@ -39,19 +40,25 @@ final class HealthDatabase: ObservableObject {
}
}
func locationSamples(for activity: WorkoutActivity) throws -> [LocationSample] {
try activity.locationSamples(in: database)
func locationSamples(for activity: HKWorkoutActivity) throws -> [LocationSample] {
try LocationSample.locationSamples(from: activity.startDate, to: activity.currentEndDate, in: database)
}
func samples(for activity: WorkoutActivity) throws -> [Sample.DataType : [Sample]] {
try activity.samples(in: database)
func locationSampleCount(for activity: HKWorkoutActivity) throws -> Int {
try LocationSample.locationSampleCount(from: activity.startDate, to: activity.currentEndDate, in: database)
}
func sampleCount(for activity: WorkoutActivity) throws -> Int {
try activity.sampleCount(in: database)
func samples(for activity: HKWorkoutActivity) throws -> [Sample.DataType : [Sample]] {
try Sample.samples(from: activity.startDate, to: activity.currentEndDate, in: database).reduce(into: [:]) {
$0[$1.dataType] = ($0[$1.dataType] ?? []) + [$1]
}
}
var activities: [WorkoutActivity] {
func sampleCount(for activity: HKWorkoutActivity) throws -> Int {
try Sample.sampleCount(from: activity.startDate, to: activity.currentEndDate, in: database)
}
var activities: [HKWorkoutActivity] {
workouts.map { $0.activities }.joined().sorted()
}
@ -59,11 +66,11 @@ final class HealthDatabase: ObservableObject {
let activities = self.activities
var current = activities.first!
for next in activities.dropFirst() {
let overlap = next.startDate.timeIntervalSince(current.endDate)
let overlap = next.startDate.timeIntervalSince(current.currentEndDate)
if overlap < 0 {
print("Overlap \(-overlap.roundedInt) s:")
print(" Activity \(current.activityType.description): \(current.startDate.timeAndDateText) -> \(current.endDate.timeAndDateText)")
print(" Activity \(next.activityType.description): \(next.startDate.timeAndDateText) -> \(next.endDate.timeAndDateText)")
print(" Activity \(current.workoutConfiguration.activityType.description): \(current.startDate.timeAndDateText) -> \(current.currentEndDate.timeAndDateText)")
print(" Activity \(next.workoutConfiguration.activityType.description): \(next.startDate.timeAndDateText) -> \(next.currentEndDate.timeAndDateText)")
}
current = next
}
@ -73,3 +80,10 @@ final class HealthDatabase: ObservableObject {
self.init(fileUrl: .init(filePath: "/"), database: database)
}
}
private extension HKWorkoutActivity {
var currentEndDate: Date {
endDate ?? Date()
}
}

View File

@ -31,7 +31,7 @@ extension Workout {
let id = row[columnDataId]
let events = try HKWorkoutEventTable.events(for: id, in: database)
let activities = try WorkoutActivity.activities(for: id, in: database)
let activities = try WorkoutActivityTable.activities(for: id, in: database)
let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys)
return .init(
id: id,
@ -74,9 +74,14 @@ extension Workout {
for event in element.events {
try event.insert(in: database, dataId: dataId)
}
for activity in element.activities {
try activity.insert(in: database, dataId: dataId)
if let activity = element.activities.first {
try WorkoutActivityTable.insert(activity, isPrimaryActivity: true, dataId: dataId, in: database)
}
for activity in element.activities.dropFirst() {
try WorkoutActivityTable.insert(activity, isPrimaryActivity: false, dataId: dataId, in: database)
}
for (key, value) in element.metadata {
try Metadata.insert(value, for: key, of: dataId, in: database)
}

View File

@ -25,8 +25,8 @@ struct Workout {
let events: [HKWorkoutEvent]
let activities: [WorkoutActivity]
let activities: [HKWorkoutActivity]
let metadata: OrderedDictionary<Metadata.Key, Metadata.Value>
var firstActivityDate: Date? {
@ -49,10 +49,10 @@ struct Workout {
}
var typeString: String {
activities.first?.activityType.description ?? "Unknown activity"
activities.first?.workoutConfiguration.activityType.description ?? "Unknown activity"
}
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] = [:]) {
init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [HKWorkoutEvent] = [], activities: [HKWorkoutActivity] = [], metadata: [Metadata.Key : Metadata.Value] = [:]) {
self.id = id
self.totalDistance = totalDistance
self.goal = .init(goalType: goalType, goal: goal)

View File

@ -2,7 +2,14 @@ import Foundation
import SQLite
import HealthKit
extension WorkoutActivity {
extension HKWorkoutActivity: Comparable {
public static func < (lhs: HKWorkoutActivity, rhs: HKWorkoutActivity) -> Bool {
lhs.startDate < rhs.startDate
}
}
enum WorkoutActivityTable {
private static let table = Table("workout_activities")
@ -30,30 +37,38 @@ extension WorkoutActivity {
private static let columnMetadata = Expression<Data?>("metadata")
private static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(from)
private static func readAll(in database: Connection) throws -> [HKWorkoutActivity] {
try database.prepare(table).map(activity)
}
private static func from(row: Row) throws -> WorkoutActivity {
.init(
id: row[columnId],
uuid: row[columnUUID],
isPrimaryActivity: row[columnIsPrimaryActivity],
activityType: .init(rawValue: UInt(row[columnActivityType]))!,
locationType: .init(rawValue: row[columnLocationType])!,
swimmingLocationType: .init(rawValue: row[columnSwimmingLocationType])!,
lapLength: try row[columnLapLength].map(lapLength),
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
duration: row[columnDuration],
metadata: row[columnMetadata])
private static func activity(from row: Row) throws -> HKWorkoutActivity {
let configuration = HKWorkoutConfiguration()
configuration.lapLength = try row[columnLapLength].map(lapLength)
configuration.activityType = .init(rawValue: UInt(row[columnActivityType]))!
configuration.locationType = .init(rawValue: row[columnLocationType])!
configuration.swimmingLocationType = .init(rawValue: row[columnSwimmingLocationType])!
let start = Date(timeIntervalSinceReferenceDate: row[columnStartDate])
let end = Date(timeIntervalSinceReferenceDate: row[columnEndDate])
let uuid = row[columnUUID].uuidString
let metadata: [String : Any] = [HKMetadataKeyExternalUUID : uuid]
// duration: row[columnDuration]
// isPrimaryActivity: row[columnIsPrimaryActivity]
// metadata: row[columnMetadata]
#warning("Decode metadata")
return .init(
workoutConfiguration: configuration,
start: start,
end: end,
metadata: metadata)
}
static func activities(for workoutId: Int, in database: Connection) throws -> [Self] {
try database.prepare(table.filter(columnOwnerId == workoutId)).map(from)
static func activities(for workoutId: Int, in database: Connection) throws -> [HKWorkoutActivity] {
try database.prepare(table.filter(columnOwnerId == workoutId)).map(activity)
}
static func createTable(in database: Connection) throws {
static func create(in database: Connection) throws {
//try database.execute("CREATE TABLE workout_activities (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE NOT NULL, owner_id INTEGER NOT NULL REFERENCES workouts(data_id) ON DELETE CASCADE, is_primary_activity INTEGER NOT NULL, activity_type INTEGER NOT NULL, location_type INTEGER NOT NULL, swimming_location_type INTEGER NOT NULL, lap_length BLOB, start_date REAL NOT NULL, end_date REAL NOT NULL, duration REAL NOT NULL, metadata BLOB)")
try database.run(table.create { t in
t.column(columnId, primaryKey: .autoincrement)
@ -71,30 +86,36 @@ extension WorkoutActivity {
})
}
func insert(in database: Connection, dataId: Int) throws {
try WorkoutActivity.insert(self, dataId: dataId, in: database)
}
private static func insert(_ element: WorkoutActivity, dataId: Int, in database: Connection) throws {
static func insert(_ element: HKWorkoutActivity, isPrimaryActivity: Bool, dataId: Int, in database: Connection) throws {
try database.run(table.insert(
columnUUID <- element.uuid,
columnUUID <- (element.externalUUID ?? element.uuid).uuidString.data(using: .utf8)!,
columnOwnerId <- dataId,
columnIsPrimaryActivity <- element.isPrimaryActivity,
columnActivityType <- Int(element.activityType.rawValue),
columnLocationType <- element.locationType.rawValue,
columnSwimmingLocationType <- element.swimmingLocationType.rawValue,
columnLapLength <- try element.lapLengthData(),
columnIsPrimaryActivity <- isPrimaryActivity,
columnActivityType <- Int(element.workoutConfiguration.activityType.rawValue),
columnLocationType <- element.workoutConfiguration.locationType.rawValue,
columnSwimmingLocationType <- element.workoutConfiguration.swimmingLocationType.rawValue,
columnLapLength <- try lapLengthData(lapLength: element.workoutConfiguration.lapLength),
columnStartDate <- element.startDate.timeIntervalSinceReferenceDate,
columnEndDate <- element.endDate.timeIntervalSinceReferenceDate,
columnDuration <- element.duration,
columnMetadata <- element.metadata)
columnEndDate <- element.endDate?.timeIntervalSinceReferenceDate ?? element.startDate.addingTimeInterval(element.duration).timeIntervalSinceReferenceDate,
columnDuration <- element.duration)
//columnMetadata <- element.metadata)
)
}
}
private extension WorkoutActivity {
private extension HKWorkoutActivity {
func lapLengthData() throws -> Data? {
var externalUUID: UUID? {
guard let string = metadata?[HKMetadataKeyExternalUUID] as? String else {
return nil
}
return UUID(uuidString: string)
}
}
private extension WorkoutActivityTable {
static func lapLengthData(lapLength: HKQuantity?) throws -> Data? {
try lapLength.map { try NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: false) }
}
@ -102,3 +123,19 @@ private extension WorkoutActivity {
try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQuantity.self, from: data)
}
}
private extension Data {
var uuidString: String {
let h = Array(self)
let parts = [h[0..<4], h[4..<6], h[6..<8], h[8..<10], h[10..<16]]
return parts.map { Data($0).hex }.joined(separator: "-")
}
}
private extension UUID {
init?(data: Data) {
self.init(uuidString: data.uuidString)
}
}

View File

@ -1,81 +0,0 @@
import Foundation
import HealthKit
struct WorkoutActivity {
private let id: Int
let uuid: Data
let isPrimaryActivity: Bool
let activityType: HKWorkoutActivityType
let locationType: HKWorkoutSessionLocationType
let swimmingLocationType: HKWorkoutSwimmingLocationType
let lapLength: HKQuantity?
#warning("Fix timezone for dates")
let startDate: Date
let endDate: Date
let duration: TimeInterval
let metadata: Data?
init(id: Int, uuid: Data, isPrimaryActivity: Bool, activityType: HKWorkoutActivityType, locationType: HKWorkoutSessionLocationType, swimmingLocationType: HKWorkoutSwimmingLocationType, lapLength: HKQuantity?, startDate: Date, endDate: Date, duration: TimeInterval, metadata: Data?) {
self.id = id
self.uuid = uuid
self.isPrimaryActivity = isPrimaryActivity
self.activityType = activityType
self.locationType = locationType
self.swimmingLocationType = swimmingLocationType
self.lapLength = lapLength
self.startDate = startDate
self.endDate = endDate
self.duration = duration
self.metadata = metadata
}
func locationSamples(in database: Database) throws -> [LocationSample] {
try LocationSample.locationSamples(from: startDate, to: endDate, in: database)
}
func locationSampleCount(in database: Database) throws -> Int {
try LocationSample.locationSampleCount(from: startDate, to: endDate, in: database)
}
func sampleCount(in database: Database) throws -> Int {
try Sample.sampleCount(from: startDate, to: endDate, in: database)
}
func samples(in database: Database) throws -> [Sample.DataType : [Sample]] {
try Sample.samples(from: startDate, to: endDate, in: database).reduce(into: [:]) {
$0[$1.dataType] = ($0[$1.dataType] ?? []) + [$1]
}
}
}
extension WorkoutActivity: Equatable {
static func == (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool {
lhs.id == rhs.id
}
}
extension WorkoutActivity: Comparable {
static func < (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool {
lhs.startDate < rhs.startDate
}
}
extension WorkoutActivity: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -60,7 +60,7 @@ private extension WorkoutEventMetadata.Element {
}
static func from(key: String, value: Any) -> Self? {
if let value = value as? UInt {
if let value = value as? UInt64 {
return .with {
$0.key = key
$0.unsignedValue = UInt64(value)

View File

@ -20,7 +20,7 @@ extension HealthDatabase {
try Workout.createTable(in: database)
try HKWorkoutEventTable.create(in: database)
try WorkoutActivity.createTable(in: database)
try WorkoutActivityTable.create(in: database)
try Metadata.createTables(in: database)
try Workout.mock1.insert(in: database)

View File

@ -1,18 +1,20 @@
import Foundation
import HealthKit
extension WorkoutActivity {
extension HKWorkoutActivity {
static var mock1: WorkoutActivity = .init(
id: 744,
uuid: Data(hex: "0e0019a803d541e7b240feb7a360911a")!,
isPrimaryActivity: true,
activityType: .init(rawValue: 24)!,
locationType: .init(rawValue: 3)!,
swimmingLocationType: .init(rawValue: 0)!,
lapLength: nil,
startDate: .init(timeIntervalSinceReferenceDate: 702107518.84307),
endDate: .init(timeIntervalSinceReferenceDate: 702143189.432644),
duration: 27405.1830769777,
metadata: nil)
static var mock1: HKWorkoutActivity = {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .init(rawValue: 24)!
configuration.locationType = .init(rawValue: 3)!
configuration.swimmingLocationType = .init(rawValue: 0)!
configuration.lapLength = nil
let metadata: [String: Any] = [ HKMetadataKeyExternalUUID : "0E0019A803D541E7B240FEB7A360911A"]
return .init(
workoutConfiguration: configuration,
start: .init(timeIntervalSinceReferenceDate: 702107518.84307),
end: .init(timeIntervalSinceReferenceDate: 702143189.432644),
metadata: metadata)
}()
}

View File

@ -22,7 +22,7 @@ struct WorkoutDetailView: View {
Section("Activities") {
ForEach(workout.activities, id: \.startDate) { activity in
NavigationLink(value: activity) {
DetailRow(activity.activityType.description,
DetailRow(activity.workoutConfiguration.activityType.description,
date: activity.startDate)
}
@ -47,7 +47,7 @@ struct WorkoutDetailView: View {
}
}
.navigationTitle(workout.typeString)
.navigationDestination(for: WorkoutActivity.self) { activity in
.navigationDestination(for: HKWorkoutActivity.self) { activity in
ActivityDetailView(activity: activity)
.environmentObject(database)
}