diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 33f5e5f..27b3e47 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -40,8 +40,6 @@ E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */; }; E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */; }; E2A38EA82B9C6EE800BAD02E /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */; }; - E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */; }; - E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */; }; E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552882BA2194400BF5E9B /* DatabasesTab.swift */; }; E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */; }; E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */; }; @@ -49,6 +47,17 @@ E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552912BA236D000BF5E9B /* DatabaseFile.swift */; }; E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */; }; E2E5529E2BA47BA600BF5E9B /* HealthDB in Frameworks */ = {isa = PBXBuildFile; productRef = E2E5529D2BA47BA600BF5E9B /* HealthDB */; }; + E2E552A12BA4B14600BF5E9B /* HeartRateSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552A02BA4B14600BF5E9B /* HeartRateSample.swift */; }; + E2E552A32BA4B58F00BF5E9B /* HeartRateGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552A22BA4B58F00BF5E9B /* HeartRateGraph.swift */; }; + E2E552A72BA7531C00BF5E9B /* Event+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552A62BA7531C00BF5E9B /* Event+Identifiable.swift */; }; + E2E552AB2BA859A700BF5E9B /* MetadataKey+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552AA2BA859A700BF5E9B /* MetadataKey+String.swift */; }; + E2E552AD2BA98B9B00BF5E9B /* RouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552AC2BA98B9B00BF5E9B /* RouteView.swift */; }; + E2E552AF2BA98BCF00BF5E9B /* WorkoutMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552AE2BA98BCF00BF5E9B /* WorkoutMapView.swift */; }; + E2E552B12BA98BE000BF5E9B /* MKMapRect+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552B02BA98BE000BF5E9B /* MKMapRect+Extensions.swift */; }; + E2E552B32BA9A1D600BF5E9B /* WorkoutTypeSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552B22BA9A1D500BF5E9B /* WorkoutTypeSelection.swift */; }; + E2E552B52BA9A5D200BF5E9B /* WorkoutListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552B42BA9A5D200BF5E9B /* WorkoutListRow.swift */; }; + E2E552B72BA9A69400BF5E9B /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552B62BA9A69400BF5E9B /* Color+Extensions.swift */; }; + E2E552B92BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552B82BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift */; }; E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; }; E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -82,8 +91,6 @@ E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Extensions.swift"; sourceTree = ""; }; E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHealthStoreView.swift; sourceTree = ""; }; - E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsView.swift; sourceTree = ""; }; - E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMetadataView.swift; sourceTree = ""; }; E2E552882BA2194400BF5E9B /* DatabasesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasesTab.swift; sourceTree = ""; }; E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = ""; }; E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Directory.swift"; sourceTree = ""; }; @@ -91,6 +98,17 @@ E2E552912BA236D000BF5E9B /* DatabaseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFile.swift; sourceTree = ""; }; E2E552932BA23B8F00BF5E9B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkout+Extensions.swift"; sourceTree = ""; }; + E2E552A02BA4B14600BF5E9B /* HeartRateSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateSample.swift; sourceTree = ""; }; + E2E552A22BA4B58F00BF5E9B /* HeartRateGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateGraph.swift; sourceTree = ""; }; + E2E552A62BA7531C00BF5E9B /* Event+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Identifiable.swift"; sourceTree = ""; }; + E2E552AA2BA859A700BF5E9B /* MetadataKey+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+String.swift"; sourceTree = ""; }; + E2E552AC2BA98B9B00BF5E9B /* RouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteView.swift; sourceTree = ""; }; + E2E552AE2BA98BCF00BF5E9B /* WorkoutMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMapView.swift; sourceTree = ""; }; + E2E552B02BA98BE000BF5E9B /* MKMapRect+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MKMapRect+Extensions.swift"; sourceTree = ""; }; + E2E552B22BA9A1D500BF5E9B /* WorkoutTypeSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutTypeSelection.swift; sourceTree = ""; }; + E2E552B42BA9A5D200BF5E9B /* WorkoutListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutListRow.swift; sourceTree = ""; }; + E2E552B62BA9A69400BF5E9B /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; + E2E552B82BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Icon.swift"; sourceTree = ""; }; E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -142,8 +160,7 @@ E2E552872BA2193B00BF5E9B /* Tabs */, E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */, 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */, - E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */, - E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */, + E2E5529F2BA4B13100BF5E9B /* UI Elements */, 885002922B5D129300E7D4DB /* ActivityDetailView.swift */, E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */, E201EC7E2B629B4C005B83D3 /* SampleListView.swift */, @@ -193,6 +210,11 @@ E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */, E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */, E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */, + E2E552A62BA7531C00BF5E9B /* Event+Identifiable.swift */, + E2E552AA2BA859A700BF5E9B /* MetadataKey+String.swift */, + E2E552B02BA98BE000BF5E9B /* MKMapRect+Extensions.swift */, + E2E552B62BA9A69400BF5E9B /* Color+Extensions.swift */, + E2E552B82BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift */, ); path = Support; sourceTree = ""; @@ -223,6 +245,19 @@ name = Frameworks; sourceTree = ""; }; + E2E5529F2BA4B13100BF5E9B /* UI Elements */ = { + isa = PBXGroup; + children = ( + E2E552A02BA4B14600BF5E9B /* HeartRateSample.swift */, + E2E552A22BA4B58F00BF5E9B /* HeartRateGraph.swift */, + E2E552AC2BA98B9B00BF5E9B /* RouteView.swift */, + E2E552AE2BA98BCF00BF5E9B /* WorkoutMapView.swift */, + E2E552B22BA9A1D500BF5E9B /* WorkoutTypeSelection.swift */, + E2E552B42BA9A5D200BF5E9B /* WorkoutListRow.swift */, + ); + path = "UI Elements"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -315,35 +350,44 @@ E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */, E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */, E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */, - E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */, E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */, 8850025D2B5C273C00E7D4DB /* WorkoutTab.swift in Sources */, 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */, 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */, E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */, E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */, + E2E552B92BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift in Sources */, 8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */, + E2E552B72BA9A69400BF5E9B /* Color+Extensions.swift in Sources */, E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */, E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, + E2E552A32BA4B58F00BF5E9B /* HeartRateGraph.swift in Sources */, + E2E552AF2BA98BCF00BF5E9B /* WorkoutMapView.swift in Sources */, E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */, E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */, E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */, - E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */, + E2E552A72BA7531C00BF5E9B /* Event+Identifiable.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, + E2E552A12BA4B14600BF5E9B /* HeartRateSample.swift in Sources */, E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */, E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */, E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */, E2E552902BA236A000BF5E9B /* DatabaseList.swift in Sources */, + E2E552B12BA98BE000BF5E9B /* MKMapRect+Extensions.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */, E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */, E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */, + E2E552B32BA9A1D600BF5E9B /* WorkoutTypeSelection.swift in Sources */, E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */, + E2E552AB2BA859A700BF5E9B /* MetadataKey+String.swift in Sources */, E20881D52B76944A00D41D95 /* Test.swift in Sources */, E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */, + E2E552B52BA9A5D200BF5E9B /* WorkoutListRow.swift in Sources */, E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */, + E2E552AD2BA98B9B00BF5E9B /* RouteView.swift in Sources */, 8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -475,6 +519,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = HealthImport/HealthImport.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -510,6 +555,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = HealthImport/HealthImport.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b071860..4c6aaf3 100644 --- a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/christophhagen/HealthDB", "state" : { "branch" : "main", - "revision" : "90b616517861733c1f52ef6f0aaf42849b44e09f" + "revision" : "50d572b72d0b52370f8b76bb41cf3f8c3e249c8e" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/christophhagen/HealthKitExtensions", "state" : { "branch" : "main", - "revision" : "a7f6612e959a76f211d8526adfd9b5bf88442bb8" + "revision" : "60f797e058ba77622de41198b59040d6404b6783" } }, { diff --git a/HealthImport/ActivityDetailView.swift b/HealthImport/ActivityDetailView.swift index cb3cca0..43adb89 100644 --- a/HealthImport/ActivityDetailView.swift +++ b/HealthImport/ActivityDetailView.swift @@ -1,5 +1,6 @@ import SwiftUI import HealthKit +import HealthKitExtensions import HealthDB import CoreLocation @@ -53,7 +54,7 @@ struct ActivityDetailView: View { if !(activity.metadata?.isEmpty ?? true) { Section("Metadata") { ForEach(metadata, id: \.key) { (key, value) in - DetailRow(key, value: "\(value)") + DetailRow(MetadataKeyName(key), value: "\(value)") } } } diff --git a/HealthImport/Assets.xcassets/AccentColor.colorset/Contents.json b/HealthImport/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..a3761cd 100644 --- a/HealthImport/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/HealthImport/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.253", + "green" : "1.000", + "red" : "0.789" + } + }, "idiom" : "universal" } ], diff --git a/HealthImport/Assets.xcassets/AppIcon.appiconset/Contents.json b/HealthImport/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..0934931 100644 --- a/HealthImport/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/HealthImport/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "HealthImport.jpg", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/HealthImport/Assets.xcassets/AppIcon.appiconset/HealthImport.jpg b/HealthImport/Assets.xcassets/AppIcon.appiconset/HealthImport.jpg new file mode 100644 index 0000000..8331ad8 Binary files /dev/null and b/HealthImport/Assets.xcassets/AppIcon.appiconset/HealthImport.jpg differ diff --git a/HealthImport/DetailRow.swift b/HealthImport/DetailRow.swift index 9c10ba8..eabf51d 100644 --- a/HealthImport/DetailRow.swift +++ b/HealthImport/DetailRow.swift @@ -15,7 +15,12 @@ struct DetailRow: View { self.title = title self.value = value?.description ?? "-" } - + + init(_ title: String, time value: Date?) { + self.title = title + self.value = value?.timeText ?? "-" + } + init(_ title: String, date value: Date?) { self.title = title self.value = value?.timeAndDateText ?? "-" diff --git a/HealthImport/EventDetailView.swift b/HealthImport/EventDetailView.swift index f4b968a..806aa3f 100644 --- a/HealthImport/EventDetailView.swift +++ b/HealthImport/EventDetailView.swift @@ -1,5 +1,7 @@ import SwiftUI import HealthKit +import HealthKitExtensions +import HealthDB struct EventDetailView: View { @@ -18,7 +20,7 @@ struct EventDetailView: View { //DetailRow("Error", value: event.error) Section("Metadata") { ForEach(metadata, id: \.key) { (key, value) in - DetailRow(key, value: "\(value)") + DetailRow(MetadataKeyName(key), value: "\(value)") } } } diff --git a/HealthImport/HealthImportApp.swift b/HealthImport/HealthImportApp.swift index a13f19f..0dbc3b2 100644 --- a/HealthImport/HealthImportApp.swift +++ b/HealthImport/HealthImportApp.swift @@ -15,13 +15,15 @@ struct HealthImportApp: App { var database = Database() @State - private var selection: TabSelection = .databases + private var selection: TabSelection = .workouts @State private var databaseList = DatabaseList() init() { performStartup() + selection = .workouts + print("Startup finished, tab: \(selection)") } private func performStartup() { @@ -39,7 +41,8 @@ struct HealthImportApp: App { return } DispatchQueue.main.async { - selection = .workouts + print("Setting selection to workouts") + self.selection = .workouts } } } @@ -57,6 +60,7 @@ struct HealthImportApp: App { .tabItem {Label("Databases", systemSymbol: .archivebox) } .tag(TabSelection.databases) } + .preferredColorScheme(.dark) } } } diff --git a/HealthImport/Support/Color+Extensions.swift b/HealthImport/Support/Color+Extensions.swift new file mode 100644 index 0000000..b1f542f --- /dev/null +++ b/HealthImport/Support/Color+Extensions.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftUI + +extension Color { + + static var glowingGreen: Color { + .init(hue: 0.25, saturation: 0.7, brightness: 0.95) + } + + static var lightGray: Color { + .secondary.opacity(0.9) + } +} diff --git a/HealthImport/Support/Date+Extensions.swift b/HealthImport/Support/Date+Extensions.swift index 6674114..b795a93 100644 --- a/HealthImport/Support/Date+Extensions.swift +++ b/HealthImport/Support/Date+Extensions.swift @@ -64,6 +64,10 @@ extension Date { return justDateFormatter.string(from: self) } + var timeText: String { + timeFormatter.string(from: self) + } + var timeAndDateText: String { dateFormatter.timeZone = .current return dateFormatter.string(from: self) diff --git a/HealthImport/Support/Event+Identifiable.swift b/HealthImport/Support/Event+Identifiable.swift new file mode 100644 index 0000000..966751d --- /dev/null +++ b/HealthImport/Support/Event+Identifiable.swift @@ -0,0 +1,11 @@ +import Foundation +import HealthKit + +extension HKWorkoutEvent: Identifiable { + + public var id: Int { + Int(dateInterval.start.timeIntervalSinceReferenceDate) << 16 + + Int(dateInterval.duration) << 8 + + type.rawValue + } +} diff --git a/HealthImport/Support/HKWorkoutActivityType+Icon.swift b/HealthImport/Support/HKWorkoutActivityType+Icon.swift new file mode 100644 index 0000000..d0d84b2 --- /dev/null +++ b/HealthImport/Support/HKWorkoutActivityType+Icon.swift @@ -0,0 +1,181 @@ +import Foundation +import HealthKit +import SFSafeSymbols + +extension HKWorkoutActivityType { + + func icon(indoor: Bool) -> SFSymbol { + switch self { + case .americanFootball: + return .figureAmericanFootball + case .archery: + return .figureArchery + case .australianFootball: + return .figureAustralianFootball + case .badminton: + return .figureBadminton + case .baseball: + return .figureBaseball + case .basketball: + return .figureBasketball + case .bowling: + return .figureBowling + case .boxing: + return .figureBoxing + case .climbing: + return .figureClimbing + case .cricket: + return .figureCricket + case .crossTraining: + return .figureCrossTraining + case .curling: + return .figureCurling + case .cycling: + return indoor ? .figureIndoorCycle : .figureOutdoorCycle + case .dance: + return .figureDance + case .danceInspiredTraining: + return .figurePlay + case .elliptical: + return .figureElliptical + case .equestrianSports: + return .figureEquestrianSports + case .fencing: + return .figureFencing + case .fishing: + return .figureFishing + case .functionalStrengthTraining: + return .figureStrengthtrainingFunctional + case .golf: + return .figureGolf + case .gymnastics: + return .figureGymnastics + case .handball: + return .figureHandball + case .hiking: + return .figureHiking + case .hockey: + return .figureHockey + case .hunting: + return .figureHunting + case .lacrosse: + return .figureLacrosse + case .martialArts: + return .figureMartialArts + case .mindAndBody: + return .figureMindAndBody + case .mixedMetabolicCardioTraining: + return .figureMixedCardio + case .paddleSports: + return .heart + case .play: + return .figurePlay + case .preparationAndRecovery: + return .figureCooldown + case .racquetball: + return .figureRacquetball + case .rowing: + return .figureRower + case .rugby: + return .figureRugby + case .running: + return .figureRun + case .sailing: + return .figureSailing + case .skatingSports: + return .figureSkating + case .snowSports: + return .figureSnowboarding + case .soccer: + return .figureSoccer + case .softball: + return .figureSoftball + case .squash: + return .figureSquash + case .stairClimbing: + return .figureStairStepper + case .surfingSports: + return .figureSurfing + case .swimming: + return indoor ? .figurePoolSwim : .figureOpenWaterSwim + case .tableTennis: + return .figureTableTennis + case .tennis: + return .figureTennis + case .trackAndField: + return .figureTrackAndField + case .traditionalStrengthTraining: + return .figureStrengthtrainingTraditional + case .volleyball: + return .figureVolleyball + case .walking: + return .figureWalk + case .waterFitness: + return .figureWaterFitness + case .waterPolo: + return .figureWaterpolo + case .waterSports: + return .figureWaterFitness + case .wrestling: + return .figureWrestling + case .yoga: + return .figureYoga + case .barre: + return .figureBarre + case .coreTraining: + return .figureCoreTraining + case .crossCountrySkiing: + return .figureSkiingCrosscountry + case .downhillSkiing: + return .figureSkiingDownhill + case .flexibility: + return .figureFlexibility + case .highIntensityIntervalTraining: + return .figureHighintensityIntervaltraining + case .jumpRope: + return .figureJumprope + case .kickboxing: + return .figureKickboxing + case .pilates: + return .figurePilates + case .snowboarding: + return .figureSnowboarding + case .stairs: + return .figureStairs + case .stepTraining: + return .figureStepTraining + case .wheelchairWalkPace: + return .figureRoll + case .wheelchairRunPace: + return .figureRollRunningpace + case .taiChi: + return .figureTaichi + case .mixedCardio: + return .figureMixedCardio + case .handCycling: + return .figureHandCycling + case .discSports: + return .figureDiscSports + case .fitnessGaming: + return .gamecontroller + case .cardioDance: + return .figureDance + case .socialDance: + return .figureDance + case .pickleball: + return .figurePickleball + case .cooldown: + return .figureCooldown + case .swimBikeRun: + return .figureRunSquareStack + case .transition: + return .arrowshapeRight + case .underwaterDiving: + return .waterWavesAndArrowDown + case .other: + return .dumbbell + @unknown default: + return .dumbbell + } + } +} diff --git a/HealthImport/Support/MKMapRect+Extensions.swift b/HealthImport/Support/MKMapRect+Extensions.swift new file mode 100644 index 0000000..ac54064 --- /dev/null +++ b/HealthImport/Support/MKMapRect+Extensions.swift @@ -0,0 +1,16 @@ +import MapKit + +extension MKMapRect { + + mutating func extend(by factor: Double) { + let dx = self.width * (1 - factor) / 2 + let dy = height * (1 - factor) / 2 + self = insetBy(dx: dx, dy: dy) + } + + func extended(by factor: Double) -> MKMapRect { + let dx = self.width * (1 - factor) / 2 + let dy = height * (1 - factor) / 2 + return insetBy(dx: dx, dy: dy) + } +} diff --git a/HealthImport/Support/MetadataKey+String.swift b/HealthImport/Support/MetadataKey+String.swift new file mode 100644 index 0000000..6625a5a --- /dev/null +++ b/HealthImport/Support/MetadataKey+String.swift @@ -0,0 +1,7 @@ +import Foundation +import HealthKitExtensions +import HealthDB + +func MetadataKeyName(_ key: String) -> String { + HKMetadataKey(rawValue: key)?.description ?? HKMetadataPrivateKey(rawValue: key)?.description ?? key +} diff --git a/HealthImport/Tabs/DatabasesTab.swift b/HealthImport/Tabs/DatabasesTab.swift index 14140e7..9310148 100644 --- a/HealthImport/Tabs/DatabasesTab.swift +++ b/HealthImport/Tabs/DatabasesTab.swift @@ -1,4 +1,6 @@ import SwiftUI +import HealthKit +import HealthKitExtensions import HealthDB import SFSafeSymbols diff --git a/HealthImport/Tabs/WorkoutTab.swift b/HealthImport/Tabs/WorkoutTab.swift index cccec81..2680476 100644 --- a/HealthImport/Tabs/WorkoutTab.swift +++ b/HealthImport/Tabs/WorkoutTab.swift @@ -10,24 +10,39 @@ struct WorkoutTab: View { @State var navigationPath: NavigationPath = .init() - @State var workouts: [Workout] = [] + @State var filteredActivityType: HKWorkoutActivityType? = nil + + @State var workouts: [(title: String, workouts: [Workout])] = [] + + @State var workoutTypeCounts: [(type: HKWorkoutActivityType, count: Int)] = [] var body: some View { NavigationStack(path: $navigationPath) { VStack { List { - ForEach(workouts) { workout in - NavigationLink(value: workout) { - VStack(alignment: .leading) { - Text(workout.typeString) - .font(.headline) - Text(workout.dateString) - .font(.caption) - .foregroundStyle(.secondary) + WorkoutTypeSelection(selected: $filteredActivityType, available: $workoutTypeCounts) + .listRowSeparator(.hidden) + ForEach(workouts, id: \.title) { month in + Section { + ForEach(month.workouts) { workout in + WorkoutListRow(workout: workout) + .overlay( + NavigationLink(value: workout) { } + .opacity(0)) + .listRowSeparator(.hidden) + .listRowInsets(.init(top: 0, leading: 16, bottom: 5, trailing: 16)) } + } header: { + Text(month.title) + .font(.title3) + .fontWeight(.bold) + .foregroundStyle(.primary) + } footer: { + Text("") } } } + .listStyle(.plain) } .navigationTitle("Workouts") .navigationDestination(for: Workout.self) { @@ -36,18 +51,19 @@ struct WorkoutTab: View { .refreshable { reloadAsync() } - .toolbar { - ToolbarItem { - NavigationLink { - SearchHealthStoreView() - } label: { - Image(systemSymbol: .magnifyingglass) - } - } - }.onChange(of: database.file, perform: { value in + .onChange(of: database.file, perform: { value in reload() }) + .onChange(of: filteredActivityType, perform: { _ in + reload() + }) + .onAppear(perform: { + if workouts.isEmpty { + reload() + } + }) } + .preferredColorScheme(.dark) } private func reload() { @@ -60,14 +76,52 @@ struct WorkoutTab: View { guard let store = database.store else { DispatchQueue.main.async { self.workouts = [] + self.workoutTypeCounts = [] } return } + loadWorkoutTypes(in: store) + loadWorkouts(in: store) + } + + private func loadWorkouts(in store: HealthDatabase) { do { - let workouts = try store.workouts() + let workouts: [Workout] + if let filteredActivityType { + workouts = try store.workouts(type: filteredActivityType) + } else { + workouts = try store.workouts() + } + print("Loaded \(workouts.count) workouts") + + let calendar = Calendar.current + var sortedIntoMonths = [(title: String, workouts: [Workout])]() + var currentMonth: String? = nil + var currentWorkouts = [Workout]() + for workout in workouts.sorted(ascending: false, using: { $0.endDate }) { + let date = workout.endDate + let month = calendar.component(.month, from: date) + let year = calendar.component(.year, from: date) + let title = "\(calendar.monthSymbols[month-1]) \(year)" + guard let lastMonth = currentMonth else { + currentMonth = title + currentWorkouts = [workout] + continue + } + guard lastMonth == title else { + sortedIntoMonths.append((lastMonth, currentWorkouts)) + currentMonth = title + currentWorkouts = [workout] + continue + } + currentWorkouts.append(workout) + } + if let currentMonth, !currentWorkouts.isEmpty { + sortedIntoMonths.append((currentMonth, currentWorkouts)) + } + DispatchQueue.main.async { - self.workouts = workouts - print("Loaded \(workouts.count) workouts") + self.workouts = sortedIntoMonths } } catch { print("Failed to load workouts: \(error)") @@ -76,6 +130,22 @@ struct WorkoutTab: View { } } } + + private func loadWorkoutTypes(in store: HealthDatabase) { + do { + let types = try store.store.workoutTypeFrequencies() + .sorted(ascending: false) { $0.value } + .map { (type: $0.key, count: $0.value) } + DispatchQueue.main.async { + self.workoutTypeCounts = types + } + } catch { + print("Failed to get workout frequencies: \(error)") + DispatchQueue.main.async { + self.workoutTypeCounts = [] + } + } + } } #Preview { diff --git a/HealthImport/UI Elements/HeartRateGraph.swift b/HealthImport/UI Elements/HeartRateGraph.swift new file mode 100644 index 0000000..6f64777 --- /dev/null +++ b/HealthImport/UI Elements/HeartRateGraph.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Charts + +struct HeartRateGraph: View { + + let measurements: [HRSample] + + let width: CGFloat + + init(measurements: [HRSample], width: CGFloat = 5.0) { + self.measurements = measurements + self.width = width + self.maximumValue = measurements.map { $0.max }.max() ?? 100 + self.minimumValue = measurements.map { $0.min }.min() ?? 80 + } + + private let maximumValue: Int + + private let minimumValue: Int + + var range: ClosedRange { + (minimumValue-10)...(maximumValue+10) + } + + var body: some View { + ZStack { + Chart(measurements, id: \.id) { + BarMark(x: .value("Time Start", $0.startDate), + yStart: .value("BPM Min", $0.min - 1), + yEnd: .value("BPM Max", $0.max + 1), + width: .inset(1)) + .clipShape(Capsule()) + .foregroundStyle(.red) + } + .chartYScale(domain: range) + .chartXAxis { + AxisMarks { + AxisValueLabel() + AxisGridLine(stroke: .init()) + } + } + .chartYAxis { + AxisMarks { + AxisValueLabel() + } + } + .chartYAxis(.hidden) + HStack { + Spacer() + VStack(alignment: .trailing) { + Text("\(maximumValue)") + Spacer() + Text("\(minimumValue)") + } + .foregroundStyle(Color.primary.opacity(0.8)) + .font(.caption) + } + } + } +} + +#Preview { + let now = Date.now + let count = 50 + let interval = TimeInterval(600) + let samples = (0.. [HRSample] { + let interval = end.timeIntervalSince(start) / Double(categories) + var categories: [HRSample] = [] + var categoryEndDuration = interval + var minimum = Int.max + var maximum = Int.min + var hasSamplesInCategory = false + + let unit = HKUnit.count().unitDivided(by: .minute()) + + func advanceToNextCategory() { + defer { categoryEndDuration += interval } + guard hasSamplesInCategory else { + return + } + categories.append(.init( + date: start.addingTimeInterval(categoryEndDuration - (interval * 0.48)), + duration: interval * 0.96, + min: minimum, + max: maximum)) + minimum = Int.max + maximum = Int.min + hasSamplesInCategory = false + } + + for sample in samples.sorted(ascending: true, using: { $0.startDate }) { + let timestamp = sample.startDate.timeIntervalSince(start) + while timestamp > categoryEndDuration { + advanceToNextCategory() + } + let value = sample.quantity.doubleValue(for: unit).roundedInt + minimum = Swift.min(minimum, value) + maximum = Swift.max(maximum, value) + hasSamplesInCategory = true + } + + advanceToNextCategory() + return categories + } + + func test(start: Date, end: Date) { + let duration = end.timeIntervalSince(start) + let interval = DateComponents(second: Int(duration) / 20) + + let quantityType = HKObjectType.quantityType( + forIdentifier: .heartRate + )! + + let query = HKStatisticsCollectionQuery( + quantityType: quantityType, + quantitySamplePredicate: nil, + options: [.discreteMax, .discreteMin], + anchorDate: start, + intervalComponents: interval + ) + + query.initialResultsHandler = { _, results, error in + var weeklyData: [Date: (Double, Double)] = [:] + + results!.enumerateStatistics( + from: start, + to: end + ) { statistics, _ in + if let minValue = statistics.minimumQuantity() { + if let maxValue = statistics.maximumQuantity() { + let minHeartRate = minValue.doubleValue( + for: HKUnit(from: "count/min") + ) + let maxHeartRate = maxValue.doubleValue( + for: HKUnit(from: "count/min") + ) + + weeklyData[statistics.startDate] = ( + minHeartRate, maxHeartRate + ) + } + } + } + + // use `weeklyData` + } + } +} diff --git a/HealthImport/UI Elements/RouteView.swift b/HealthImport/UI Elements/RouteView.swift new file mode 100644 index 0000000..54790f5 --- /dev/null +++ b/HealthImport/UI Elements/RouteView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import CoreLocation + +struct RouteView: View { + + let locations: [CLLocation] + + let height: CGFloat = 200 + + let vPadding: CGFloat = 16 + + let hPadding: CGFloat = 6 + + var body: some View { + GeometryReader { geo in + WorkoutMapView(locations: locations) + .frame(width: geo.size.width, + height: height) + .disabled(true) + } + .frame(height: height) + .listRowSeparator(.hidden) + } +} + +struct RouteView_Previews: PreviewProvider { + static var previews: some View { + RouteView(locations: [ + .mock + ]) + } +} diff --git a/HealthImport/UI Elements/WorkoutListRow.swift b/HealthImport/UI Elements/WorkoutListRow.swift new file mode 100644 index 0000000..dfb9c34 --- /dev/null +++ b/HealthImport/UI Elements/WorkoutListRow.swift @@ -0,0 +1,126 @@ +import SwiftUI +import HealthKit +import HealthDB +import HealthKitExtensions +import SFSafeSymbols + +struct WorkoutListRow: View { + + let workout: Workout + + var indoor: Bool { + guard let isIndoor: Bool = workout.metadata[.indoorWorkout] else { + return false + } + return isIndoor + } + + var type: HKWorkoutActivityType { + if #available(iOS 17.0, *) { + if let type: HKWorkoutActivityType = workout.metadata.activityType { + return type + } + } + return workout.workoutActivityType + } + + var titleText: String { + if let distance = workout.totalDistance, distance > 0 { + return (distance * 1000).lengthAsMeter + } + return workout.duration.durationString + } + + @State var existsInHealth: Bool = false + + var body: some View { + HStack { + ZStack(alignment: .center) { + Image(systemSymbol: type.icon(indoor: indoor)) + .foregroundStyle(Color.accentColor) + .padding(12) + LinearGradient(colors: [Color.accentColor.opacity(0.0), Color.accentColor.opacity(0.4)], startPoint: .bottomLeading, endPoint: .topTrailing) + .clipShape(Circle()) + .frame(width: 50, height: 50) + }.frame(width: 50, height: 50) + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text(workout.typeString) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + Spacer() + if existsInHealth { + Image(systemSymbol: .heartCircleFill) + .foregroundStyle(.red) + } + } + HStack(alignment: .lastTextBaseline) { + Text(titleText) + .font(.title) + .foregroundStyle(Color.accentColor) + + Spacer() + Text(workout.endDate.timeOrDateText) + .font(.caption) + .foregroundStyle(.secondary) + } + + } + } + .padding(16) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 10.0)) + .onAppear(perform: findMatchingHealthWorkout) + } + + private func findMatchingHealthWorkout() { + Task { + await searchHealth() + } + } + + private func searchHealth() async { + guard HKHealthStore.isHealthDataAvailable() else { + return + } + let found = await hasWorkoutInHealth() + DispatchQueue.main.async { + self.existsInHealth = found + } + } + + private func hasWorkoutInHealth() async -> Bool { + guard let activityType = workout.workoutActivities.first?.workoutConfiguration.activityType else { + return false + } + let store = HealthStore() + switch store.authorizationStatus(for: HKWorkout.self) { + case .notDetermined: + return false + case .sharingAuthorized, .sharingDenied: + break + @unknown default: + print("Unknown permission for workouts") + return false + } + + let start = workout.startDate.addingTimeInterval(-60) + let end = workout.endDate.addingTimeInterval(60) + do { + guard let _ = try await store.workouts(activityType: activityType, from: start, to: end) + .first else { + return false + } + return true + } catch { + print("Failed to search for matching workout: \(error)") + return false + } + } +} + +#Preview { + WorkoutListRow(workout: .mock1) + .preferredColorScheme(.dark) +} diff --git a/HealthImport/UI Elements/WorkoutMapView.swift b/HealthImport/UI Elements/WorkoutMapView.swift new file mode 100644 index 0000000..e34e991 --- /dev/null +++ b/HealthImport/UI Elements/WorkoutMapView.swift @@ -0,0 +1,72 @@ +import SwiftUI +import MapKit + +struct WorkoutMapView: UIViewRepresentable { + + let locations: [CLLocation] + + private var track: MKPolyline { + let coordinates = locations.map { $0.coordinate } + return .init(coordinates: coordinates, + count: coordinates.count) + } + + var boundingRect: MKMapRect { + track.boundingMapRect.extended(by: 1.2) + } + + private var region: MKCoordinateRegion { + .init(boundingRect) + } + + func makeUIView(context: Context) -> MKMapView { + let mapView = MKMapView() + mapView.region = region + mapView.delegate = context.coordinator + mapView.addOverlay(track) + return mapView + } + + func updateUIView(_ view: MKMapView, context: Context) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, MKMapViewDelegate { + var parent: WorkoutMapView + + init(_ parent: WorkoutMapView) { + self.parent = parent + } + + func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + let renderer = MKPolylineRenderer(overlay: overlay) + renderer.lineWidth = 4.0 + renderer.strokeColor = .systemBlue + return renderer + } + + private func regionDidChangeFromUserInteraction(_ mapView: MKMapView) -> Bool { + let view = mapView.subviews[0] + // Look through gesture recognizers to determine whether this region change is from user interaction + guard let gestureRecognizers = view.gestureRecognizers else { + return false + } + for recognizer in gestureRecognizers { + if recognizer.state == .began || recognizer.state == .ended { + return true + } + } + return false + } + } +} + +struct WorkoutMapView_Previews: PreviewProvider { + static var previews: some View { + WorkoutMapView(locations: []) + } +} diff --git a/HealthImport/UI Elements/WorkoutTypeSelection.swift b/HealthImport/UI Elements/WorkoutTypeSelection.swift new file mode 100644 index 0000000..698cf8b --- /dev/null +++ b/HealthImport/UI Elements/WorkoutTypeSelection.swift @@ -0,0 +1,52 @@ +import SwiftUI +import HealthKit + +struct WorkoutTypeSelection: View { + + @Binding + var selected: HKWorkoutActivityType? + + @Binding + var available: [(type: HKWorkoutActivityType, count: Int)] + + var body: some View { + ScrollView(.horizontal) { + HStack { + Text("All") + .padding(.horizontal) + .padding(.vertical, 8) + .background(selected == nil ? Color.accentColor : Color.gray.opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 50)) + .foregroundStyle(selected == nil ? Color.black : Color.white) + .onTapGesture { + selected = nil + } + //.padding(.leading) + ForEach(available, id: \.type) { element in + Text("\(element.type)") + .padding(.horizontal) + .padding(.vertical, 8) + .background(element.type == selected ? Color.accentColor : Color.gray.opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 50)) + .foregroundStyle(element.type == selected ? Color.black : Color.white) + .onTapGesture { + selected = element.type + } + } + } + .font(.headline) + } + .scrollIndicators(.hidden) + } +} + +#Preview { + WorkoutTypeSelection( + selected: .constant(.running), + available: .constant([ + (type: .running, count: 13), + (type: .soccer, count: 10), + (type: .cycling, count: 7), + ])) + .preferredColorScheme(.dark) +} diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index 85e527f..86974bd 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -14,119 +14,286 @@ struct WorkoutDetailView: View { let workout: Workout + private let heartRateCategoryCount = 110 + @State private var healthWorkout: HKWorkout? @State - var heartRateSamplesInHealth: [HeartRate] = [] + private var heartRateSamples: [HeartRate] = [] @State - var heartRateSamplesInDatabase: [HeartRate] = [] + private var samples: [HRSample] = [] @State - var locationSamples: [CLLocation] = [] + private var locationSamples: [CLLocation] = [] + + @State + private var privateMetadata: [String : Any] = [:] + + private var metadataFields: [(key: String, value: Any)] { + workout.metadata.sorted { $0.key } + } + + private var privateMetadataFields: [(key: String, value: Any)] { + privateMetadata.sorted { $0.key } + } + + @State + private var isProcessingWorkout = false + + private var averageHeartRate: Int { + let sum = heartRateSamples.reduce(0) { $0 + $1.beatsPerMinute } + return (Double(sum) / Double(heartRateSamples.count)).roundedInt + } var body: some View { List { - Section("Info") { - DetailRow("ID", value: workout.id) - DetailRow("Total Distance", kilometer: workout.totalDistance) - DetailRow("Duration", duration: workout.duration) - DetailRow("Goal", value: workout.goal) + if healthWorkout != nil { + HStack { + Spacer() + VStack { + Text("Matching workout found in Health") + .foregroundStyle(.black) + } + Spacer() + } + .padding(.vertical, 8) + .listRowBackground(Color.accentColor) + } else { + Button(action: addWorkoutToHealth) { + HStack { + Spacer() + if isProcessingWorkout { + ProgressView() + .progressViewStyle(.circular) + Text("Adding workout to health...") + .foregroundStyle(.accent) + } else { + Text("Add workout to health") + .foregroundStyle(.accent) + } + Spacer() + } + .padding(.vertical, 8) + } + .disabled(isProcessingWorkout) } - if !workout.workoutActivities.isEmpty { - Section("Activities") { + Section("Info") { + DetailRow("Start", date: workout.startDate) + DetailRow("Duration", duration: workout.duration) + DetailRow("Total Distance", kilometer: workout.totalDistance) + if let goal = workout.goal { + DetailRow("Goal", value: goal) + } + } + Section { + DisclosureGroup { ForEach(workout.workoutActivities, id: \.startDate) { activity in NavigationLink(value: activity) { DetailRow(activity.workoutConfiguration.activityType.description, - date: activity.startDate) + time: activity.startDate) } } + } label: { + DetailRow("Activities", value: workout.workoutActivities.count) + } + DisclosureGroup { + ForEach(workout.workoutEvents) { event in + NavigationLink(value: event) { + DetailRow(event.type.description, time: event.dateInterval.start) + } + } + } label: { + DetailRow("Events", value: workout.workoutEvents.count) + } + DisclosureGroup { + ForEach(metadataFields, id:\.key) { (key, value) in + DetailRow(MetadataKeyName(key), value: "\(value)") + } + } label: { + DetailRow("Metadata", value: workout.metadata.count) + } + DisclosureGroup { + ForEach(privateMetadataFields, id:\.key) { (key, value) in + DetailRow(MetadataKeyName(key), value: "\(value)") + } + } label: { + DetailRow("Private Metadata", value: privateMetadata.count) } } - if !workout.workoutEvents.isEmpty { - Section("Events") { - NavigationLink(value: workout.workoutEvents) { - DetailRow("Events", value: workout.workoutEvents.count) + if !heartRateSamples.isEmpty { + Section("Heart Rate") { + VStack(alignment: .leading) { + HeartRateGraph(measurements: samples) + .frame(height: 110) + HStack { + Text("\(averageHeartRate) BPM AVG") + .foregroundStyle(.red) + .fontWeight(.semibold) + Spacer() + Text("\(heartRateSamples.count) samples") + .foregroundStyle(.secondary) + } + .font(.caption) + .textCase(.uppercase) } } } - if !workout.metadata.isEmpty { - Section("Metadata") { - NavigationLink { - WorkoutMetadataView(metadata: workout.metadata) - } label: { - DetailRow("Metadata", value: workout.metadata.count) - } - } - } - Section("Heart Rate") { - DetailRow("Samples", value: "\(heartRateSamplesInDatabase.count)") - DetailRow("Range", value: "\(heartRateSamplesInDatabase.minimumHeartRate) - \(heartRateSamplesInDatabase.maximumHeartRate)") - } - if let healthWorkout { - Section("Matching health workout") { - DetailRow("Duration", value: healthWorkout.duration.durationString) - DetailRow("Distance", kilometer: healthWorkout.distance?.doubleValue(for: .meterUnit(with: .kilo))) - DetailRow("Heart rate samples", value: "\(heartRateSamplesInHealth.count)") - DetailRow("Heart rate range", value: "\(heartRateSamplesInHealth.minimumHeartRate) - \(heartRateSamplesInHealth.maximumHeartRate)") - } - } - - if !locationSamples.isEmpty { - Section("Locations") { - DetailRow("Count", value: "\(locationSamples.count)") + Section("Route") { + Text("") + .frame(height: 150) + .listRowBackground(WorkoutMapView(locations: locationSamples)) } } } + .listStyle(SidebarListStyle()) .navigationTitle(workout.typeString) .navigationDestination(for: HKWorkoutActivity.self) { activity in ActivityDetailView(workout: workout, activity: activity) } - .navigationDestination(for: [HKWorkoutEvent].self) { - WorkoutEventsView(events: $0) + .navigationDestination(for: HKWorkoutEvent.self) { event in + EventDetailView(event: event) } .onAppear(perform: loadSamples) } - private func loadSamples() { + private func addWorkoutToHealth() { + guard let db = database.store else { + return + } + DispatchQueue.main.async { + self.isProcessingWorkout = true + } Task { - checkPermissionsAndSearchHealth() do { - guard let samples: [HeartRate] = try database.store?.samples(associatedWith: workout) else { - return - } - DispatchQueue.main.async { - self.heartRateSamplesInDatabase = samples - } - print("Loaded \(samples.count) heart rate samples from database") + try await insert(workout: workout, using: db) } catch { - print("Failed to load heart rate samples from database: \(error)") + print("Failed to insert workout: \(error)") + } + DispatchQueue.main.async { + self.isProcessingWorkout = false } } } - private func checkPermissionsAndSearchHealth() { - Task { - do { - try await checkPermissionsAndFindWorkout() - } catch { - print("Failed to search for workout: \(error)") + private func insert(workout: Workout, using db: HealthDatabase) async throws { + try await store.requestAuthorization(toShare: HKWorkout.self, read: HKWorkout.self) + if store.authorizationStatus(for: HKWorkout.self) == .notDetermined || + store.authorizationStatus(for: HKWorkoutRoute.self) == .notDetermined { + print("Requesting workout sharing permission") + try await store.requestAuthorization(toShare: HKWorkout.self, HKWorkoutRoute.self, read: HKWorkout.self, HKWorkoutRoute.self) + } + guard store.authorizationStatus(for: HKWorkout.self) == .sharingAuthorized else { + print("No sharing permission for workouts") + return + } + guard store.authorizationStatus(for: HKWorkoutRoute.self) == .sharingAuthorized else { + print("No sharing permission for workout routes") + return + } + do { + print("Getting samples") + let samples = try db.store.samples(associatedWith: workout) + let route = try db.store.route(associatedWith: workout) + .map { try db.store.locations(associatedWith: $0) } ?? [] + + print("Saving workout in Health: \(samples.count) samples, \(route.count) locations") + + let savedWorkout = try await workout.insert( + into: store.store, + samples: samples, + route: route) + DispatchQueue.main.async { + self.healthWorkout = savedWorkout } + print("Saved workout in Health") + let energySamples: [ActiveEnergyBurned] = try await store.samples(associatedWith: savedWorkout) + print("Found \(energySamples.count) energy samples") + if let route = try await store.route(associatedWith: savedWorkout) { + let locations = try await store.locations(associatedWith: route) + print("Found \(locations.count)/\(locationSamples.count) locations associated with saved workout") + } else { + print("No route associated with saved workout") + } + } catch { + print("Failed to add workout to health: \(error)") + } + } + + private func loadSamples() { + Task { + await checkPermissionsAndSearchHealth() + } + guard let db = database.store else { return } + Task { + await loadHeartRateSamples(db: db) + await loadLocationSamples(db: db) + await loadPrivateMetadata(db: db) + } + } + + private func loadHeartRateSamples(db: HealthDatabase) async { + do { + let samples: [HeartRate] = try db.samples(associatedWith: workout) + let graphSamples = HRSample.create(from: samples, start: workout.startDate, end: workout.endDate, categories: heartRateCategoryCount) + DispatchQueue.main.async { + self.heartRateSamples = samples + self.samples = graphSamples + } + print("Loaded \(samples.count) heart rate samples from database") + } catch { + print("Failed to load heart rate samples from database: \(error)") + } + } + + private func loadLocationSamples(db: HealthDatabase) async { + do { + guard let route = try db.route(associatedWith: workout) else { + print("No route associated with workout") + return + } + let locations = try db.locations(associatedWith: route) + DispatchQueue.main.async { + self.locationSamples = locations + } + print("Loaded \(locations.count) locations from database") + } catch { + print("Failed to load locations or route from database: \(error)") + } + } + + private func loadPrivateMetadata(db: HealthDatabase) async { + do { + let metadata = try db.store.metadata(for: workout.uuid, includePrivateMetadata: true) + .filter { $0.key.hasPrefix("_HKPrivate") } + DispatchQueue.main.async { + self.privateMetadata = metadata + } + print("Loaded \(metadata.count) private metadata fields") + } catch { + print("Failed to load private metadata from database: \(error)") + } + } + + private func checkPermissionsAndSearchHealth() async { + guard HKHealthStore.isHealthDataAvailable() else { + return + } + do { + try await checkPermissionsAndFindWorkout() + } catch { + print("Failed to search for workout: \(error)") } } private func checkPermissionsAndFindWorkout() async throws { - switch store.authorizationStatus(for: HKWorkout.self) { case .notDetermined: try await requestWorkoutPermission() try await checkPermissionsAndFindWorkout() - case .sharingAuthorized: - await findWorkoutInHealth() - case .sharingDenied: - print("No permission to write workouts") + case .sharingAuthorized, .sharingDenied: await findWorkoutInHealth() return @unknown default: @@ -147,25 +314,19 @@ struct WorkoutDetailView: View { let start = workout.startDate.addingTimeInterval(-60) let end = workout.endDate.addingTimeInterval(60) - guard let workout = try? await store.workouts(activityType: activityType, from: start, to: end) - .first else { - print("No workout found or error") - return - } - - print("Found matching workout in health") - DispatchQueue.main.async { - self.healthWorkout = workout - } - do { - let heartRates: [HeartRate] = try await store.samples(associatedWith: workout) - print("Found \(heartRates.count) heart rate samples in Health") + guard let workout = try await store.workouts(activityType: activityType, from: start, to: end) + .first else { + print("No matching workout found in Health") + return + } + + print("Found matching workout in health") DispatchQueue.main.async { - self.heartRateSamplesInHealth = heartRates + self.healthWorkout = workout } } catch { - print("Failed to get heart rates for workout: \(error)") + print("Failed to search for matching workout: \(error)") } } } @@ -174,6 +335,7 @@ struct WorkoutDetailView: View { return NavigationStack { WorkoutDetailView(workout: .mock1) .environmentObject(Database.mock) + .preferredColorScheme(.dark) } } @@ -203,3 +365,15 @@ private extension HeartRate { quantity.doubleValue(for: .count().unitDivided(by: .minute())).roundedInt } } + +extension HeartRate { + + var sampleWithoutPrivateMetadata: HeartRate { + .init(quantity: quantity, + start: startDate, + end: endDate, + uuid: externalUUID, + device: device, + metadata: metadata?.removingPrivateFields()) + } +} diff --git a/HealthImport/WorkoutEventsView.swift b/HealthImport/WorkoutEventsView.swift deleted file mode 100644 index 055b7e1..0000000 --- a/HealthImport/WorkoutEventsView.swift +++ /dev/null @@ -1,26 +0,0 @@ -import SwiftUI -import HealthDB -import HealthKit - -struct WorkoutEventsView: View { - - let events: [HKWorkoutEvent] - - var body: some View { - List { - ForEach(events) { event in - NavigationLink(value: event) { - DetailRow(event.type.description, date: event.dateInterval.start) - } - } - } - .navigationTitle("Events") - .navigationDestination(for: HKWorkoutEvent.self) { event in - EventDetailView(event: event) - } - } -} - -#Preview { - WorkoutEventsView(events: Workout.mock1.workoutEvents) -} diff --git a/HealthImport/WorkoutMetadataView.swift b/HealthImport/WorkoutMetadataView.swift deleted file mode 100644 index a56e485..0000000 --- a/HealthImport/WorkoutMetadataView.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI -import HealthDB - -struct WorkoutMetadataView: View { - - let metadata: [String : Any] - - private var metadataFields: [(key: String, value: Any)] { - metadata.sorted { $0.key } - } - - var body: some View { - List { - ForEach(metadataFields, id:\.key) { (key, value) in - let keyString = HKMetadataKey.describe(key: key) ?? HKMetadataPrivateKey.describe(key: key) ?? key - DetailRow(keyString, value: "\(value)") - } - } - .navigationTitle("Metadata") - } -} - -#Preview { - WorkoutMetadataView(metadata: Workout.mock1.metadata) -}