From 8ace8e93197b56e290e83beb7cdccd77cc4a35f0 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sun, 21 Jan 2024 10:31:47 +0100 Subject: [PATCH] Read workouts, events, activities --- HealthImport.xcodeproj/project.pbxproj | 135 +++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 14 ++ .../xcschemes/xcschememanagement.plist | 21 ++ HealthImport/ActivityDetailView.swift | 39 ++++ HealthImport/ContentView.swift | 43 ++-- HealthImport/Database Entries/DBWorkout.swift | 45 +++++ .../Database Entries/DBWorkoutActivity.swift | 79 ++++++++ .../Database Entries/DBWorkoutEvent.swift | 53 +++++ HealthImport/DetailRow.swift | 54 +++++ HealthImport/EventDetailView.swift | 31 +++ HealthImport/HealthDatabase.swift | 37 ++++ HealthImport/Model/Workout.swift | 93 +++++++++ HealthImport/Model/WorkoutActivity.swift | 62 ++++++ HealthImport/Model/WorkoutEvent.swift | 51 +++++ HealthImport/Support/Date+Extensions.swift | 63 ++++++ .../HKWorkoutActivityType+Extensions.swift | 187 ++++++++++++++++++ .../HKWorkoutEventType+Extensions.swift | 20 ++ ...orkoutSessionLocationType+Extensions.swift | 18 ++ ...rkoutSwimmingLocationType+Extensions.swift | 18 ++ .../Support/Optional+Extensions.swift | 11 ++ .../Support/TimeInterval+Extensions.swift | 33 ++++ HealthImport/WorkoutDetailView.swift | 83 ++++++++ 22 files changed, 1177 insertions(+), 13 deletions(-) create mode 100644 HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 HealthImport/ActivityDetailView.swift create mode 100644 HealthImport/Database Entries/DBWorkout.swift create mode 100644 HealthImport/Database Entries/DBWorkoutActivity.swift create mode 100644 HealthImport/Database Entries/DBWorkoutEvent.swift create mode 100644 HealthImport/DetailRow.swift create mode 100644 HealthImport/EventDetailView.swift create mode 100644 HealthImport/HealthDatabase.swift create mode 100644 HealthImport/Model/Workout.swift create mode 100644 HealthImport/Model/WorkoutActivity.swift create mode 100644 HealthImport/Model/WorkoutEvent.swift create mode 100644 HealthImport/Support/Date+Extensions.swift create mode 100644 HealthImport/Support/HKWorkoutActivityType+Extensions.swift create mode 100644 HealthImport/Support/HKWorkoutEventType+Extensions.swift create mode 100644 HealthImport/Support/HKWorkoutSessionLocationType+Extensions.swift create mode 100644 HealthImport/Support/HKWorkoutSwimmingLocationType+Extensions.swift create mode 100644 HealthImport/Support/Optional+Extensions.swift create mode 100644 HealthImport/Support/TimeInterval+Extensions.swift create mode 100644 HealthImport/WorkoutDetailView.swift diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 7f91261..0062392 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -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 = ""; }; 8850025E2B5C273E00E7D4DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 885002612B5C273E00E7D4DB /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = ""; }; + 885002702B5C299900E7D4DB /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = ""; }; + 885002782B5C320400E7D4DB /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; + 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkout.swift; sourceTree = ""; }; + 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutEvent.swift; sourceTree = ""; }; + 8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = ""; }; + 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEvent.swift; sourceTree = ""; }; + 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Extensions.swift"; sourceTree = ""; }; + 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutActivity.swift; sourceTree = ""; }; + 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivity.swift; sourceTree = ""; }; + 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = ""; }; + 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + 885002902B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutEventType+Extensions.swift"; sourceTree = ""; }; + 885002922B5D129300E7D4DB /* ActivityDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityDetailView.swift; sourceTree = ""; }; + 885002942B5D147100E7D4DB /* DetailRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailRow.swift; sourceTree = ""; }; + 885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSessionLocationType+Extensions.swift"; sourceTree = ""; }; + 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = ""; }; + 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; + 8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = ""; }; /* 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 = ""; @@ -67,6 +116,48 @@ path = "Preview Content"; sourceTree = ""; }; + 8850026A2B5C276B00E7D4DB /* Resources */ = { + isa = PBXGroup; + children = ( + 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */, + ); + path = Resources; + sourceTree = ""; + }; + 885002802B5C37A800E7D4DB /* Database Entries */ = { + isa = PBXGroup; + children = ( + 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */, + 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */, + 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */, + ); + path = "Database Entries"; + sourceTree = ""; + }; + 885002812B5C37B700E7D4DB /* Model */ = { + isa = PBXGroup; + children = ( + 8850027E2B5C36A700E7D4DB /* Workout.swift */, + 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, + 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */, + ); + path = Model; + sourceTree = ""; + }; + 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 = ""; + }; /* 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 */; } diff --git a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..d1a572d --- /dev/null +++ b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/HealthImport.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist b/HealthImport.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist index 3e9755d..7af10f9 100644 --- a/HealthImport.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/HealthImport.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,27 @@ orderHint 0 + SQLite (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + SQLite (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + SQLite (Playground).xcscheme + + isShown + + orderHint + 1 + diff --git a/HealthImport/ActivityDetailView.swift b/HealthImport/ActivityDetailView.swift new file mode 100644 index 0000000..bd4c036 --- /dev/null +++ b/HealthImport/ActivityDetailView.swift @@ -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)) + ) + } +} diff --git a/HealthImport/ContentView.swift b/HealthImport/ContentView.swift index e2f5652..4236022 100644 --- a/HealthImport/ContentView.swift +++ b/HealthImport/ContentView.swift @@ -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() } } diff --git a/HealthImport/Database Entries/DBWorkout.swift b/HealthImport/Database Entries/DBWorkout.swift new file mode 100644 index 0000000..1a6acbb --- /dev/null +++ b/HealthImport/Database Entries/DBWorkout.swift @@ -0,0 +1,45 @@ +import Foundation +import SQLite + +struct DBWorkout { + + private static let table = Table("workouts") + + private static let rowDataId = Expression("data_id") + + private static let rowTotalDistance = Expression("total_distance") + + private static let rowGoalType = Expression("goal_type") + + private static let rowGoal = Expression("goal") + + private static let rowCondenserVersion = Expression("condenser_version") + + private static let rowCondenserDate = Expression("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] + } +} + diff --git a/HealthImport/Database Entries/DBWorkoutActivity.swift b/HealthImport/Database Entries/DBWorkoutActivity.swift new file mode 100644 index 0000000..f7f78eb --- /dev/null +++ b/HealthImport/Database Entries/DBWorkoutActivity.swift @@ -0,0 +1,79 @@ +import Foundation +import SQLite + +struct DBWorkoutActivity { + + private static let table = Table("workout_activities") + + private static let rowId = Expression("ROWID") + + private static let rowUUID = Expression("uuid") + + private static let rowOwnerId = Expression("owner_id") + + private static let rowIsPrimaryActivity = Expression("is_primary_activity") + + private static let rowActivityType = Expression("activity_type") + + private static let rowLocationType = Expression("location_type") + + private static let rowSwimmingLocationType = Expression("swimming_location_type") + + private static let rowLapLength = Expression("lap_length") + + private static let rowStartDate = Expression("start_date") + + private static let rowEndDate = Expression("end_date") + + private static let rowDuration = Expression("duration") + + private static let rowMetadata = Expression("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] + } +} diff --git a/HealthImport/Database Entries/DBWorkoutEvent.swift b/HealthImport/Database Entries/DBWorkoutEvent.swift new file mode 100644 index 0000000..a37ec43 --- /dev/null +++ b/HealthImport/Database Entries/DBWorkoutEvent.swift @@ -0,0 +1,53 @@ +import Foundation +import SQLite + +struct DBWorkoutEvent { + + private static let table = Table("workout_events") + + private static let rowOwnerId = Expression("owner_id") + + private static let rowDate = Expression("date") + + private static let rowType = Expression("type") + + private static let rowDuration = Expression("duration") + + private static let rowMetadata = Expression("metadata") + + private static let rowSessionUUID = Expression("session_uuid") + + private static let rowError = Expression("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] + } +} diff --git a/HealthImport/DetailRow.swift b/HealthImport/DetailRow.swift new file mode 100644 index 0000000..9c10ba8 --- /dev/null +++ b/HealthImport/DetailRow.swift @@ -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) + } +} diff --git a/HealthImport/EventDetailView.swift b/HealthImport/EventDetailView.swift new file mode 100644 index 0000000..0efd064 --- /dev/null +++ b/HealthImport/EventDetailView.swift @@ -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) + ) + } +} diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift new file mode 100644 index 0000000..012af32 --- /dev/null +++ b/HealthImport/HealthDatabase.swift @@ -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)") + } + } +} diff --git a/HealthImport/Model/Workout.swift b/HealthImport/Model/Workout.swift new file mode 100644 index 0000000..a084c92 --- /dev/null +++ b/HealthImport/Model/Workout.swift @@ -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) + } +} diff --git a/HealthImport/Model/WorkoutActivity.swift b/HealthImport/Model/WorkoutActivity.swift new file mode 100644 index 0000000..62a49f4 --- /dev/null +++ b/HealthImport/Model/WorkoutActivity.swift @@ -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) + } +} diff --git a/HealthImport/Model/WorkoutEvent.swift b/HealthImport/Model/WorkoutEvent.swift new file mode 100644 index 0000000..b451052 --- /dev/null +++ b/HealthImport/Model/WorkoutEvent.swift @@ -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) + } +} diff --git a/HealthImport/Support/Date+Extensions.swift b/HealthImport/Support/Date+Extensions.swift new file mode 100644 index 0000000..c3515ce --- /dev/null +++ b/HealthImport/Support/Date+Extensions.swift @@ -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) + } +} diff --git a/HealthImport/Support/HKWorkoutActivityType+Extensions.swift b/HealthImport/Support/HKWorkoutActivityType+Extensions.swift new file mode 100644 index 0000000..37d5e62 --- /dev/null +++ b/HealthImport/Support/HKWorkoutActivityType+Extensions.swift @@ -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 + } +} diff --git a/HealthImport/Support/HKWorkoutEventType+Extensions.swift b/HealthImport/Support/HKWorkoutEventType+Extensions.swift new file mode 100644 index 0000000..9fc8863 --- /dev/null +++ b/HealthImport/Support/HKWorkoutEventType+Extensions.swift @@ -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" + } + } +} diff --git a/HealthImport/Support/HKWorkoutSessionLocationType+Extensions.swift b/HealthImport/Support/HKWorkoutSessionLocationType+Extensions.swift new file mode 100644 index 0000000..29db586 --- /dev/null +++ b/HealthImport/Support/HKWorkoutSessionLocationType+Extensions.swift @@ -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" + } + } +} diff --git a/HealthImport/Support/HKWorkoutSwimmingLocationType+Extensions.swift b/HealthImport/Support/HKWorkoutSwimmingLocationType+Extensions.swift new file mode 100644 index 0000000..319de46 --- /dev/null +++ b/HealthImport/Support/HKWorkoutSwimmingLocationType+Extensions.swift @@ -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" + } + } +} diff --git a/HealthImport/Support/Optional+Extensions.swift b/HealthImport/Support/Optional+Extensions.swift new file mode 100644 index 0000000..898b57e --- /dev/null +++ b/HealthImport/Support/Optional+Extensions.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Optional { + + func map(_ transform: (Wrapped) -> T) -> T? { + guard let self else { + return nil + } + return transform(self) + } +} diff --git a/HealthImport/Support/TimeInterval+Extensions.swift b/HealthImport/Support/TimeInterval+Extensions.swift new file mode 100644 index 0000000..247677b --- /dev/null +++ b/HealthImport/Support/TimeInterval+Extensions.swift @@ -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) + } +} diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift new file mode 100644 index 0000000..1462b45 --- /dev/null +++ b/HealthImport/WorkoutDetailView.swift @@ -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)) + ])) + } +}