Read workouts, events, activities

This commit is contained in:
Christoph Hagen 2024-01-21 10:31:47 +01:00
parent b75e0afe4a
commit 8ace8e9319
22 changed files with 1177 additions and 13 deletions

View File

@ -11,6 +11,26 @@
8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850025C2B5C273C00E7D4DB /* ContentView.swift */; };
8850025F2B5C273E00E7D4DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8850025E2B5C273E00E7D4DB /* Assets.xcassets */; };
885002622B5C273E00E7D4DB /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 885002612B5C273E00E7D4DB /* Preview Assets.xcassets */; };
8850026C2B5C278600E7D4DB /* healthdb_secure.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */; };
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002702B5C299900E7D4DB /* HealthDatabase.swift */; };
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 885002762B5C2FC400E7D4DB /* SQLite */; };
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002782B5C320400E7D4DB /* Optional+Extensions.swift */; };
8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */; };
8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */; };
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 */; };
885002892B5C873C00E7D4DB /* DBWorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002882B5C873C00E7D4DB /* DBWorkoutActivity.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 */; };
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002922B5D129300E7D4DB /* ActivityDetailView.swift */; };
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002942B5D147100E7D4DB /* DetailRow.swift */; };
885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */; };
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */; };
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */; };
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -19,6 +39,25 @@
8850025C2B5C273C00E7D4DB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
8850025E2B5C273E00E7D4DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
885002612B5C273E00E7D4DB /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = "<group>"; };
885002702B5C299900E7D4DB /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = "<group>"; };
885002782B5C320400E7D4DB /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkout.swift; sourceTree = "<group>"; };
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutEvent.swift; sourceTree = "<group>"; };
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>"; };
885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutActivity.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>"; };
885002922B5D129300E7D4DB /* ActivityDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityDetailView.swift; sourceTree = "<group>"; };
885002942B5D147100E7D4DB /* DetailRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailRow.swift; sourceTree = "<group>"; };
885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSessionLocationType+Extensions.swift"; sourceTree = "<group>"; };
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = "<group>"; };
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -26,6 +65,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -51,10 +91,19 @@
885002592B5C273C00E7D4DB /* HealthImport */ = {
isa = PBXGroup;
children = (
8850026A2B5C276B00E7D4DB /* Resources */,
8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */,
8850025C2B5C273C00E7D4DB /* ContentView.swift */,
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
8850029C2B5D197300E7D4DB /* EventDetailView.swift */,
885002942B5D147100E7D4DB /* DetailRow.swift */,
8850025E2B5C273E00E7D4DB /* Assets.xcassets */,
885002602B5C273E00E7D4DB /* Preview Content */,
885002702B5C299900E7D4DB /* HealthDatabase.swift */,
885002802B5C37A800E7D4DB /* Database Entries */,
885002812B5C37B700E7D4DB /* Model */,
885002832B5C37C600E7D4DB /* Support */,
);
path = HealthImport;
sourceTree = "<group>";
@ -67,6 +116,48 @@
path = "Preview Content";
sourceTree = "<group>";
};
8850026A2B5C276B00E7D4DB /* Resources */ = {
isa = PBXGroup;
children = (
8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */,
);
path = Resources;
sourceTree = "<group>";
};
885002802B5C37A800E7D4DB /* Database Entries */ = {
isa = PBXGroup;
children = (
8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */,
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */,
885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */,
);
path = "Database Entries";
sourceTree = "<group>";
};
885002812B5C37B700E7D4DB /* Model */ = {
isa = PBXGroup;
children = (
8850027E2B5C36A700E7D4DB /* Workout.swift */,
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */,
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */,
);
path = Model;
sourceTree = "<group>";
};
885002832B5C37C600E7D4DB /* Support */ = {
isa = PBXGroup;
children = (
8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */,
885002782B5C320400E7D4DB /* Optional+Extensions.swift */,
885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */,
885002902B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift */,
885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */,
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */,
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */,
);
path = Support;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -83,6 +174,9 @@
dependencies = (
);
name = HealthImport;
packageProductDependencies = (
885002762B5C2FC400E7D4DB /* SQLite */,
);
productName = HealthImport;
productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */;
productType = "com.apple.product-type.application";
@ -111,6 +205,9 @@
Base,
);
mainGroup = 8850024E2B5C273C00E7D4DB;
packageReferences = (
885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */,
);
productRefGroup = 885002582B5C273C00E7D4DB /* Products */;
projectDirPath = "";
projectRoot = "";
@ -125,6 +222,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
8850026C2B5C278600E7D4DB /* healthdb_secure.sqlite in Resources */,
885002622B5C273E00E7D4DB /* Preview Assets.xcassets in Resources */,
8850025F2B5C273E00E7D4DB /* Assets.xcassets in Resources */,
);
@ -137,7 +235,25 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */,
8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */,
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */,
885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */,
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */,
8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */,
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */,
885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */,
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */,
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */,
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */,
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */,
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */,
885002892B5C873C00E7D4DB /* DBWorkoutActivity.swift in Sources */,
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -344,6 +460,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/stephencelis/SQLite.swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.14.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
885002762B5C2FC400E7D4DB /* SQLite */ = {
isa = XCSwiftPackageProductDependency;
package = 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */;
productName = SQLite;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 8850024F2B5C273C00E7D4DB /* Project object */;
}

View File

@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "sqlite.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/stephencelis/SQLite.swift",
"state" : {
"revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb",
"version" : "0.14.1"
}
}
],
"version" : 2
}

View File

@ -9,6 +9,27 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>SQLite (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>SQLite (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>SQLite (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,39 @@
import SwiftUI
struct ActivityDetailView: View {
let activity: WorkoutActivity
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("Start", date: activity.startDate)
DetailRow("End", date: activity.endDate)
DetailRow("Duration", duration: activity.duration)
DetailRow("Metadata", value: activity.metadata)
}
.navigationTitle("Activity")
}
}
#Preview {
NavigationStack {
ActivityDetailView(activity: .init(
uuid: .init(repeating: 42, count: 3),
isPrimaryActivity: true,
activityType: .running,
locationType: .outdoor,
swimmingLocationType: .unknown,
lapLength: .init(repeating: 42, count: 3),
startDate: .now.addingTimeInterval(-100),
endDate: .now,
duration: 100.0,
metadata: .init(repeating: 42, count: 3))
)
}
}

View File

@ -1,21 +1,38 @@
//
// ContentView.swift
// HealthImport
//
// Created by iMac on 20.01.24.
//
import SwiftUI
import HealthKit
struct ContentView: View {
static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite")
@StateObject var database: HealthDatabase = {
try! .init(fileUrl: databaseFileUrl!)
}()
@State var navigationPath: NavigationPath = .init()
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
NavigationStack(path: $navigationPath) {
VStack {
List {
ForEach(database.workouts) { workout in
NavigationLink(value: workout) {
VStack(alignment: .leading) {
Text(workout.typeString)
Text(workout.dateString)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle("Workouts")
.navigationDestination(for: Workout.self) {
WorkoutDetailView(workout: $0)
.environmentObject(database)
}
}
.padding()
}
}

View File

@ -0,0 +1,45 @@
import Foundation
import SQLite
struct DBWorkout {
private static let table = Table("workouts")
private static let rowDataId = Expression<Int>("data_id")
private static let rowTotalDistance = Expression<Double?>("total_distance")
private static let rowGoalType = Expression<Int?>("goal_type")
private static let rowGoal = Expression<Double?>("goal")
private static let rowCondenserVersion = Expression<Int?>("condenser_version")
private static let rowCondenserDate = Expression<Double?>("condenser_date")
static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(Self.init(row:))
}
let dataId: Int
let totalDistance: Double?
let goalType: Int?
let goal: Double?
let condenserVersion: Int?
let condenserDate: Double?
init(row: Row) {
self.dataId = row[DBWorkout.rowDataId]
self.totalDistance = row[DBWorkout.rowTotalDistance]
self.goalType = row[DBWorkout.rowGoalType]
self.goal = row[DBWorkout.rowGoal]
self.condenserVersion = row[DBWorkout.rowCondenserVersion]
self.condenserDate = row[DBWorkout.rowCondenserDate]
}
}

View File

@ -0,0 +1,79 @@
import Foundation
import SQLite
struct DBWorkoutActivity {
private static let table = Table("workout_activities")
private static let rowId = Expression<Int>("ROWID")
private static let rowUUID = Expression<Data>("uuid")
private static let rowOwnerId = Expression<Int>("owner_id")
private static let rowIsPrimaryActivity = Expression<Bool>("is_primary_activity")
private static let rowActivityType = Expression<Int>("activity_type")
private static let rowLocationType = Expression<Int>("location_type")
private static let rowSwimmingLocationType = Expression<Int>("swimming_location_type")
private static let rowLapLength = Expression<Data?>("lap_length")
private static let rowStartDate = Expression<Double>("start_date")
private static let rowEndDate = Expression<Double>("end_date")
private static let rowDuration = Expression<Double>("duration")
private static let rowMetadata = Expression<Data?>("metadata")
static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(Self.init)
}
static func activities(for workoutId: Int, in database: Connection) throws -> [Self] {
try database.prepare(table.filter(rowOwnerId == workoutId)).map(Self.init)
}
let id: Int
let uuid: Data
let ownerId: Int
let isPrimaryActivity: Bool
let activityType: Int
let locationType: Int
let swimmingLocationType: Int
let lapLength: Data?
#warning("Fix timezone for dates")
let startDate: Double
let endDate: Double
let duration: Double
let metadata: Data?
init(row: Row) {
self.id = row[DBWorkoutActivity.rowId]
self.uuid = row[DBWorkoutActivity.rowUUID]
self.ownerId = row[DBWorkoutActivity.rowOwnerId]
self.isPrimaryActivity = row[DBWorkoutActivity.rowIsPrimaryActivity]
self.activityType = row[DBWorkoutActivity.rowActivityType]
self.locationType = row[DBWorkoutActivity.rowLocationType]
self.swimmingLocationType = row[DBWorkoutActivity.rowSwimmingLocationType]
self.lapLength = row[DBWorkoutActivity.rowLapLength]
self.startDate = row[DBWorkoutActivity.rowStartDate]
self.endDate = row[DBWorkoutActivity.rowEndDate]
self.duration = row[DBWorkoutActivity.rowDuration]
self.metadata = row[DBWorkoutActivity.rowMetadata]
}
}

View File

@ -0,0 +1,53 @@
import Foundation
import SQLite
struct DBWorkoutEvent {
private static let table = Table("workout_events")
private static let rowOwnerId = Expression<Int>("owner_id")
private static let rowDate = Expression<Double>("date")
private static let rowType = Expression<Int>("type")
private static let rowDuration = Expression<Double>("duration")
private static let rowMetadata = Expression<Data?>("metadata")
private static let rowSessionUUID = Expression<Data?>("session_uuid")
private static let rowError = Expression<Data?>("error")
static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(Self.init)
}
static func events(for workoutId: Int, in database: Connection) throws -> [Self] {
try database.prepare(table.filter(rowOwnerId == workoutId)).map(Self.init)
}
let ownerId: Int
let date: Double
let type: Int
let duration: Double
let metadata: Data?
let sessionUUID: Data?
let error: Data?
init(row: Row) {
self.ownerId = row[DBWorkoutEvent.rowOwnerId]
self.date = row[DBWorkoutEvent.rowDate]
self.type = row[DBWorkoutEvent.rowType]
self.duration = row[DBWorkoutEvent.rowDuration]
self.metadata = row[DBWorkoutEvent.rowMetadata]
self.sessionUUID = row[DBWorkoutEvent.rowSessionUUID]
self.error = row[DBWorkoutEvent.rowError]
}
}

View File

@ -0,0 +1,54 @@
import SwiftUI
struct DetailRow: View {
let title: String
let value: String
init(_ title: String, value: String) {
self.title = title
self.value = value
}
init(_ title: String, value: CustomStringConvertible?) {
self.title = title
self.value = value?.description ?? "-"
}
init(_ title: String, date value: Date?) {
self.title = title
self.value = value?.timeAndDateText ?? "-"
}
init(_ title: String, kilometer: Double?) {
self.title = title
self.value = kilometer?.distanceAsKilometer ?? "-"
}
init(_ title: String, duration: TimeInterval?) {
self.title = title
self.value = duration?.durationString ?? "-"
}
var body: some View {
HStack {
Text(title)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(.primary)
}
}
}
#Preview {
List {
DetailRow("Title", value: "Some")
DetailRow("Convertible", value: 123)
DetailRow("Optional", value: nil)
DetailRow("Date", date: .now)
DetailRow("Distance", kilometer: 123.4)
DetailRow("Duration", duration: 678.9)
}
}

View File

@ -0,0 +1,31 @@
import SwiftUI
struct EventDetailView: View {
let event: WorkoutEvent
var body: some View {
List {
DetailRow("Date", date: event.date)
DetailRow("Type", value: event.type)
DetailRow("Duration", duration: event.duration)
DetailRow("Metadata", value: event.metadata)
DetailRow("Session UUID", value: event.sessionUUID)
DetailRow("Error", value: event.error)
}
.navigationTitle("Event")
}
}
#Preview {
NavigationStack {
EventDetailView(event: .init(
date: .now,
type: .pause,
duration: 12.3,
metadata: .init(repeating: 42, count: 2),
sessionUUID: .init(repeating: 42, count: 3),
error: nil)
)
}
}

View File

@ -0,0 +1,37 @@
import Foundation
import SQLite
final class HealthDatabase: ObservableObject {
let fileUrl: URL
let database: Connection
@Published
var workouts: [Workout] = []
init(fileUrl: URL) throws {
self.fileUrl = fileUrl
self.database = try Connection(fileUrl.path)
DispatchQueue.global().async {
self.readAllWorkouts()
}
}
func readAllWorkouts() {
do {
let dbWorkouts = try DBWorkout.readAll(in: database)
let workouts = try dbWorkouts.map { entry in
let events = try DBWorkoutEvent.events(for: entry.dataId, in: database)
let activities = try DBWorkoutActivity.activities(for: entry.dataId, in: database)
return Workout(entry: entry, events: events, activities: activities)
}
DispatchQueue.main.async {
self.workouts = workouts
}
} catch {
print("Failed to read workouts: \(error)")
}
}
}

View File

@ -0,0 +1,93 @@
import Foundation
private let df: DateFormatter = {
let df = DateFormatter()
df.timeZone = .current
df.dateStyle = .short
df.timeStyle = .short
return df
}()
struct Workout {
let id: Int
/// The distance in km (?)
let totalDistance: Double?
let goalType: Int?
let goal: Double?
let condenserVersion: Int?
let condenserDate: Date?
let events: [WorkoutEvent]
let activities: [WorkoutActivity]
var firstActivityDate: Date? {
activities.map { $0.startDate }.min()
}
var firstEventDate: Date? {
events.map { $0.date }.min()
}
var firstAvailableDate: Date? {
[condenserDate, firstEventDate, firstActivityDate].compactMap { $0 }.min()
}
var dateString: String {
guard let firstAvailableDate else {
return "No date"
}
return df.string(from: firstAvailableDate)
}
var typeString: String {
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] = []) {
self.id = id
self.totalDistance = totalDistance
self.goalType = goalType
self.goal = goal
self.condenserVersion = condenserVersion
self.condenserDate = condenserDate
self.events = events
self.activities = activities
}
}
extension Workout {
init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity]) {
self.id = entry.dataId
self.totalDistance = entry.totalDistance
self.goalType = entry.goalType
self.goal = entry.goal
self.condenserVersion = entry.condenserVersion
self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) }
self.events = events.map(WorkoutEvent.init)
self.activities = activities.map(WorkoutActivity.init)
}
}
extension Workout: Identifiable { }
extension Workout: Equatable {
static func == (lhs: Workout, rhs: Workout) -> Bool {
lhs.id == rhs.id
}
}
extension Workout: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -0,0 +1,62 @@
import Foundation
import HealthKit
struct WorkoutActivity {
let uuid: Data
let isPrimaryActivity: Bool
let activityType: HKWorkoutActivityType
let locationType: HKWorkoutSessionLocationType
let swimmingLocationType: HKWorkoutSwimmingLocationType
let lapLength: Data?
let startDate: Date
let endDate: Date
let duration: TimeInterval
let metadata: Data?
}
extension WorkoutActivity {
init(entry: DBWorkoutActivity) {
self.uuid = entry.uuid
self.isPrimaryActivity = entry.isPrimaryActivity
self.activityType = .init(rawValue: UInt(entry.activityType))!
self.locationType = .init(rawValue: entry.locationType)!
self.swimmingLocationType = .init(rawValue: entry.swimmingLocationType)!
self.lapLength = entry.lapLength
self.startDate = Date(timeIntervalSinceReferenceDate: entry.startDate)
self.endDate = Date(timeIntervalSinceReferenceDate: entry.endDate)
self.duration = entry.duration
self.metadata = entry.metadata
}
}
extension WorkoutActivity: Equatable {
static func == (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool {
lhs.uuid == rhs.uuid
}
}
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(uuid)
}
}

View File

@ -0,0 +1,51 @@
import Foundation
import HealthKit
struct WorkoutEvent {
let date: Date
let type: HKWorkoutEventType
let duration: TimeInterval
let metadata: Data?
let sessionUUID: Data?
let error: Data?
}
extension WorkoutEvent {
init(entry: DBWorkoutEvent) {
self.date = Date(timeIntervalSinceReferenceDate: entry.date)
self.type = .init(rawValue: entry.type)!
self.duration = entry.duration
self.metadata = entry.metadata
self.sessionUUID = entry.sessionUUID
self.error = entry.error
}
}
extension WorkoutEvent: Equatable {
static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool {
lhs.date == rhs.date
}
}
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)
}
}

View File

@ -0,0 +1,63 @@
import Foundation
private let dateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .short
return df
}()
private let justDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .none
return df
}()
private let timeFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .none
df.timeStyle = .short
return df
}()
extension Date {
var durationSinceNowText: String {
let secondsAgo = -timeIntervalSinceNow
guard secondsAgo > 2.5 else {
return "Now"
}
guard secondsAgo > 60 else {
let multiples = Int((secondsAgo / 5).rounded()) * 5
return "\(multiples) seconds ago"
}
let minutesAgo = Int((secondsAgo / 60).rounded())
guard minutesAgo > 1 else {
return "\(minutesAgo) minute ago"
}
guard minutesAgo > 60 else {
return "\(minutesAgo) minutes ago"
}
let hoursAgo = Int(Double(minutesAgo / 60).rounded())
guard hoursAgo > 1 else {
return "\(hoursAgo) hour ago"
}
guard hoursAgo > 24 else {
return "\(hoursAgo) hours ago"
}
return dateFormatter.string(from: self)
}
var timeOrDateText: String {
if Calendar.current.isDateInToday(self) {
return timeFormatter.string(from: self)
}
return justDateFormatter.string(from: self)
}
var timeAndDateText: String {
dateFormatter.string(from: self)
}
}

View File

@ -0,0 +1,187 @@
import Foundation
import HealthKit
extension HKWorkoutActivityType: CustomStringConvertible {
public var description: String {
switch self {
case .climbing:
return "Climbing"
case .cycling:
return "Cycling"
case .hiking:
return "Hiking"
case .hockey:
return "Hockey"
case .other:
return "Other"
case .rowing:
return "Rowing"
case .running:
return "Running"
case .swimming:
return "Swimming"
case .yoga:
return "Yoga"
case .walking:
return "Walking"
case .americanFootball:
return "American Football"
case .archery:
return "Archery"
case .australianFootball:
return "Australian Football"
case .badminton:
return "Badminton"
case .baseball:
return "Baseball"
case .basketball:
return "Basketball"
case .bowling:
return "Bowling"
case .boxing:
return "Boxing"
case .cricket:
return "Cricket"
case .crossTraining:
return "Cross Training"
case .curling:
return "Curling"
case .dance:
return "Dance"
case .danceInspiredTraining:
return "Dance Inspired Training"
case .elliptical:
return "Elliptical"
case .equestrianSports:
return "Equestrian Sports"
case .fencing:
return "Fencing"
case .fishing:
return "Fishing"
case .functionalStrengthTraining:
return "Functional Strength Training"
case .golf:
return "Golf"
case .gymnastics:
return "Gymnastics"
case .handball:
return "Handball"
case .hunting:
return "Hunting"
case .lacrosse:
return "Lacrosse"
case .martialArts:
return "Martial Arts"
case .mindAndBody:
return "Mind And Body"
case .mixedMetabolicCardioTraining:
return "Mixed Metabolic Cardio Training"
case .paddleSports:
return "Paddle Sports"
case .play:
return "Play"
case .preparationAndRecovery:
return "Preparation and Recovery"
case .racquetball:
return "Racquetball"
case .rugby:
return "Rugby"
case .sailing:
return "Sailing"
case .skatingSports:
return "Skating Sports"
case .snowSports:
return "Snow Sports"
case .soccer:
return "Soccer"
case .softball:
return "Softball"
case .squash:
return "Squash"
case .stairClimbing:
return "Stair Climbing"
case .surfingSports:
return "Surfing Sports"
case .tableTennis:
return "Table Tennis"
case .tennis:
return "Tennis"
case .trackAndField:
return "Track And Field"
case .traditionalStrengthTraining:
return "Traditional Strength Training"
case .volleyball:
return "Volleyball"
case .waterFitness:
return "Water Fitness"
case .waterPolo:
return "Water Polo"
case .waterSports:
return "Water Sports"
case .wrestling:
return "Wrestling"
case .barre:
return "Barre"
case .coreTraining:
return "Core Training"
case .crossCountrySkiing:
return "Cross Country Skiing"
case .downhillSkiing:
return "Downholl Skiing"
case .flexibility:
return "Flexibility"
case .highIntensityIntervalTraining:
return "High Intensity Interval Training"
case .jumpRope:
return "Jump Rope"
case .kickboxing:
return "Kickboxing"
case .pilates:
return "Pilates"
case .snowboarding:
return "Snowboarding"
case .stairs:
return "Stairs"
case .stepTraining:
return "Step Training"
case .wheelchairWalkPace:
return "Wheelchair Walk Pace"
case .wheelchairRunPace:
return "Wheelchair Run Pace"
case .taiChi:
return "Tai Chi"
case .mixedCardio:
return "Mixed Cardio"
case .handCycling:
return "Hand Cycling"
case .discSports:
return "Disc Sports"
case .fitnessGaming:
return "Fitness Gaming"
case .cardioDance:
return "Cardio Dance"
case .socialDance:
return "Social Dance"
case .pickleball:
return "Pickleball"
case .cooldown:
return "Cooldown"
case .swimBikeRun:
return "Triathlon"
case .transition:
return "Transition"
case .underwaterDiving:
return "Underwater Diving"
@unknown default:
return "\(rawValue)"
}
}
}
extension HKWorkoutActivityType: Comparable {
public static func < (lhs: HKWorkoutActivityType, rhs: HKWorkoutActivityType) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

View File

@ -0,0 +1,20 @@
import Foundation
import HealthKit
extension HKWorkoutEventType: CustomStringConvertible {
public var description: String {
switch self {
case .pause: return "Pause"
case .resume: return "Resume"
case .lap: return "Lap"
case .marker: return "Marker"
case .motionPaused: return "Motion Paused"
case .motionResumed: return "Motino Resumed"
case .segment: return "Segment"
case .pauseOrResumeRequest: return "Pause or Resume Request"
@unknown default:
return "Unknown"
}
}
}

View File

@ -0,0 +1,18 @@
import Foundation
import HealthKit
extension HKWorkoutSessionLocationType: CustomStringConvertible {
public var description: String {
switch self {
case .unknown:
return "Unknown"
case .indoor:
return "Indoor"
case .outdoor:
return "Outdoor"
@unknown default:
return "Unknown default"
}
}
}

View File

@ -0,0 +1,18 @@
import Foundation
import HealthKit
extension HKWorkoutSwimmingLocationType: CustomStringConvertible {
public var description: String {
switch self {
case .unknown:
return "Unknown"
case .pool:
return "Pool"
case .openWater:
return "Open Water"
@unknown default:
return "Unknown default"
}
}
}

View File

@ -0,0 +1,11 @@
import Foundation
extension Optional {
func map<T>(_ transform: (Wrapped) -> T) -> T? {
guard let self else {
return nil
}
return transform(self)
}
}

View File

@ -0,0 +1,33 @@
import Foundation
extension Double {
var roundedInt: Int {
Int(rounded())
}
var distanceAsKilometer: String {
guard self < 0.1 else {
return String(format: "%.2f km", self)
}
return String(format: "%.1f m", self / 1000)
}
}
extension TimeInterval {
var durationString: String {
let totalSeconds = roundedInt
guard totalSeconds >= 60 else {
return String(format: "%.3f s", self)
}
let seconds = totalSeconds % 60
let totalMinutes = totalSeconds / 60
guard totalMinutes >= 60 else {
return String(format: "%d:%02d", totalMinutes, seconds)
}
let minutes = totalMinutes % 60
let totalHours = totalMinutes / 60
return String(format: "%d:%02d:%02d", totalHours, minutes, seconds)
}
}

View File

@ -0,0 +1,83 @@
import SwiftUI
struct WorkoutDetailView: View {
@EnvironmentObject
var database: HealthDatabase
let workout: Workout
var body: some View {
List {
Section("Info") {
DetailRow("ID", value: workout.id)
DetailRow("Total Distance", kilometer: workout.totalDistance)
DetailRow("Goal Type", value: workout.goalType)
DetailRow("Goal", value: workout.goal)
DetailRow("Condenser Version", value: workout.condenserVersion)
DetailRow("Condenser Date", date: workout.condenserDate)
}
if !workout.events.isEmpty {
Section("Events") {
ForEach(workout.events, id: \.date) { event in
NavigationLink(value: event) {
DetailRow(event.type.description, date: event.date)
}
}
}
}
if !workout.activities.isEmpty {
Section("Activities") {
ForEach(workout.activities, id: \.startDate) { activity in
NavigationLink(value: activity) {
DetailRow(activity.activityType.description,
date: activity.startDate)
}
}
}
}
}
.navigationTitle(workout.typeString)
.navigationDestination(for: WorkoutActivity.self) { activity in
ActivityDetailView(activity: activity)
}
.navigationDestination(for: WorkoutEvent.self) { event in
EventDetailView(event: event)
}
}
}
#Preview {
NavigationStack {
WorkoutDetailView(workout: .init(
id: 123,
totalDistance: 234.5,
goalType: 3,
goal: 345.6,
condenserVersion: 1,
condenserDate: .now,
events: [
.init(
date: .now,
type: .pause,
duration: 12.3,
metadata: .init(repeating: 42, count: 2),
sessionUUID: .init(repeating: 42, count: 3),
error: nil)
],
activities: [
.init(
uuid: .init(repeating: 42, count: 3),
isPrimaryActivity: true,
activityType: .running,
locationType: .outdoor,
swimmingLocationType: .unknown,
lapLength: .init(repeating: 42, count: 3),
startDate: .now.addingTimeInterval(-100),
endDate: .now,
duration: 100.0,
metadata: .init(repeating: 42, count: 3))
]))
}
}