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 */; };
|
E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */; };
|
||||||
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */; };
|
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */; };
|
||||||
E2A38EA82B9C6EE800BAD02E /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */; };
|
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 */; };
|
E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552882BA2194400BF5E9B /* DatabasesTab.swift */; };
|
||||||
E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5528B2BA21C0700BF5E9B /* HealthDatabase.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 */; };
|
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 */; };
|
E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552912BA236D000BF5E9B /* DatabaseFile.swift */; };
|
||||||
E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */; };
|
E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */; };
|
||||||
E2E5529E2BA47BA600BF5E9B /* HealthDB in Frameworks */ = {isa = PBXBuildFile; productRef = E2E5529D2BA47BA600BF5E9B /* HealthDB */; };
|
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 */; };
|
E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; };
|
||||||
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; };
|
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -82,8 +91,6 @@
|
|||||||
E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -142,8 +160,7 @@
|
|||||||
E2E552872BA2193B00BF5E9B /* Tabs */,
|
E2E552872BA2193B00BF5E9B /* Tabs */,
|
||||||
E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */,
|
E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */,
|
||||||
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
|
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
|
||||||
E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */,
|
E2E5529F2BA4B13100BF5E9B /* UI Elements */,
|
||||||
E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */,
|
|
||||||
885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
|
885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
|
||||||
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */,
|
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */,
|
||||||
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */,
|
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */,
|
||||||
@ -193,6 +210,11 @@
|
|||||||
E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */,
|
E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */,
|
||||||
E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */,
|
E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */,
|
||||||
E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.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;
|
path = Support;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -223,6 +245,19 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -315,35 +350,44 @@
|
|||||||
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */,
|
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */,
|
||||||
E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */,
|
E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */,
|
||||||
E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */,
|
E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */,
|
||||||
E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */,
|
|
||||||
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */,
|
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */,
|
||||||
8850025D2B5C273C00E7D4DB /* WorkoutTab.swift in Sources */,
|
8850025D2B5C273C00E7D4DB /* WorkoutTab.swift in Sources */,
|
||||||
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */,
|
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */,
|
||||||
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
|
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
|
||||||
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */,
|
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */,
|
||||||
E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */,
|
E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */,
|
||||||
|
E2E552B92BA9A77D00BF5E9B /* HKWorkoutActivityType+Icon.swift in Sources */,
|
||||||
8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */,
|
8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */,
|
||||||
|
E2E552B72BA9A69400BF5E9B /* Color+Extensions.swift in Sources */,
|
||||||
E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */,
|
E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */,
|
||||||
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
|
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
|
||||||
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
|
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
|
||||||
|
E2E552A32BA4B58F00BF5E9B /* HeartRateGraph.swift in Sources */,
|
||||||
|
E2E552AF2BA98BCF00BF5E9B /* WorkoutMapView.swift in Sources */,
|
||||||
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
|
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
|
||||||
E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */,
|
E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */,
|
||||||
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
|
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
|
||||||
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
|
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
|
||||||
E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */,
|
E2E552A72BA7531C00BF5E9B /* Event+Identifiable.swift in Sources */,
|
||||||
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
|
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
|
||||||
|
E2E552A12BA4B14600BF5E9B /* HeartRateSample.swift in Sources */,
|
||||||
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */,
|
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */,
|
||||||
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,
|
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,
|
||||||
E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */,
|
E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */,
|
||||||
E2E552902BA236A000BF5E9B /* DatabaseList.swift in Sources */,
|
E2E552902BA236A000BF5E9B /* DatabaseList.swift in Sources */,
|
||||||
|
E2E552B12BA98BE000BF5E9B /* MKMapRect+Extensions.swift in Sources */,
|
||||||
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
|
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
|
||||||
E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */,
|
E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */,
|
||||||
E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */,
|
E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */,
|
||||||
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */,
|
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */,
|
||||||
|
E2E552B32BA9A1D600BF5E9B /* WorkoutTypeSelection.swift in Sources */,
|
||||||
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
|
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
|
||||||
|
E2E552AB2BA859A700BF5E9B /* MetadataKey+String.swift in Sources */,
|
||||||
E20881D52B76944A00D41D95 /* Test.swift in Sources */,
|
E20881D52B76944A00D41D95 /* Test.swift in Sources */,
|
||||||
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */,
|
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */,
|
||||||
|
E2E552B52BA9A5D200BF5E9B /* WorkoutListRow.swift in Sources */,
|
||||||
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */,
|
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */,
|
||||||
|
E2E552AD2BA98B9B00BF5E9B /* RouteView.swift in Sources */,
|
||||||
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,
|
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -475,6 +519,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = HealthImport/HealthImport.entitlements;
|
CODE_SIGN_ENTITLEMENTS = HealthImport/HealthImport.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@ -510,6 +555,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = HealthImport/HealthImport.entitlements;
|
CODE_SIGN_ENTITLEMENTS = HealthImport/HealthImport.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"location" : "https://github.com/christophhagen/HealthDB",
|
"location" : "https://github.com/christophhagen/HealthDB",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "90b616517861733c1f52ef6f0aaf42849b44e09f"
|
"revision" : "50d572b72d0b52370f8b76bb41cf3f8c3e249c8e"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -16,7 +16,7 @@
|
|||||||
"location" : "https://github.com/christophhagen/HealthKitExtensions",
|
"location" : "https://github.com/christophhagen/HealthKitExtensions",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "a7f6612e959a76f211d8526adfd9b5bf88442bb8"
|
"revision" : "60f797e058ba77622de41198b59040d6404b6783"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HealthKit
|
import HealthKit
|
||||||
|
import HealthKitExtensions
|
||||||
import HealthDB
|
import HealthDB
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
@ -53,7 +54,7 @@ struct ActivityDetailView: View {
|
|||||||
if !(activity.metadata?.isEmpty ?? true) {
|
if !(activity.metadata?.isEmpty ?? true) {
|
||||||
Section("Metadata") {
|
Section("Metadata") {
|
||||||
ForEach(metadata, id: \.key) { (key, value) in
|
ForEach(metadata, id: \.key) { (key, value) in
|
||||||
DetailRow(key, value: "\(value)")
|
DetailRow(MetadataKeyName(key), value: "\(value)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"colors" : [
|
"colors" : [
|
||||||
{
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.253",
|
||||||
|
"green" : "1.000",
|
||||||
|
"red" : "0.789"
|
||||||
|
}
|
||||||
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "HealthImport.jpg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"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 ?? "-"
|
self.value = value?.description ?? "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(_ title: String, time value: Date?) {
|
||||||
|
self.title = title
|
||||||
|
self.value = value?.timeText ?? "-"
|
||||||
|
}
|
||||||
|
|
||||||
init(_ title: String, date value: Date?) {
|
init(_ title: String, date value: Date?) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.value = value?.timeAndDateText ?? "-"
|
self.value = value?.timeAndDateText ?? "-"
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HealthKit
|
import HealthKit
|
||||||
|
import HealthKitExtensions
|
||||||
|
import HealthDB
|
||||||
|
|
||||||
struct EventDetailView: View {
|
struct EventDetailView: View {
|
||||||
|
|
||||||
@ -18,7 +20,7 @@ struct EventDetailView: View {
|
|||||||
//DetailRow("Error", value: event.error)
|
//DetailRow("Error", value: event.error)
|
||||||
Section("Metadata") {
|
Section("Metadata") {
|
||||||
ForEach(metadata, id: \.key) { (key, value) in
|
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()
|
var database = Database()
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var selection: TabSelection = .databases
|
private var selection: TabSelection = .workouts
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var databaseList = DatabaseList()
|
private var databaseList = DatabaseList()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
performStartup()
|
performStartup()
|
||||||
|
selection = .workouts
|
||||||
|
print("Startup finished, tab: \(selection)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performStartup() {
|
private func performStartup() {
|
||||||
@ -39,7 +41,8 @@ struct HealthImportApp: App {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
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) }
|
.tabItem {Label("Databases", systemSymbol: .archivebox) }
|
||||||
.tag(TabSelection.databases)
|
.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)
|
return justDateFormatter.string(from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var timeText: String {
|
||||||
|
timeFormatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
var timeAndDateText: String {
|
var timeAndDateText: String {
|
||||||
dateFormatter.timeZone = .current
|
dateFormatter.timeZone = .current
|
||||||
return dateFormatter.string(from: self)
|
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 SwiftUI
|
||||||
|
import HealthKit
|
||||||
|
import HealthKitExtensions
|
||||||
import HealthDB
|
import HealthDB
|
||||||
import SFSafeSymbols
|
import SFSafeSymbols
|
||||||
|
|
||||||
|
@ -10,24 +10,39 @@ struct WorkoutTab: View {
|
|||||||
|
|
||||||
@State var navigationPath: NavigationPath = .init()
|
@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 {
|
var body: some View {
|
||||||
NavigationStack(path: $navigationPath) {
|
NavigationStack(path: $navigationPath) {
|
||||||
VStack {
|
VStack {
|
||||||
List {
|
List {
|
||||||
ForEach(workouts) { workout in
|
WorkoutTypeSelection(selected: $filteredActivityType, available: $workoutTypeCounts)
|
||||||
NavigationLink(value: workout) {
|
.listRowSeparator(.hidden)
|
||||||
VStack(alignment: .leading) {
|
ForEach(workouts, id: \.title) { month in
|
||||||
Text(workout.typeString)
|
Section {
|
||||||
.font(.headline)
|
ForEach(month.workouts) { workout in
|
||||||
Text(workout.dateString)
|
WorkoutListRow(workout: workout)
|
||||||
.font(.caption)
|
.overlay(
|
||||||
.foregroundStyle(.secondary)
|
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")
|
.navigationTitle("Workouts")
|
||||||
.navigationDestination(for: Workout.self) {
|
.navigationDestination(for: Workout.self) {
|
||||||
@ -36,18 +51,19 @@ struct WorkoutTab: View {
|
|||||||
.refreshable {
|
.refreshable {
|
||||||
reloadAsync()
|
reloadAsync()
|
||||||
}
|
}
|
||||||
.toolbar {
|
.onChange(of: database.file, perform: { value in
|
||||||
ToolbarItem {
|
|
||||||
NavigationLink {
|
|
||||||
SearchHealthStoreView()
|
|
||||||
} label: {
|
|
||||||
Image(systemSymbol: .magnifyingglass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onChange(of: database.file, perform: { value in
|
|
||||||
reload()
|
reload()
|
||||||
})
|
})
|
||||||
|
.onChange(of: filteredActivityType, perform: { _ in
|
||||||
|
reload()
|
||||||
|
})
|
||||||
|
.onAppear(perform: {
|
||||||
|
if workouts.isEmpty {
|
||||||
|
reload()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reload() {
|
private func reload() {
|
||||||
@ -60,14 +76,52 @@ struct WorkoutTab: View {
|
|||||||
guard let store = database.store else {
|
guard let store = database.store else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.workouts = []
|
self.workouts = []
|
||||||
|
self.workoutTypeCounts = []
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loadWorkoutTypes(in: store)
|
||||||
|
loadWorkouts(in: store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadWorkouts(in store: HealthDatabase) {
|
||||||
do {
|
do {
|
||||||
let workouts = try store.workouts()
|
let workouts: [Workout]
|
||||||
DispatchQueue.main.async {
|
if let filteredActivityType {
|
||||||
self.workouts = workouts
|
workouts = try store.workouts(type: filteredActivityType)
|
||||||
|
} else {
|
||||||
|
workouts = try store.workouts()
|
||||||
|
}
|
||||||
print("Loaded \(workouts.count) 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 {
|
} catch {
|
||||||
print("Failed to load workouts: \(error)")
|
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 {
|
#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
|
let workout: Workout
|
||||||
|
|
||||||
|
private let heartRateCategoryCount = 110
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var healthWorkout: HKWorkout?
|
private var healthWorkout: HKWorkout?
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var heartRateSamplesInHealth: [HeartRate] = []
|
private var heartRateSamples: [HeartRate] = []
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var heartRateSamplesInDatabase: [HeartRate] = []
|
private var samples: [HRSample] = []
|
||||||
|
|
||||||
@State
|
@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 {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section("Info") {
|
if healthWorkout != nil {
|
||||||
DetailRow("ID", value: workout.id)
|
HStack {
|
||||||
DetailRow("Total Distance", kilometer: workout.totalDistance)
|
Spacer()
|
||||||
DetailRow("Duration", duration: workout.duration)
|
VStack {
|
||||||
DetailRow("Goal", value: workout.goal)
|
Text("Matching workout found in Health")
|
||||||
|
.foregroundStyle(.black)
|
||||||
}
|
}
|
||||||
if !workout.workoutActivities.isEmpty {
|
Spacer()
|
||||||
Section("Activities") {
|
}
|
||||||
|
.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
|
ForEach(workout.workoutActivities, id: \.startDate) { activity in
|
||||||
NavigationLink(value: activity) {
|
NavigationLink(value: activity) {
|
||||||
DetailRow(activity.workoutConfiguration.activityType.description,
|
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 {
|
} label: {
|
||||||
Section("Events") {
|
|
||||||
NavigationLink(value: workout.workoutEvents) {
|
|
||||||
DetailRow("Events", value: workout.workoutEvents.count)
|
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: {
|
} label: {
|
||||||
DetailRow("Metadata", value: workout.metadata.count)
|
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") {
|
Section("Heart Rate") {
|
||||||
DetailRow("Samples", value: "\(heartRateSamplesInDatabase.count)")
|
VStack(alignment: .leading) {
|
||||||
DetailRow("Range", value: "\(heartRateSamplesInDatabase.minimumHeartRate) - \(heartRateSamplesInDatabase.maximumHeartRate)")
|
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 {
|
if !locationSamples.isEmpty {
|
||||||
Section("Locations") {
|
Section("Route") {
|
||||||
DetailRow("Count", value: "\(locationSamples.count)")
|
Text("")
|
||||||
|
.frame(height: 150)
|
||||||
|
.listRowBackground(WorkoutMapView(locations: locationSamples))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(SidebarListStyle())
|
||||||
.navigationTitle(workout.typeString)
|
.navigationTitle(workout.typeString)
|
||||||
.navigationDestination(for: HKWorkoutActivity.self) { activity in
|
.navigationDestination(for: HKWorkoutActivity.self) { activity in
|
||||||
ActivityDetailView(workout: workout, activity: activity)
|
ActivityDetailView(workout: workout, activity: activity)
|
||||||
}
|
}
|
||||||
.navigationDestination(for: [HKWorkoutEvent].self) {
|
.navigationDestination(for: HKWorkoutEvent.self) { event in
|
||||||
WorkoutEventsView(events: $0)
|
EventDetailView(event: event)
|
||||||
}
|
}
|
||||||
.onAppear(perform: loadSamples)
|
.onAppear(perform: loadSamples)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSamples() {
|
private func addWorkoutToHealth() {
|
||||||
Task {
|
guard let db = database.store else {
|
||||||
checkPermissionsAndSearchHealth()
|
|
||||||
do {
|
|
||||||
guard let samples: [HeartRate] = try database.store?.samples(associatedWith: workout) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
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")
|
print("Loaded \(samples.count) heart rate samples from database")
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to load heart rate samples from database: \(error)")
|
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() {
|
private func loadPrivateMetadata(db: HealthDatabase) async {
|
||||||
Task {
|
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 {
|
do {
|
||||||
try await checkPermissionsAndFindWorkout()
|
try await checkPermissionsAndFindWorkout()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to search for workout: \(error)")
|
print("Failed to search for workout: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func checkPermissionsAndFindWorkout() async throws {
|
private func checkPermissionsAndFindWorkout() async throws {
|
||||||
|
|
||||||
switch store.authorizationStatus(for: HKWorkout.self) {
|
switch store.authorizationStatus(for: HKWorkout.self) {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
try await requestWorkoutPermission()
|
try await requestWorkoutPermission()
|
||||||
try await checkPermissionsAndFindWorkout()
|
try await checkPermissionsAndFindWorkout()
|
||||||
case .sharingAuthorized:
|
case .sharingAuthorized, .sharingDenied:
|
||||||
await findWorkoutInHealth()
|
|
||||||
case .sharingDenied:
|
|
||||||
print("No permission to write workouts")
|
|
||||||
await findWorkoutInHealth()
|
await findWorkoutInHealth()
|
||||||
return
|
return
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@ -147,9 +314,10 @@ struct WorkoutDetailView: View {
|
|||||||
|
|
||||||
let start = workout.startDate.addingTimeInterval(-60)
|
let start = workout.startDate.addingTimeInterval(-60)
|
||||||
let end = workout.endDate.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 {
|
.first else {
|
||||||
print("No workout found or error")
|
print("No matching workout found in Health")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,15 +325,8 @@ struct WorkoutDetailView: View {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.healthWorkout = workout
|
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 {
|
} 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 {
|
return NavigationStack {
|
||||||
WorkoutDetailView(workout: .mock1)
|
WorkoutDetailView(workout: .mock1)
|
||||||
.environmentObject(Database.mock)
|
.environmentObject(Database.mock)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,3 +365,15 @@ private extension HeartRate {
|
|||||||
quantity.doubleValue(for: .count().unitDivided(by: .minute())).roundedInt
|
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