Import workouts to health, improve UI
This commit is contained in:
parent
5dcaf0b3d7
commit
a2228d63b2
@ -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;
|
||||
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.253",
|
||||
"green" : "1.000",
|
||||
"red" : "0.789"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "HealthImport.jpg",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
BIN
HealthImport/Assets.xcassets/AppIcon.appiconset/HealthImport.jpg
Normal file
BIN
HealthImport/Assets.xcassets/AppIcon.appiconset/HealthImport.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 461 KiB |
@ -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 ?? "-"
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
13
HealthImport/Support/Color+Extensions.swift
Normal file
13
HealthImport/Support/Color+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
11
HealthImport/Support/Event+Identifiable.swift
Normal file
11
HealthImport/Support/Event+Identifiable.swift
Normal 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
|
||||
}
|
||||
}
|
181
HealthImport/Support/HKWorkoutActivityType+Icon.swift
Normal file
181
HealthImport/Support/HKWorkoutActivityType+Icon.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
16
HealthImport/Support/MKMapRect+Extensions.swift
Normal file
16
HealthImport/Support/MKMapRect+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
7
HealthImport/Support/MetadataKey+String.swift
Normal file
7
HealthImport/Support/MetadataKey+String.swift
Normal 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
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
import HealthKitExtensions
|
||||
import HealthDB
|
||||
import SFSafeSymbols
|
||||
|
||||
|
@ -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 {
|
||||
|
80
HealthImport/UI Elements/HeartRateGraph.swift
Normal file
80
HealthImport/UI Elements/HeartRateGraph.swift
Normal 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()
|
||||
|
||||
}
|
107
HealthImport/UI Elements/HeartRateSample.swift
Normal file
107
HealthImport/UI Elements/HeartRateSample.swift
Normal 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`
|
||||
}
|
||||
}
|
||||
}
|
32
HealthImport/UI Elements/RouteView.swift
Normal file
32
HealthImport/UI Elements/RouteView.swift
Normal 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
|
||||
])
|
||||
}
|
||||
}
|
126
HealthImport/UI Elements/WorkoutListRow.swift
Normal file
126
HealthImport/UI Elements/WorkoutListRow.swift
Normal 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)
|
||||
}
|
72
HealthImport/UI Elements/WorkoutMapView.swift
Normal file
72
HealthImport/UI Elements/WorkoutMapView.swift
Normal 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: [])
|
||||
}
|
||||
}
|
52
HealthImport/UI Elements/WorkoutTypeSelection.swift
Normal file
52
HealthImport/UI Elements/WorkoutTypeSelection.swift
Normal 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)
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user