Import workouts to health, improve UI

This commit is contained in:
Christoph Hagen 2024-03-19 14:25:51 +01:00
parent 5dcaf0b3d7
commit a2228d63b2
26 changed files with 1129 additions and 165 deletions

View File

@ -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 = "<group>"; };
E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Extensions.swift"; sourceTree = "<group>"; };
E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHealthStoreView.swift; sourceTree = "<group>"; };
E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsView.swift; sourceTree = "<group>"; };
E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMetadataView.swift; sourceTree = "<group>"; };
E2E552882BA2194400BF5E9B /* DatabasesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasesTab.swift; sourceTree = "<group>"; };
E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = "<group>"; };
E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Directory.swift"; sourceTree = "<group>"; };
@ -91,6 +98,17 @@
E2E552912BA236D000BF5E9B /* DatabaseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFile.swift; sourceTree = "<group>"; };
E2E552932BA23B8F00BF5E9B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkout+Extensions.swift"; sourceTree = "<group>"; };
E2E552A02BA4B14600BF5E9B /* HeartRateSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateSample.swift; sourceTree = "<group>"; };
E2E552A22BA4B58F00BF5E9B /* HeartRateGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateGraph.swift; sourceTree = "<group>"; };
E2E552A62BA7531C00BF5E9B /* Event+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Identifiable.swift"; sourceTree = "<group>"; };
E2E552AA2BA859A700BF5E9B /* MetadataKey+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+String.swift"; sourceTree = "<group>"; };
E2E552AC2BA98B9B00BF5E9B /* RouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteView.swift; sourceTree = "<group>"; };
E2E552AE2BA98BCF00BF5E9B /* WorkoutMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMapView.swift; sourceTree = "<group>"; };
E2E552B02BA98BE000BF5E9B /* MKMapRect+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MKMapRect+Extensions.swift"; sourceTree = "<group>"; };
E2E552B22BA9A1D500BF5E9B /* WorkoutTypeSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutTypeSelection.swift; sourceTree = "<group>"; };
E2E552B42BA9A5D200BF5E9B /* WorkoutListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutListRow.swift; sourceTree = "<group>"; };
E2E552B62BA9A69400BF5E9B /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
E2E552B82BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Icon.swift"; sourceTree = "<group>"; };
E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -223,6 +245,19 @@
name = Frameworks;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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;

View File

@ -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"
}
},
{

View File

@ -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)")
}
}
}

View File

@ -1,6 +1,15 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.253",
"green" : "1.000",
"red" : "0.789"
}
},
"idiom" : "universal"
}
],

View File

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "HealthImport.jpg",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

View File

@ -16,6 +16,11 @@ struct DetailRow: View {
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 ?? "-"

View File

@ -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)")
}
}
}

View File

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

View File

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

View File

@ -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)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import Foundation
import HealthKitExtensions
import HealthDB
func MetadataKeyName(_ key: String) -> String {
HKMetadataKey(rawValue: key)?.description ?? HKMetadataPrivateKey(rawValue: key)?.description ?? key
}

View File

@ -1,4 +1,6 @@
import SwiftUI
import HealthKit
import HealthKitExtensions
import HealthDB
import SFSafeSymbols

View File

@ -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()
DispatchQueue.main.async {
self.workouts = 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 = 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 {

View File

@ -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<Int> {
(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..<count).map {
let start = now.addingTimeInterval(TimeInterval($0) * interval)
return HRSample(date: start,
duration: interval,
min: ($0 * 5) % 100 + 50,
max: ($0 * 5) % 100 + 70)
}
return HeartRateGraph(measurements: samples)
.padding(5)
.background(Color.gray.opacity(0.7))
.clipShape(RoundedRectangle(cornerSize: .init(width: 8, height: 8)))
.frame(height: 120)
.padding()
}

View File

@ -0,0 +1,107 @@
import Foundation
import HealthKitExtensions
import HealthKit
import Charts
struct HRSample: Identifiable {
let id = UUID()
let startDate: Date
let endDate: Date
let min: Int
let max: Int
init(date: Date, duration: TimeInterval, min: Int, max: Int) {
self.startDate = date
self.endDate = date.addingTimeInterval(duration)
self.min = min
self.max = max
}
static func create(from samples: [HeartRate], start: Date, end: Date, categories: Int) -> [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`
}
}
}

View File

@ -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
])
}
}

View File

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

View File

@ -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: [])
}
}

View File

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

View File

@ -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)
}
if !workout.workoutActivities.isEmpty {
Section("Activities") {
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)
}
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)
}
}
if !workout.workoutEvents.isEmpty {
Section("Events") {
NavigationLink(value: workout.workoutEvents) {
} label: {
DetailRow("Events", value: workout.workoutEvents.count)
}
DisclosureGroup {
ForEach(metadataFields, id:\.key) { (key, value) in
DetailRow(MetadataKeyName(key), value: "\(value)")
}
}
if !workout.metadata.isEmpty {
Section("Metadata") {
NavigationLink {
WorkoutMetadataView(metadata: workout.metadata)
} 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 !heartRateSamples.isEmpty {
Section("Heart Rate") {
DetailRow("Samples", value: "\(heartRateSamplesInDatabase.count)")
DetailRow("Range", value: "\(heartRateSamplesInDatabase.minimumHeartRate) - \(heartRateSamplesInDatabase.maximumHeartRate)")
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 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() {
Task {
checkPermissionsAndSearchHealth()
do {
guard let samples: [HeartRate] = try database.store?.samples(associatedWith: workout) else {
private func addWorkoutToHealth() {
guard let db = database.store else {
return
}
DispatchQueue.main.async {
self.heartRateSamplesInDatabase = samples
self.isProcessingWorkout = true
}
Task {
do {
try await insert(workout: workout, using: db)
} catch {
print("Failed to insert workout: \(error)")
}
DispatchQueue.main.async {
self.isProcessingWorkout = false
}
}
}
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 checkPermissionsAndSearchHealth() {
Task {
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,9 +314,10 @@ 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)
do {
guard let workout = try await store.workouts(activityType: activityType, from: start, to: end)
.first else {
print("No workout found or error")
print("No matching workout found in Health")
return
}
@ -157,15 +325,8 @@ struct WorkoutDetailView: View {
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")
DispatchQueue.main.async {
self.heartRateSamplesInHealth = heartRates
}
} 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())
}
}

View File

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

View File

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