Refactor SQLite, add mock data
This commit is contained in:
parent
b89eb0103d
commit
218705a4d2
@ -15,12 +15,10 @@
|
||||
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002702B5C299900E7D4DB /* HealthDatabase.swift */; };
|
||||
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 885002762B5C2FC400E7D4DB /* SQLite */; };
|
||||
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002782B5C320400E7D4DB /* Optional+Extensions.swift */; };
|
||||
8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */; };
|
||||
8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */; };
|
||||
8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */; };
|
||||
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027E2B5C36A700E7D4DB /* Workout.swift */; };
|
||||
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */; };
|
||||
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */; };
|
||||
885002892B5C873C00E7D4DB /* DBWorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */; };
|
||||
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */; };
|
||||
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */; };
|
||||
8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */; };
|
||||
@ -31,12 +29,30 @@
|
||||
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */; };
|
||||
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */; };
|
||||
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.swift */; };
|
||||
8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */; };
|
||||
885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */; };
|
||||
8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */; };
|
||||
885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A22B5D217600E7D4DB /* MetadataValue.swift */; };
|
||||
885002A62B5D296700E7D4DB /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A52B5D296700E7D4DB /* Collections */; };
|
||||
885002A82B5D296700E7D4DB /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A72B5D296700E7D4DB /* DequeModule */; };
|
||||
885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A92B5D296700E7D4DB /* OrderedCollections */; };
|
||||
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC722B626A30005B83D3 /* WorkoutActivity+Mock.swift */; };
|
||||
E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC742B626B19005B83D3 /* Metadata+Mock.swift */; };
|
||||
E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC762B626FC1005B83D3 /* MetadataKey.swift */; };
|
||||
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */; };
|
||||
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7A2B6275CA005B83D3 /* Metadata.swift */; };
|
||||
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; };
|
||||
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */; };
|
||||
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */; };
|
||||
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */; };
|
||||
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6832B5E76A4003A8873 /* Location+Mock.swift */; };
|
||||
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6852B5FBF0B003A8873 /* Sample.swift */; };
|
||||
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */; };
|
||||
E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* Sample+Unit.swift */; };
|
||||
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */; };
|
||||
E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; };
|
||||
E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */; };
|
||||
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6932B5FD587003A8873 /* Workout+Mock.swift */; };
|
||||
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */; };
|
||||
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@ -48,12 +64,10 @@
|
||||
8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = "<group>"; };
|
||||
885002702B5C299900E7D4DB /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = "<group>"; };
|
||||
885002782B5C320400E7D4DB /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
|
||||
8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkout.swift; sourceTree = "<group>"; };
|
||||
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutEvent.swift; sourceTree = "<group>"; };
|
||||
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+SQLite.swift"; sourceTree = "<group>"; };
|
||||
8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = "<group>"; };
|
||||
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEvent.swift; sourceTree = "<group>"; };
|
||||
885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Extensions.swift"; sourceTree = "<group>"; };
|
||||
885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutActivity.swift; sourceTree = "<group>"; };
|
||||
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivity.swift; sourceTree = "<group>"; };
|
||||
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = "<group>"; };
|
||||
8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||
@ -64,9 +78,27 @@
|
||||
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = "<group>"; };
|
||||
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
|
||||
8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; };
|
||||
8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadata.swift; sourceTree = "<group>"; };
|
||||
885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadataKey.swift; sourceTree = "<group>"; };
|
||||
8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataValue+SQLite.swift"; sourceTree = "<group>"; };
|
||||
885002A22B5D217600E7D4DB /* MetadataValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataValue.swift; sourceTree = "<group>"; };
|
||||
E201EC722B626A30005B83D3 /* WorkoutActivity+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+Mock.swift"; sourceTree = "<group>"; };
|
||||
E201EC742B626B19005B83D3 /* Metadata+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Mock.swift"; sourceTree = "<group>"; };
|
||||
E201EC762B626FC1005B83D3 /* MetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataKey.swift; sourceTree = "<group>"; };
|
||||
E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E201EC7A2B6275CA005B83D3 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = "<group>"; };
|
||||
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleListView.swift; sourceTree = "<group>"; };
|
||||
E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleDetailView.swift; sourceTree = "<group>"; };
|
||||
E27BC6832B5E76A4003A8873 /* Location+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+Mock.swift"; sourceTree = "<group>"; };
|
||||
E27BC6852B5FBF0B003A8873 /* Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sample.swift; sourceTree = "<group>"; };
|
||||
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Quantity.swift"; sourceTree = "<group>"; };
|
||||
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Unit.swift"; sourceTree = "<group>"; };
|
||||
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = "<group>"; };
|
||||
E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = "<group>"; };
|
||||
E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = "<group>"; };
|
||||
E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -108,6 +140,8 @@
|
||||
8850025C2B5C273C00E7D4DB /* ContentView.swift */,
|
||||
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
|
||||
885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
|
||||
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */,
|
||||
E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */,
|
||||
8850029C2B5D197300E7D4DB /* EventDetailView.swift */,
|
||||
885002942B5D147100E7D4DB /* DetailRow.swift */,
|
||||
8850025E2B5C273E00E7D4DB /* Assets.xcassets */,
|
||||
@ -124,6 +158,12 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
885002612B5C273E00E7D4DB /* Preview Assets.xcassets */,
|
||||
E27BC6832B5E76A4003A8873 /* Location+Mock.swift */,
|
||||
E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */,
|
||||
E27BC6932B5FD587003A8873 /* Workout+Mock.swift */,
|
||||
E201EC742B626B19005B83D3 /* Metadata+Mock.swift */,
|
||||
E201EC722B626A30005B83D3 /* WorkoutActivity+Mock.swift */,
|
||||
E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
@ -139,11 +179,6 @@
|
||||
885002802B5C37A800E7D4DB /* Database Entries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */,
|
||||
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */,
|
||||
885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */,
|
||||
8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */,
|
||||
885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */,
|
||||
);
|
||||
path = "Database Entries";
|
||||
sourceTree = "<group>";
|
||||
@ -151,10 +186,21 @@
|
||||
885002812B5C37B700E7D4DB /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8850027E2B5C36A700E7D4DB /* Workout.swift */,
|
||||
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */,
|
||||
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */,
|
||||
E27BC6792B5D99AC003A8873 /* LocationSample.swift */,
|
||||
E201EC7A2B6275CA005B83D3 /* Metadata.swift */,
|
||||
E201EC762B626FC1005B83D3 /* MetadataKey.swift */,
|
||||
E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */,
|
||||
885002A22B5D217600E7D4DB /* MetadataValue.swift */,
|
||||
8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */,
|
||||
E27BC6852B5FBF0B003A8873 /* Sample.swift */,
|
||||
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */,
|
||||
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */,
|
||||
8850027E2B5C36A700E7D4DB /* Workout.swift */,
|
||||
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */,
|
||||
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */,
|
||||
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */,
|
||||
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */,
|
||||
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
@ -162,6 +208,7 @@
|
||||
885002832B5C37C600E7D4DB /* Support */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */,
|
||||
8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */,
|
||||
885002782B5C320400E7D4DB /* Optional+Extensions.swift */,
|
||||
885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */,
|
||||
@ -169,6 +216,7 @@
|
||||
885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */,
|
||||
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */,
|
||||
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */,
|
||||
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */,
|
||||
);
|
||||
path = Support;
|
||||
sourceTree = "<group>";
|
||||
@ -254,28 +302,44 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */,
|
||||
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */,
|
||||
8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */,
|
||||
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */,
|
||||
885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */,
|
||||
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
|
||||
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */,
|
||||
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */,
|
||||
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */,
|
||||
E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */,
|
||||
8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */,
|
||||
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */,
|
||||
885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */,
|
||||
E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */,
|
||||
E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */,
|
||||
E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */,
|
||||
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */,
|
||||
885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */,
|
||||
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */,
|
||||
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
|
||||
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
|
||||
8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */,
|
||||
8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */,
|
||||
8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */,
|
||||
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */,
|
||||
8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */,
|
||||
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
|
||||
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
|
||||
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
|
||||
885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */,
|
||||
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */,
|
||||
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */,
|
||||
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */,
|
||||
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
|
||||
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,
|
||||
E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */,
|
||||
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
|
||||
8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */,
|
||||
885002892B5C873C00E7D4DB /* DBWorkoutActivity.swift in Sources */,
|
||||
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */,
|
||||
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
|
||||
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */,
|
||||
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */,
|
||||
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>HealthImport.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>SQLite (Playground) 1.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>SQLite (Playground) 2.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>SQLite (Playground).xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
@ -2,8 +2,13 @@ import SwiftUI
|
||||
|
||||
struct ActivityDetailView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var database: HealthDatabase
|
||||
|
||||
let activity: WorkoutActivity
|
||||
|
||||
@State var locations: [LocationSample] = []
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
DetailRow("UUID", value: activity.uuid)
|
||||
@ -16,24 +21,39 @@ struct ActivityDetailView: View {
|
||||
DetailRow("End", date: activity.endDate)
|
||||
DetailRow("Duration", duration: activity.duration)
|
||||
DetailRow("Metadata", value: activity.metadata)
|
||||
if !locations.isEmpty {
|
||||
NavigationLink(value: locations) {
|
||||
DetailRow("Locations", value: "\(locations.count)")
|
||||
}
|
||||
} else {
|
||||
DetailRow("Locations", value: "0")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.navigationDestination(for: [LocationSample].self) { locations in
|
||||
LocationSampleListView(samples: locations)
|
||||
}
|
||||
.onAppear(perform: load)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
Task {
|
||||
do {
|
||||
let samples = try database.locationSamples(for: activity)
|
||||
.sorted { $0.timestamp }
|
||||
DispatchQueue.main.async {
|
||||
self.locations = samples
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load location samples for activity: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ActivityDetailView(activity: .init(
|
||||
uuid: .init(repeating: 42, count: 3),
|
||||
isPrimaryActivity: true,
|
||||
activityType: .running,
|
||||
locationType: .outdoor,
|
||||
swimmingLocationType: .unknown,
|
||||
lapLength: .init(repeating: 42, count: 3),
|
||||
startDate: .now.addingTimeInterval(-100),
|
||||
endDate: .now,
|
||||
duration: 100.0,
|
||||
metadata: .init(repeating: 42, count: 3))
|
||||
)
|
||||
ActivityDetailView(activity: .mock1)
|
||||
.environmentObject(HealthDatabase.mock())
|
||||
}
|
||||
}
|
||||
|
@ -3,11 +3,7 @@ import HealthKit
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite")
|
||||
|
||||
@StateObject var database: HealthDatabase = {
|
||||
try! .init(fileUrl: databaseFileUrl!)
|
||||
}()
|
||||
@StateObject var database: HealthDatabase
|
||||
|
||||
@State var navigationPath: NavigationPath = .init()
|
||||
|
||||
@ -37,5 +33,5 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
ContentView(database: .mock())
|
||||
}
|
||||
|
@ -1,57 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
struct DBMetadata {
|
||||
|
||||
private static let table = Table("metadata_values")
|
||||
|
||||
private static let rowKeyId = Expression<Int?>("key_id")
|
||||
|
||||
private static let rowObjectId = Expression<Int?>("object_id")
|
||||
|
||||
private static let rowValueType = Expression<Int>("value_type")
|
||||
|
||||
private static let rowStringValue = Expression<String?>("string_value")
|
||||
|
||||
private static let rowNumericalValue = Expression<Double?>("numerical_value")
|
||||
|
||||
private static let rowDateValue = Expression<Double?>("date_value")
|
||||
|
||||
private static let rowDataValue = Expression<Data?>("data_value")
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(Self.init)
|
||||
}
|
||||
|
||||
static func metadata(for workoutId: Int, in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table.filter(rowObjectId == workoutId)).map(Self.init)
|
||||
}
|
||||
|
||||
let keyId: Int?
|
||||
|
||||
let objectId: Int?
|
||||
|
||||
let valueType: Int
|
||||
|
||||
let string: String?
|
||||
|
||||
let number: Double?
|
||||
|
||||
let date: Double?
|
||||
|
||||
let data: Data?
|
||||
}
|
||||
|
||||
extension DBMetadata {
|
||||
|
||||
init(row: Row) {
|
||||
self.keyId = row[DBMetadata.rowKeyId]
|
||||
self.objectId = row[DBMetadata.rowObjectId]
|
||||
self.valueType = row[DBMetadata.rowValueType]
|
||||
self.string = row[DBMetadata.rowStringValue]
|
||||
self.number = row[DBMetadata.rowNumericalValue]
|
||||
self.date = row[DBMetadata.rowDateValue]
|
||||
self.data = row[DBMetadata.rowDataValue]
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
struct DBMetadataKey {
|
||||
|
||||
private static let table = Table("metadata_keys")
|
||||
|
||||
private static let rowId = Expression<Int>("ROWID")
|
||||
|
||||
private static let rowKey = Expression<String>("key")
|
||||
|
||||
static func key(for keyId: Int, in database: Connection) throws -> String {
|
||||
try database.prepare(table.filter(rowId == keyId).limit(1)).map { $0[rowKey] }.first!
|
||||
}
|
||||
|
||||
static func readAll(in database: Connection) throws -> [ Int: String] {
|
||||
try database.prepare(table).reduce(into: [:]) { dict, row in
|
||||
dict[row[rowId]] = row[rowKey]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
struct DBWorkout {
|
||||
|
||||
private static let table = Table("workouts")
|
||||
|
||||
private static let rowDataId = Expression<Int>("data_id")
|
||||
|
||||
private static let rowTotalDistance = Expression<Double?>("total_distance")
|
||||
|
||||
private static let rowGoalType = Expression<Int?>("goal_type")
|
||||
|
||||
private static let rowGoal = Expression<Double?>("goal")
|
||||
|
||||
private static let rowCondenserVersion = Expression<Int?>("condenser_version")
|
||||
|
||||
private static let rowCondenserDate = Expression<Double?>("condenser_date")
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(Self.init(row:))
|
||||
}
|
||||
|
||||
let dataId: Int
|
||||
|
||||
let totalDistance: Double?
|
||||
|
||||
let goalType: Int?
|
||||
|
||||
let goal: Double?
|
||||
|
||||
let condenserVersion: Int?
|
||||
|
||||
let condenserDate: Double?
|
||||
|
||||
init(row: Row) {
|
||||
self.dataId = row[DBWorkout.rowDataId]
|
||||
self.totalDistance = row[DBWorkout.rowTotalDistance]
|
||||
self.goalType = row[DBWorkout.rowGoalType]
|
||||
self.goal = row[DBWorkout.rowGoal]
|
||||
self.condenserVersion = row[DBWorkout.rowCondenserVersion]
|
||||
self.condenserDate = row[DBWorkout.rowCondenserDate]
|
||||
}
|
||||
}
|
||||
|
@ -1,79 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
struct DBWorkoutActivity {
|
||||
|
||||
private static let table = Table("workout_activities")
|
||||
|
||||
private static let rowId = Expression<Int>("ROWID")
|
||||
|
||||
private static let rowUUID = Expression<Data>("uuid")
|
||||
|
||||
private static let rowOwnerId = Expression<Int>("owner_id")
|
||||
|
||||
private static let rowIsPrimaryActivity = Expression<Bool>("is_primary_activity")
|
||||
|
||||
private static let rowActivityType = Expression<Int>("activity_type")
|
||||
|
||||
private static let rowLocationType = Expression<Int>("location_type")
|
||||
|
||||
private static let rowSwimmingLocationType = Expression<Int>("swimming_location_type")
|
||||
|
||||
private static let rowLapLength = Expression<Data?>("lap_length")
|
||||
|
||||
private static let rowStartDate = Expression<Double>("start_date")
|
||||
|
||||
private static let rowEndDate = Expression<Double>("end_date")
|
||||
|
||||
private static let rowDuration = Expression<Double>("duration")
|
||||
|
||||
private static let rowMetadata = Expression<Data?>("metadata")
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(Self.init)
|
||||
}
|
||||
|
||||
static func activities(for workoutId: Int, in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table.filter(rowOwnerId == workoutId)).map(Self.init)
|
||||
}
|
||||
|
||||
let id: Int
|
||||
|
||||
let uuid: Data
|
||||
|
||||
let ownerId: Int
|
||||
|
||||
let isPrimaryActivity: Bool
|
||||
|
||||
let activityType: Int
|
||||
|
||||
let locationType: Int
|
||||
|
||||
let swimmingLocationType: Int
|
||||
|
||||
let lapLength: Data?
|
||||
|
||||
#warning("Fix timezone for dates")
|
||||
let startDate: Double
|
||||
|
||||
let endDate: Double
|
||||
|
||||
let duration: Double
|
||||
|
||||
let metadata: Data?
|
||||
|
||||
init(row: Row) {
|
||||
self.id = row[DBWorkoutActivity.rowId]
|
||||
self.uuid = row[DBWorkoutActivity.rowUUID]
|
||||
self.ownerId = row[DBWorkoutActivity.rowOwnerId]
|
||||
self.isPrimaryActivity = row[DBWorkoutActivity.rowIsPrimaryActivity]
|
||||
self.activityType = row[DBWorkoutActivity.rowActivityType]
|
||||
self.locationType = row[DBWorkoutActivity.rowLocationType]
|
||||
self.swimmingLocationType = row[DBWorkoutActivity.rowSwimmingLocationType]
|
||||
self.lapLength = row[DBWorkoutActivity.rowLapLength]
|
||||
self.startDate = row[DBWorkoutActivity.rowStartDate]
|
||||
self.endDate = row[DBWorkoutActivity.rowEndDate]
|
||||
self.duration = row[DBWorkoutActivity.rowDuration]
|
||||
self.metadata = row[DBWorkoutActivity.rowMetadata]
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
struct DBWorkoutEvent {
|
||||
|
||||
private static let table = Table("workout_events")
|
||||
|
||||
private static let rowOwnerId = Expression<Int>("owner_id")
|
||||
|
||||
private static let rowDate = Expression<Double>("date")
|
||||
|
||||
private static let rowType = Expression<Int>("type")
|
||||
|
||||
private static let rowDuration = Expression<Double>("duration")
|
||||
|
||||
private static let rowMetadata = Expression<Data?>("metadata")
|
||||
|
||||
private static let rowSessionUUID = Expression<Data?>("session_uuid")
|
||||
|
||||
private static let rowError = Expression<Data?>("error")
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(Self.init)
|
||||
}
|
||||
|
||||
static func events(for workoutId: Int, in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table.filter(rowOwnerId == workoutId)).map(Self.init)
|
||||
}
|
||||
|
||||
let ownerId: Int
|
||||
|
||||
let date: Double
|
||||
|
||||
let type: Int
|
||||
|
||||
let duration: Double
|
||||
|
||||
let metadata: Data?
|
||||
|
||||
let sessionUUID: Data?
|
||||
|
||||
let error: Data?
|
||||
|
||||
init(row: Row) {
|
||||
self.ownerId = row[DBWorkoutEvent.rowOwnerId]
|
||||
self.date = row[DBWorkoutEvent.rowDate]
|
||||
self.type = row[DBWorkoutEvent.rowType]
|
||||
self.duration = row[DBWorkoutEvent.rowDuration]
|
||||
self.metadata = row[DBWorkoutEvent.rowMetadata]
|
||||
self.sessionUUID = row[DBWorkoutEvent.rowSessionUUID]
|
||||
self.error = row[DBWorkoutEvent.rowError]
|
||||
}
|
||||
}
|
@ -19,13 +19,6 @@ struct EventDetailView: View {
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
EventDetailView(event: .init(
|
||||
date: .now,
|
||||
type: .pause,
|
||||
duration: 12.3,
|
||||
metadata: .init(repeating: 42, count: 2),
|
||||
sessionUUID: .init(repeating: 42, count: 3),
|
||||
error: nil)
|
||||
)
|
||||
EventDetailView(event: .mock1.first!)
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +1,67 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import CoreLocation
|
||||
|
||||
typealias Database = Connection
|
||||
|
||||
final class HealthDatabase: ObservableObject {
|
||||
|
||||
let fileUrl: URL
|
||||
private let fileUrl: URL
|
||||
|
||||
let database: Connection
|
||||
private let database: Connection
|
||||
|
||||
@Published
|
||||
var workouts: [Workout] = []
|
||||
|
||||
init(fileUrl: URL) throws {
|
||||
convenience init(fileUrl: URL) throws {
|
||||
let database = try Connection(fileUrl.path)
|
||||
self.init(fileUrl: fileUrl, database: database)
|
||||
}
|
||||
|
||||
init(fileUrl: URL, database: Connection) {
|
||||
self.fileUrl = fileUrl
|
||||
self.database = try Connection(fileUrl.path)
|
||||
self.database = database
|
||||
DispatchQueue.global().async {
|
||||
self.readAllWorkouts()
|
||||
}
|
||||
}
|
||||
|
||||
func readAllWorkouts() {
|
||||
let workouts: [Workout]
|
||||
do {
|
||||
let dbWorkouts = try DBWorkout.readAll(in: database)
|
||||
let metadataKeys = try DBMetadataKey.readAll(in: database)
|
||||
let workouts = try dbWorkouts.map { entry in
|
||||
let events = try DBWorkoutEvent.events(for: entry.dataId, in: database)
|
||||
let activities = try DBWorkoutActivity.activities(for: entry.dataId, in: database)
|
||||
let metadata: [String : MetadataValue] = try DBMetadata.metadata(for: entry.dataId, in: database).reduce(into: [:]) { dict, item in
|
||||
let key = metadataKeys[item.keyId!]!
|
||||
dict[key] = MetadataValue(entry: item)
|
||||
workouts = try Workout.readAll(in: database)
|
||||
} catch {
|
||||
print("Failed to read workouts: \(error)")
|
||||
return
|
||||
}
|
||||
return Workout(entry: entry, events: events, activities: activities, metadata: metadata)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.workouts = workouts
|
||||
}
|
||||
} catch {
|
||||
print("Failed to read workouts: \(error)")
|
||||
}
|
||||
|
||||
func locationSamples(for activity: WorkoutActivity) throws -> [LocationSample] {
|
||||
try activity.locationSamples(in: database)
|
||||
}
|
||||
|
||||
var activities: [WorkoutActivity] {
|
||||
workouts.map { $0.activities }.joined().sorted()
|
||||
}
|
||||
|
||||
private func testActivityOverlap() {
|
||||
let activities = self.activities
|
||||
var current = activities.first!
|
||||
for next in activities.dropFirst() {
|
||||
let overlap = next.startDate.timeIntervalSince(current.endDate)
|
||||
if overlap < 0 {
|
||||
print("Overlap \(-overlap.roundedInt) s:")
|
||||
print(" Activity \(current.activityType.description): \(current.startDate.timeAndDateText) -> \(current.endDate.timeAndDateText)")
|
||||
print(" Activity \(next.activityType.description): \(next.startDate.timeAndDateText) -> \(next.endDate.timeAndDateText)")
|
||||
}
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(database: Database) {
|
||||
self.init(fileUrl: .init(filePath: "/"), database: database)
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,20 @@
|
||||
//
|
||||
// HealthImportApp.swift
|
||||
// HealthImport
|
||||
//
|
||||
// Created by iMac on 20.01.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HealthImportApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
ContentView(database: .init())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension HealthDatabase {
|
||||
|
||||
static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite")
|
||||
|
||||
convenience init() {
|
||||
try! self.init(fileUrl: HealthDatabase.databaseFileUrl!)
|
||||
}
|
||||
}
|
||||
|
29
HealthImport/LocationSampleDetailView.swift
Normal file
29
HealthImport/LocationSampleDetailView.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LocationSampleDetailView: View {
|
||||
|
||||
let location: LocationSample
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
DetailRow("Timestamp", date: location.timestamp)
|
||||
DetailRow("Latitude",
|
||||
value: location.coordinate.latitude.asDegrees(decimals: 8))
|
||||
DetailRow("Longitude", value: location.coordinate.longitude.asDegrees(decimals: 8))
|
||||
DetailRow("Hor. accuracy", value: location.horizontalAccuracy.meter)
|
||||
DetailRow("Altitude", value: location.altitude.meter)
|
||||
DetailRow("Ver. accuracy", value: location.verticalAccuracy.meter)
|
||||
DetailRow("Speed", value: location.speed.speedAsMetersPerSecond)
|
||||
DetailRow("Speed accuracy", value: location.speedAccuracy.speedAsMetersPerSecond)
|
||||
DetailRow("Course", value: location.course.asDegrees())
|
||||
DetailRow("Course accuracy", value: location.courseAccuracy.asDegrees())
|
||||
}
|
||||
.navigationTitle("Location")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
LocationSampleDetailView(location: .mock)
|
||||
}
|
||||
}
|
26
HealthImport/LocationSampleListView.swift
Normal file
26
HealthImport/LocationSampleListView.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LocationSampleListView: View {
|
||||
|
||||
let samples: [LocationSample]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(samples, id: \.timestamp) { location in
|
||||
NavigationLink(value: location) {
|
||||
DetailRow("", value: location.timestamp.timeAndDateWithSecondsText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Locations")
|
||||
.navigationDestination(for: LocationSample.self) {
|
||||
LocationSampleDetailView(location: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
LocationSampleListView(samples: [.mock])
|
||||
}
|
||||
}
|
71
HealthImport/Model/LocationSample.swift
Normal file
71
HealthImport/Model/LocationSample.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import CoreLocation
|
||||
|
||||
typealias LocationSample = CLLocation
|
||||
|
||||
extension LocationSample {
|
||||
|
||||
private static let table = Table("location_series_data")
|
||||
|
||||
/// `location_series_data[series_identifier]` <-> `workout_activities[ROW_ID]`
|
||||
private static let rowSeriesIdentifier = Expression<Int>("series_identifier")
|
||||
|
||||
private static let rowTimestamp = Expression<Double>("timestamp")
|
||||
|
||||
private static let rowLongitude = Expression<Double>("longitude")
|
||||
|
||||
private static let rowLatitude = Expression<Double>("latitude")
|
||||
|
||||
private static let rowAltitude = Expression<Double>("altitude")
|
||||
|
||||
private static let rowSpeed = Expression<Double>("speed")
|
||||
|
||||
private static let rowCourse = Expression<Double>("course")
|
||||
|
||||
private static let rowHorizontalAccuracy = Expression<Double>("horizontal_accuracy")
|
||||
|
||||
private static let rowVerticalAccuracy = Expression<Double>("vertical_accuracy")
|
||||
|
||||
private static let rowSpeedAccuracy = Expression<Double>("speed_accuracy")
|
||||
|
||||
private static let rowCourseAccuracy = Expression<Double>("course_accuracy")
|
||||
|
||||
private static let rowSignalEnvironment = Expression<Double>("signal_environment")
|
||||
|
||||
static func locationSamples(for seriesId: Int, in database: Database) throws -> [LocationSample] {
|
||||
try database.prepare(table.filter(rowSeriesIdentifier == seriesId)).map(location)
|
||||
}
|
||||
|
||||
static func locationSampleCount(for seriesId: Int, in database: Database) throws -> Int {
|
||||
try database.scalar(table.filter(rowSeriesIdentifier == seriesId).count)
|
||||
}
|
||||
|
||||
static func locationSamples(from start: Date, to end: Date, in database: Database) throws -> [LocationSample] {
|
||||
let startTime = start.timeIntervalSinceReferenceDate
|
||||
let endTime = end.timeIntervalSinceReferenceDate
|
||||
return try database.prepare(table.filter(rowTimestamp >= startTime && rowTimestamp <= endTime)).map(location)
|
||||
}
|
||||
|
||||
static func locationSampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
|
||||
let startTime = start.timeIntervalSinceReferenceDate
|
||||
let endTime = end.timeIntervalSinceReferenceDate
|
||||
return try database.scalar(table.filter(rowTimestamp >= startTime && rowTimestamp <= endTime).count)
|
||||
}
|
||||
|
||||
private static func location(row: Row) -> LocationSample {
|
||||
.init(
|
||||
coordinate: .init(
|
||||
latitude: row[rowLatitude],
|
||||
longitude: row[rowLongitude]),
|
||||
altitude: row[rowAltitude],
|
||||
horizontalAccuracy: row[rowHorizontalAccuracy],
|
||||
verticalAccuracy: row[rowHorizontalAccuracy],
|
||||
course: row[rowCourse],
|
||||
courseAccuracy: row[rowCourseAccuracy],
|
||||
speed: row[rowSpeed],
|
||||
speedAccuracy: row[rowSpeedAccuracy],
|
||||
timestamp: .init(timeIntervalSinceReferenceDate: row[rowTimestamp]),
|
||||
sourceInfo: .init())
|
||||
}
|
||||
}
|
29
HealthImport/Model/Metadata.swift
Normal file
29
HealthImport/Model/Metadata.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
enum Metadata {
|
||||
|
||||
static func allKeys(in database: Database) throws -> [Int : Key] {
|
||||
try Key.readAll(in: database)
|
||||
}
|
||||
|
||||
static func createTables(in database: Connection) throws {
|
||||
try Value.createTable(in: database)
|
||||
try Key.createTable(in: database)
|
||||
}
|
||||
|
||||
static func metadata(for workoutId: Int, in database: Connection, keyMap: [Int : Key]) throws -> [Key : Value] {
|
||||
return try Value.metadata(for: workoutId, in: database).reduce(into: [:]) { dict, entry in
|
||||
guard let key = keyMap[entry.keyId] else {
|
||||
print("No '\(entry.keyId)' in table 'metadata_keys'")
|
||||
return
|
||||
}
|
||||
dict[key] = entry.value
|
||||
}
|
||||
}
|
||||
|
||||
static func insert(_ value: Value, for key: Key, of workoutId: Int, in database: Connection) throws {
|
||||
let keyId = try Metadata.Key.hasKey(key, in: database) ?? Metadata.Key.insert(key: key, in: database)
|
||||
try Value.insert(value, of: workoutId, for: keyId, in: database)
|
||||
}
|
||||
}
|
38
HealthImport/Model/MetadataKey+SQLite.swift
Normal file
38
HealthImport/Model/MetadataKey+SQLite.swift
Normal file
@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Metadata.Key {
|
||||
|
||||
private static let table = Table("metadata_keys")
|
||||
|
||||
private static let columnId = Expression<Int>("ROWID")
|
||||
|
||||
private static let columnKey = Expression<String>("key")
|
||||
|
||||
static func key(for keyId: Int, in database: Connection) throws -> String {
|
||||
try database.prepare(table.filter(columnId == keyId).limit(1)).map { $0[columnKey] }.first!
|
||||
}
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Int : Self] {
|
||||
try database.prepare(table).reduce(into: [:]) { dict, row in
|
||||
dict[row[columnId]] = .init(rawValue: row[columnKey])
|
||||
}
|
||||
}
|
||||
|
||||
static func createTable(in database: Connection) throws {
|
||||
//try database.execute("CREATE TABLE metadata_keys (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE)")
|
||||
try database.run(table.create { table in
|
||||
table.column(columnId, primaryKey: .autoincrement)
|
||||
table.column(columnKey, unique: true)
|
||||
})
|
||||
}
|
||||
|
||||
static func hasKey(_ key: Self, in database: Connection) throws -> Int? {
|
||||
try database.prepare(table.filter(columnKey == key.rawValue).limit(1)).map { $0[columnId] }.first
|
||||
}
|
||||
|
||||
static func insert(key: Self, in database: Connection) throws -> Int {
|
||||
Int(try database.run(table.insert(columnKey <- key.rawValue)))
|
||||
}
|
||||
|
||||
}
|
325
HealthImport/Model/MetadataKey.swift
Normal file
325
HealthImport/Model/MetadataKey.swift
Normal file
@ -0,0 +1,325 @@
|
||||
import Foundation
|
||||
|
||||
extension Metadata {
|
||||
|
||||
enum Key {
|
||||
case wasUserEntered
|
||||
case heartRateSensorLocation
|
||||
case sessionId
|
||||
case timeZone
|
||||
case subIndex
|
||||
case indoorWorkout
|
||||
case privateMediaSourceBundleIdentifier
|
||||
case sexualActivityProtectionUsed
|
||||
case privateSleepAlarmUserWakeTime
|
||||
case privateSleepAlarmUserSetBedtime
|
||||
case devicePlacementSide
|
||||
case privateHeadphoneAudioDataIsTransient
|
||||
case privateCoreMotionSourceIdentifier
|
||||
case privateHeartRateContext
|
||||
case algorithmVersion
|
||||
case privateAppleHeartbeatSeriesAlgorithmVersion
|
||||
case barometricPressure
|
||||
case privateBloodOxygenContext
|
||||
case privateHeartbeatSequenceContext
|
||||
case appleECGAlgorithmVersion
|
||||
case privateRegulatedUpdateVersion
|
||||
case privateActivitySummaryIndex
|
||||
case privateDeepBreathingEndReason
|
||||
case privateDeepBreathingFinalHeartRate
|
||||
case privateMindfulnessSessionType
|
||||
case privateWorkoutAverageHeartRate
|
||||
case privateWorkoutMaxHeartRate
|
||||
case privateWeatherCondition
|
||||
case privateWorkoutWeatherLocationCoordinatesLongitude
|
||||
case privateWorkoutMinHeartRate
|
||||
case privateWorkoutWasInDaytime
|
||||
case privateWorkoutActivityMoveMode
|
||||
case averageMETs
|
||||
case privateWorkoutWeatherLocationCoordinatesLatitude
|
||||
case privateLostGPSAtSomePoint
|
||||
case weatherTemperature
|
||||
case privateWorkoutMinGroundElevation
|
||||
case weatherHumidity
|
||||
case privateWorkoutMaxGroundElevation
|
||||
case elevationAscended
|
||||
case privateWorkoutAverageCadence
|
||||
case vO2MaxTestType
|
||||
case privateUserOnBetaBlocker
|
||||
case swimmingLocationType
|
||||
case lapLength
|
||||
case swimmingStrokeStyle
|
||||
case audioExposureLevel
|
||||
case privateAudioExposureLimit
|
||||
case dateOfEarliestDataUsedForEstimate
|
||||
case appleDeviceCalibrated
|
||||
case heartRateEventThreshold
|
||||
case privateHeartRateEventThreshold
|
||||
case privateWorkoutTargetZoneMax
|
||||
case privateWorkoutTargetZoneType
|
||||
case privateWorkoutTargetZoneMin
|
||||
case privateFallActionRequested
|
||||
case glassesPrescriptionDescription
|
||||
case privateWorkoutElapsedTimeInHeartRateZones
|
||||
case privateWorkoutWeatherSourceName
|
||||
case privateWorkoutHeartRateZones
|
||||
case privateWorkoutConfiguration
|
||||
case privateWorkoutHeartRateZonesConfigurationType
|
||||
case privateWorkoutAveragePower
|
||||
case privateMetricPlatterStatistics
|
||||
case userMotionContext
|
||||
case heartRateRecoveryActivityDuration
|
||||
case heartRateRecoveryTestType
|
||||
case heartRateRecoveryMaxObservedRecoveryHeartRate
|
||||
case heartRateRecoveryActivityType
|
||||
case sessionEstimate
|
||||
case privateWorkoutExtendedMode
|
||||
case unknown(String)
|
||||
}
|
||||
}
|
||||
|
||||
extension Metadata.Key: RawRepresentable {
|
||||
|
||||
init?(rawValue: String) {
|
||||
switch rawValue {
|
||||
case "HKWasUserEntered": self = .wasUserEntered
|
||||
case "HKHeartRateSensorLocation": self = .heartRateSensorLocation
|
||||
case "sessionId": self = .sessionId
|
||||
case "HKTimeZone": self = .timeZone
|
||||
case "subIndex": self = .subIndex
|
||||
case "HKIndoorWorkout": self = .indoorWorkout
|
||||
case "_HKPrivateMediaSourceBundleIdentifier": self = .privateMediaSourceBundleIdentifier
|
||||
case "HKSexualActivityProtectionUsed": self = .sexualActivityProtectionUsed
|
||||
case "_HKPrivateSleepAlarmUserWakeTime": self = .privateSleepAlarmUserWakeTime
|
||||
case "_HKPrivateSleepAlarmUserSetBedtime": self = .privateSleepAlarmUserSetBedtime
|
||||
case "HKMetadataKeyDevicePlacementSide": self = .devicePlacementSide
|
||||
case "_HKPrivateMetadataKeyHeadphoneAudioDataIsTransient": self = .privateHeadphoneAudioDataIsTransient
|
||||
case "_HKPrivateCoreMotionSourceIdentifier": self = .privateCoreMotionSourceIdentifier
|
||||
case "_HKPrivateHeartRateContext": self = .privateHeartRateContext
|
||||
case "HKAlgorithmVersion": self = .algorithmVersion
|
||||
case "_HKPrivateMetadataKeyAppleHeartbeatSeriesAlgorithmVersion": self = .privateAppleHeartbeatSeriesAlgorithmVersion
|
||||
case "HKMetadataKeyBarometricPressure": self = .barometricPressure
|
||||
case "_HKPrivateBloodOxygenContext": self = .privateBloodOxygenContext
|
||||
case "_HKPrivateHeartbeatSequenceContext": self = .privateHeartbeatSequenceContext
|
||||
case "HKMetadataKeyAppleECGAlgorithmVersion": self = .appleECGAlgorithmVersion
|
||||
case "_HKPrivateMetadataKeyRegulatedUpdateVersion": self = .privateRegulatedUpdateVersion
|
||||
case "_HKPrivateActivitySummaryIndex": self = .privateActivitySummaryIndex
|
||||
case "_HKPrivateDeepBreathingEndReason": self = .privateDeepBreathingEndReason
|
||||
case "_HKPrivateDeepBreathingFinalHeartRate": self = .privateDeepBreathingFinalHeartRate
|
||||
case "_HKPrivateMetadataMindfulnessSessionType": self = .privateMindfulnessSessionType
|
||||
case "_HKPrivateWorkoutAverageHeartRate": self = .privateWorkoutAverageHeartRate
|
||||
case "_HKPrivateWorkoutMaxHeartRate": self = .privateWorkoutMaxHeartRate
|
||||
case "_HKPrivateWeatherCondition": self = .privateWeatherCondition
|
||||
case "_HKPrivateWorkoutWeatherLocationCoordinatesLongitude": self = .privateWorkoutWeatherLocationCoordinatesLongitude
|
||||
case "_HKPrivateWorkoutMinHeartRate": self = .privateWorkoutMinHeartRate
|
||||
case "_HKPrivateWorkoutWasInDaytime": self = .privateWorkoutWasInDaytime
|
||||
case "_HKPrivateWorkoutActivityMoveMode": self = .privateWorkoutActivityMoveMode
|
||||
case "HKAverageMETs": self = .averageMETs
|
||||
case "_HKPrivateWorkoutWeatherLocationCoordinatesLatitude": self = .privateWorkoutWeatherLocationCoordinatesLatitude
|
||||
case "_HKPrivateMetadataKeyLostGPSAtSomePoint": self = .privateLostGPSAtSomePoint
|
||||
case "HKWeatherTemperature": self = .weatherTemperature
|
||||
case "_HKPrivateWorkoutMinGroundElevation": self = .privateWorkoutMinGroundElevation
|
||||
case "HKWeatherHumidity": self = .weatherHumidity
|
||||
case "_HKPrivateWorkoutMaxGroundElevation": self = .privateWorkoutMaxGroundElevation
|
||||
case "HKElevationAscended": self = .elevationAscended
|
||||
case "_HKPrivateWorkoutAverageCadence": self = .privateWorkoutAverageCadence
|
||||
case "HKVO2MaxTestType": self = .vO2MaxTestType
|
||||
case "_HKPrivateMetadataKeyUserOnBetaBlocker": self = .privateUserOnBetaBlocker
|
||||
case "HKSwimmingLocationType": self = .swimmingLocationType
|
||||
case "HKLapLength": self = .lapLength
|
||||
case "HKSwimmingStrokeStyle": self = .swimmingStrokeStyle
|
||||
case "HKMetadataKeyAudioExposureLevel": self = .audioExposureLevel
|
||||
case "_HKPrivateMetadataKeyAudioExposureLimit": self = .privateAudioExposureLimit
|
||||
case "HKDateOfEarliestDataUsedForEstimate": self = .dateOfEarliestDataUsedForEstimate
|
||||
case "HKMetadataKeyAppleDeviceCalibrated": self = .appleDeviceCalibrated
|
||||
case "HKHeartRateEventThreshold": self = .heartRateEventThreshold
|
||||
case "_HKPrivateMetadataKeyHeartRateEventThreshold": self = .privateHeartRateEventThreshold
|
||||
case "_HKPrivateWorkoutTargetZoneMax": self = .privateWorkoutTargetZoneMax
|
||||
case "_HKPrivateWorkoutTargetZoneType": self = .privateWorkoutTargetZoneType
|
||||
case "_HKPrivateWorkoutTargetZoneMin": self = .privateWorkoutTargetZoneMin
|
||||
case "_HKPrivateFallActionRequested": self = .privateFallActionRequested
|
||||
case "HKMetadataKeyGlassesPrescriptionDescription": self = .glassesPrescriptionDescription
|
||||
case "_HKPrivateWorkoutElapsedTimeInHeartRateZones": self = .privateWorkoutElapsedTimeInHeartRateZones
|
||||
case "_HKPrivateWorkoutWeatherSourceName": self = .privateWorkoutWeatherSourceName
|
||||
case "_HKPrivateWorkoutHeartRateZones": self = .privateWorkoutHeartRateZones
|
||||
case "_HKPrivateWorkoutConfiguration": self = .privateWorkoutConfiguration
|
||||
case "_HKPrivateWorkoutHeartRateZonesConfigurationType": self = .privateWorkoutHeartRateZonesConfigurationType
|
||||
case "_HKPrivateWorkoutAveragePower": self = .privateWorkoutAveragePower
|
||||
case "_HKPrivateMetadataKeyMetricPlatterStatistics": self = .privateMetricPlatterStatistics
|
||||
case "HKMetadataKeyUserMotionContext": self = .userMotionContext
|
||||
case "HKMetadataKeyHeartRateRecoveryActivityDuration": self = .heartRateRecoveryActivityDuration
|
||||
case "HKMetadataKeyHeartRateRecoveryTestType": self = .heartRateRecoveryTestType
|
||||
case "HKMetadataKeyHeartRateRecoveryMaxObservedRecoveryHeartRate": self = .heartRateRecoveryMaxObservedRecoveryHeartRate
|
||||
case "HKMetadataKeyHeartRateRecoveryActivityType": self = .heartRateRecoveryActivityType
|
||||
case "HKMetadataKeySessionEstimate": self = .sessionEstimate
|
||||
case "_HKPrivateWorkoutExtendedMode": self = .privateWorkoutExtendedMode
|
||||
default:
|
||||
self = .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .wasUserEntered: return "HKWasUserEntered"
|
||||
case .heartRateSensorLocation: return "HKHeartRateSensorLocation"
|
||||
case .sessionId: return "sessionId"
|
||||
case .timeZone: return "HKTimeZone"
|
||||
case .subIndex: return "subIndex"
|
||||
case .indoorWorkout: return "HKIndoorWorkout"
|
||||
case .privateMediaSourceBundleIdentifier: return "_HKPrivateMediaSourceBundleIdentifier"
|
||||
case .sexualActivityProtectionUsed: return "HKSexualActivityProtectionUsed"
|
||||
case .privateSleepAlarmUserWakeTime: return "_HKPrivateSleepAlarmUserWakeTime"
|
||||
case .privateSleepAlarmUserSetBedtime: return "_HKPrivateSleepAlarmUserSetBedtime"
|
||||
case .devicePlacementSide: return "HKMetadataKeyDevicePlacementSide"
|
||||
case .privateHeadphoneAudioDataIsTransient: return "_HKPrivateMetadataKeyHeadphoneAudioDataIsTransient"
|
||||
case .privateCoreMotionSourceIdentifier: return "_HKPrivateCoreMotionSourceIdentifier"
|
||||
case .privateHeartRateContext: return "_HKPrivateHeartRateContext"
|
||||
case .algorithmVersion: return "HKAlgorithmVersion"
|
||||
case .privateAppleHeartbeatSeriesAlgorithmVersion: return "_HKPrivateMetadataKeyAppleHeartbeatSeriesAlgorithmVersion"
|
||||
case .barometricPressure: return "HKMetadataKeyBarometricPressure"
|
||||
case .privateBloodOxygenContext: return "_HKPrivateBloodOxygenContext"
|
||||
case .privateHeartbeatSequenceContext: return "_HKPrivateHeartbeatSequenceContext"
|
||||
case .appleECGAlgorithmVersion: return "HKMetadataKeyAppleECGAlgorithmVersion"
|
||||
case .privateRegulatedUpdateVersion: return "_HKPrivateMetadataKeyRegulatedUpdateVersion"
|
||||
case .privateActivitySummaryIndex: return "_HKPrivateActivitySummaryIndex"
|
||||
case .privateDeepBreathingEndReason: return "_HKPrivateDeepBreathingEndReason"
|
||||
case .privateDeepBreathingFinalHeartRate: return "_HKPrivateDeepBreathingFinalHeartRate"
|
||||
case .privateMindfulnessSessionType: return "_HKPrivateMetadataMindfulnessSessionType"
|
||||
case .privateWorkoutAverageHeartRate: return "_HKPrivateWorkoutAverageHeartRate"
|
||||
case .privateWorkoutMaxHeartRate: return "_HKPrivateWorkoutMaxHeartRate"
|
||||
case .privateWeatherCondition: return "_HKPrivateWeatherCondition"
|
||||
case .privateWorkoutWeatherLocationCoordinatesLongitude: return "_HKPrivateWorkoutWeatherLocationCoordinatesLongitude"
|
||||
case .privateWorkoutMinHeartRate: return "_HKPrivateWorkoutMinHeartRate"
|
||||
case .privateWorkoutWasInDaytime: return "_HKPrivateWorkoutWasInDaytime"
|
||||
case .privateWorkoutActivityMoveMode: return "_HKPrivateWorkoutActivityMoveMode"
|
||||
case .averageMETs: return "HKAverageMETs"
|
||||
case .privateWorkoutWeatherLocationCoordinatesLatitude: return "_HKPrivateWorkoutWeatherLocationCoordinatesLatitude"
|
||||
case .privateLostGPSAtSomePoint: return "_HKPrivateMetadataKeyLostGPSAtSomePoint"
|
||||
case .weatherTemperature: return "HKWeatherTemperature"
|
||||
case .privateWorkoutMinGroundElevation: return "_HKPrivateWorkoutMinGroundElevation"
|
||||
case .weatherHumidity: return "HKWeatherHumidity"
|
||||
case .privateWorkoutMaxGroundElevation: return "_HKPrivateWorkoutMaxGroundElevation"
|
||||
case .elevationAscended: return "HKElevationAscended"
|
||||
case .privateWorkoutAverageCadence: return "_HKPrivateWorkoutAverageCadence"
|
||||
case .vO2MaxTestType: return "HKVO2MaxTestType"
|
||||
case .privateUserOnBetaBlocker: return "_HKPrivateMetadataKeyUserOnBetaBlocker"
|
||||
case .swimmingLocationType: return "HKSwimmingLocationType"
|
||||
case .lapLength: return "HKLapLength"
|
||||
case .swimmingStrokeStyle: return "HKSwimmingStrokeStyle"
|
||||
case .audioExposureLevel: return "HKMetadataKeyAudioExposureLevel"
|
||||
case .privateAudioExposureLimit: return "_HKPrivateMetadataKeyAudioExposureLimit"
|
||||
case .dateOfEarliestDataUsedForEstimate: return "HKDateOfEarliestDataUsedForEstimate"
|
||||
case .appleDeviceCalibrated: return "HKMetadataKeyAppleDeviceCalibrated"
|
||||
case .heartRateEventThreshold: return "HKHeartRateEventThreshold"
|
||||
case .privateHeartRateEventThreshold: return "_HKPrivateMetadataKeyHeartRateEventThreshold"
|
||||
case .privateWorkoutTargetZoneMax: return "_HKPrivateWorkoutTargetZoneMax"
|
||||
case .privateWorkoutTargetZoneType: return "_HKPrivateWorkoutTargetZoneType"
|
||||
case .privateWorkoutTargetZoneMin: return "_HKPrivateWorkoutTargetZoneMin"
|
||||
case .privateFallActionRequested: return "_HKPrivateFallActionRequested"
|
||||
case .glassesPrescriptionDescription: return "HKMetadataKeyGlassesPrescriptionDescription"
|
||||
case .privateWorkoutElapsedTimeInHeartRateZones: return "_HKPrivateWorkoutElapsedTimeInHeartRateZones"
|
||||
case .privateWorkoutWeatherSourceName: return "_HKPrivateWorkoutWeatherSourceName"
|
||||
case .privateWorkoutHeartRateZones: return "_HKPrivateWorkoutHeartRateZones"
|
||||
case .privateWorkoutConfiguration: return "_HKPrivateWorkoutConfiguration"
|
||||
case .privateWorkoutHeartRateZonesConfigurationType: return "_HKPrivateWorkoutHeartRateZonesConfigurationType"
|
||||
case .privateWorkoutAveragePower: return "_HKPrivateWorkoutAveragePower"
|
||||
case .privateMetricPlatterStatistics: return "_HKPrivateMetadataKeyMetricPlatterStatistics"
|
||||
case .userMotionContext: return "HKMetadataKeyUserMotionContext"
|
||||
case .heartRateRecoveryActivityDuration: return "HKMetadataKeyHeartRateRecoveryActivityDuration"
|
||||
case .heartRateRecoveryTestType: return "HKMetadataKeyHeartRateRecoveryTestType"
|
||||
case .heartRateRecoveryMaxObservedRecoveryHeartRate: return "HKMetadataKeyHeartRateRecoveryMaxObservedRecoveryHeartRate"
|
||||
case .heartRateRecoveryActivityType: return "HKMetadataKeyHeartRateRecoveryActivityType"
|
||||
case .sessionEstimate: return "HKMetadataKeySessionEstimate"
|
||||
case .privateWorkoutExtendedMode: return "_HKPrivateWorkoutExtendedMode"
|
||||
case .unknown(let rawValue):
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Metadata.Key: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension Metadata.Key: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .wasUserEntered: return "Entered by user"
|
||||
case .heartRateSensorLocation: return "Location of Heart Rate Sensor"
|
||||
case .sessionId: return "Session ID"
|
||||
case .timeZone: return "Timezone"
|
||||
case .subIndex: return "Sub-Index"
|
||||
case .indoorWorkout: return "Indoor Workout"
|
||||
case .sexualActivityProtectionUsed: return "Protection Used for Sexual Activity"
|
||||
case .privateMediaSourceBundleIdentifier: return "Media Source Bundle Identifier (private)"
|
||||
case .privateSleepAlarmUserWakeTime: return "Sleep Alarm User Wake Time (private)"
|
||||
case .privateSleepAlarmUserSetBedtime: return "Sleep Alarm User Set Bedtime (private)"
|
||||
case .devicePlacementSide: return "Device Placement Side"
|
||||
case .privateHeadphoneAudioDataIsTransient: return "Headphone Audio Data is Transient (private)"
|
||||
case .privateCoreMotionSourceIdentifier: return "Core Motion Source Identifier (private)"
|
||||
case .privateHeartRateContext: return "Heart Rate Context (private)"
|
||||
case .algorithmVersion: return "Algorithm Version"
|
||||
case .privateAppleHeartbeatSeriesAlgorithmVersion: return "Apple Heartbeat Series Algorithm Version (private)"
|
||||
case .barometricPressure: return "Barometric Pressure"
|
||||
case .privateBloodOxygenContext: return "Blood Oxygen Context (private)"
|
||||
case .privateHeartbeatSequenceContext: return "Heartbeat Sequence Context (private)"
|
||||
case .appleECGAlgorithmVersion: return "Apple ECG Algorithm Version"
|
||||
case .privateRegulatedUpdateVersion: return "Regulated Update Version (private)"
|
||||
case .privateActivitySummaryIndex: return "Activity Summary Index (private)"
|
||||
case .privateDeepBreathingEndReason: return "Reason for End of Deep Breathing (private)"
|
||||
case .privateDeepBreathingFinalHeartRate: return "Deep Breathing Final Heart Rate (private)"
|
||||
case .privateMindfulnessSessionType: return "Mindfulness Session Type (private)"
|
||||
case .privateWorkoutAverageHeartRate: return "Average Workout Heart Rate (private)"
|
||||
case .privateWorkoutMaxHeartRate: return "Maximum Workout Heart Rate (private)"
|
||||
case .privateWorkoutMinHeartRate: return "Minimum Workout Heart Rate (private)"
|
||||
case .privateWorkoutWasInDaytime: return "Workout Was in Daytime (private)"
|
||||
case .privateWorkoutActivityMoveMode: return "Workout Activity Move Mode (private)"
|
||||
case .averageMETs: return "Average METs"
|
||||
case .privateWorkoutWeatherLocationCoordinatesLatitude: return "Workout Weather Location Latitude (private)"
|
||||
case .privateWorkoutWeatherLocationCoordinatesLongitude: return "Workout Weather Location Longitude (private)"
|
||||
case .privateLostGPSAtSomePoint: return "Lost GPS At Some Point (private)"
|
||||
case .privateWeatherCondition: return "Weather Condition (private)"
|
||||
case .weatherTemperature: return "Weather Temperature"
|
||||
case .weatherHumidity: return "Weather Humidity"
|
||||
case .privateWorkoutMinGroundElevation: return "Minimum Ground Elevation for Workout (private)"
|
||||
case .privateWorkoutMaxGroundElevation: return "Maximum Ground Elevation for Workout (private)"
|
||||
case .elevationAscended: return "Ascended Elevation"
|
||||
case .privateWorkoutAverageCadence: return "Average Workout Cadence (private)"
|
||||
case .vO2MaxTestType: return "VO2Max Test Type"
|
||||
case .privateUserOnBetaBlocker: return "User is on Beta Blocker (private)"
|
||||
case .swimmingLocationType: return "Swimming Location Type"
|
||||
case .lapLength: return "Lap Length"
|
||||
case .swimmingStrokeStyle: return "Swimming Stroke Style"
|
||||
case .audioExposureLevel: return "Audio Exposure Level"
|
||||
case .privateAudioExposureLimit: return "Audio Exposure Limit (private)"
|
||||
case .dateOfEarliestDataUsedForEstimate: return "Date of Earliest Data Used for Estimate"
|
||||
case .appleDeviceCalibrated: return "Apple Device Calibrated"
|
||||
case .heartRateEventThreshold: return "Heart Rate Event Threshold"
|
||||
case .privateHeartRateEventThreshold: return "Heart Rate Event Threshold (private)"
|
||||
case .privateWorkoutTargetZoneMax: return "Workout Target Zone Maximum (private)"
|
||||
case .privateWorkoutTargetZoneType: return "Workout Target Zone Type (private)"
|
||||
case .privateWorkoutTargetZoneMin: return "Workout Target Zone Minimum (private)"
|
||||
case .privateFallActionRequested: return "Requested Fall Action (private)"
|
||||
case .glassesPrescriptionDescription: return "Glasses Prescription Description"
|
||||
case .privateWorkoutElapsedTimeInHeartRateZones: return "Workout Elapsed Time in Heart Rate Zones (private)"
|
||||
case .privateWorkoutWeatherSourceName: return "Workout Weather Source Name (private)"
|
||||
case .privateWorkoutHeartRateZones: return "Workout Heart Rate Zones (private)"
|
||||
case .privateWorkoutConfiguration: return "Workout Configuration (private)"
|
||||
case .privateWorkoutHeartRateZonesConfigurationType: return "Workout Heart Rate Zones Configuration Type (private)"
|
||||
case .privateWorkoutAveragePower: return "Average Workout Power (private)"
|
||||
case .privateMetricPlatterStatistics: return "Metric Platter Statistics (private)"
|
||||
case .userMotionContext: return "User Motion Context"
|
||||
case .heartRateRecoveryActivityDuration: return "Heart Rate Recovery Activity Duration"
|
||||
case .heartRateRecoveryActivityType: return "Heart Rate Recovery Activity Type"
|
||||
case .heartRateRecoveryTestType: return "Heart Rate Recovery Test Type"
|
||||
case .heartRateRecoveryMaxObservedRecoveryHeartRate: return "Max. Observed Recovery Heart Rate"
|
||||
case .sessionEstimate: return "Session Estimate"
|
||||
case .privateWorkoutExtendedMode: return "Extended Workout Mode (private)"
|
||||
case .unknown(let string): return string
|
||||
}
|
||||
}
|
||||
}
|
124
HealthImport/Model/MetadataValue+SQLite.swift
Normal file
124
HealthImport/Model/MetadataValue+SQLite.swift
Normal file
@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Metadata.Value {
|
||||
|
||||
private static let table = Table("metadata_values")
|
||||
|
||||
private static let columnRowId = Expression<Int>("ROW_ID")
|
||||
|
||||
private static let columnKeyId = Expression<Int?>("key_id")
|
||||
|
||||
private static let columnObjectId = Expression<Int?>("object_id")
|
||||
|
||||
private static let columnValueType = Expression<Int>("value_type")
|
||||
|
||||
private static let columnStringValue = Expression<String?>("string_value")
|
||||
|
||||
private static let columnNumericalValue = Expression<Double?>("numerical_value")
|
||||
|
||||
private static let columnDateValue = Expression<Double?>("date_value")
|
||||
|
||||
private static let columnDataValue = Expression<Data?>("data_value")
|
||||
|
||||
private static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(from)
|
||||
}
|
||||
|
||||
static func metadata(for workoutId: Int, in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table.filter(columnObjectId == workoutId)).map(from)
|
||||
}
|
||||
|
||||
static func metadata(for workoutId: Int, in database: Connection) throws -> [(keyId: Int, value: Self)] {
|
||||
try database.prepare(table.filter(columnObjectId == workoutId)).compactMap { row in
|
||||
guard let keyId = row[columnKeyId] else {
|
||||
print("Found 'key_id == NULL' for metadata value of workout \(workoutId)")
|
||||
return nil
|
||||
}
|
||||
return (keyId, from(row: row))
|
||||
}
|
||||
}
|
||||
|
||||
static func createTable(in database: Connection) throws {
|
||||
//try database.execute("CREATE TABLE metadata_values (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, key_id INTEGER, object_id INTEGER, value_type INTEGER NOT NULL DEFAULT 0, string_value TEXT, numerical_value REAL, date_value REAL, data_value BLOB)")
|
||||
try database.run(table.create { table in
|
||||
table.column(columnRowId, primaryKey: .autoincrement)
|
||||
table.column(columnKeyId)
|
||||
table.column(columnObjectId)
|
||||
table.column(columnValueType, defaultValue: 0)
|
||||
table.column(columnStringValue)
|
||||
table.column(columnNumericalValue)
|
||||
table.column(columnDateValue)
|
||||
table.column(columnDataValue)
|
||||
})
|
||||
}
|
||||
|
||||
static func insert(_ element: Self, of workoutId: Int, for keyId: Int, in database: Connection) throws {
|
||||
try database.run(table.insert(
|
||||
columnKeyId <- keyId,
|
||||
columnObjectId <- workoutId,
|
||||
columnValueType <- element.valueType.rawValue,
|
||||
columnStringValue <- element.stringValue,
|
||||
columnNumericalValue <- element.numericalValue,
|
||||
columnDateValue <- element.dateValue,
|
||||
columnDataValue <- element.dataValue
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private extension Metadata.Value {
|
||||
|
||||
var stringValue: String? {
|
||||
if case let .string(value) = self {
|
||||
return value
|
||||
}
|
||||
if case let .numerical(_, unit) = self {
|
||||
return unit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var numericalValue: Double? {
|
||||
if case let .number(value) = self {
|
||||
return value
|
||||
}
|
||||
if case let .numerical(value: value, unit: _) = self {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dateValue: Double? {
|
||||
if case let .date(value: date) = self {
|
||||
return date.timeIntervalSinceReferenceDate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dataValue: Data? {
|
||||
if case let .data(data) = self {
|
||||
return data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension Metadata.Value {
|
||||
|
||||
static func from(row: Row) -> Self {
|
||||
let valueType = ValueType(rawValue: row[columnValueType])!
|
||||
switch valueType {
|
||||
case .string:
|
||||
return .string(value: row[columnStringValue]!)
|
||||
case .number:
|
||||
return .number(value: row[columnNumericalValue]!)
|
||||
case .date:
|
||||
return .date(value: .init(timeIntervalSinceReferenceDate: row[columnDateValue]!))
|
||||
case .numerical:
|
||||
return .numerical(value: row[columnNumericalValue]!, unit: row[columnStringValue]!)
|
||||
case .data:
|
||||
return .data(value: row[columnDataValue]!)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum MetadataValue {
|
||||
extension Metadata {
|
||||
|
||||
enum Value {
|
||||
|
||||
case string(value: String)
|
||||
case number(value: Double)
|
||||
@ -25,28 +27,20 @@ enum MetadataValue {
|
||||
/// Uses only the `data_value` column
|
||||
case data = 4
|
||||
}
|
||||
}
|
||||
|
||||
extension MetadataValue {
|
||||
|
||||
init(entry: DBMetadata) {
|
||||
let valueType = ValueType(rawValue: entry.valueType)!
|
||||
switch valueType {
|
||||
case .string:
|
||||
self = .string(value: entry.string!)
|
||||
case .number:
|
||||
self = .number(value: entry.number!)
|
||||
case .date:
|
||||
self = .date(value: .init(timeIntervalSinceReferenceDate: entry.date!))
|
||||
case .numerical:
|
||||
self = .numerical(value: entry.number!, unit: entry.string!)
|
||||
case .data:
|
||||
self = .data(value: entry.data!)
|
||||
var valueType: ValueType {
|
||||
switch self {
|
||||
case .string: return .string
|
||||
case .number: return .number
|
||||
case .date: return .date
|
||||
case .numerical: return .numerical
|
||||
case .data: return .data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MetadataValue: CustomStringConvertible {
|
||||
extension Metadata.Value: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
|
23
HealthImport/Model/Sample+Quantity.swift
Normal file
23
HealthImport/Model/Sample+Quantity.swift
Normal file
@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Sample {
|
||||
|
||||
private static let table = Table("quantity_samples")
|
||||
|
||||
private static let rowDataId = Expression<Int>("data_id")
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let rowQuantity = Expression<Double>("quantity")
|
||||
|
||||
private static let rowOriginalQuantity = Expression<Double?>("original_quantity")
|
||||
|
||||
/// References `ROW_ID` on table `unit_strings`
|
||||
private static let rowOriginalUnit = Expression<Int?>("original_unit")
|
||||
|
||||
static func quantity(for id: Int, in database: Database) throws -> (quantity: Double, original: Double?, unit: Int?)? {
|
||||
try database.prepare(table.filter(rowDataId == id).limit(1)).map {
|
||||
(quantity: $0[rowQuantity], original: $0[rowOriginalQuantity], unit: $0[rowOriginalUnit])
|
||||
}.first
|
||||
}
|
||||
}
|
18
HealthImport/Model/Sample+Unit.swift
Normal file
18
HealthImport/Model/Sample+Unit.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Sample {
|
||||
|
||||
private static let table = Table("unit_strings")
|
||||
|
||||
private static let rowId = Expression<Int>("ROW_ID")
|
||||
|
||||
// - NOTE: Technically optional
|
||||
private static let rowUnitString = Expression<String>("quantity")
|
||||
|
||||
static func unit(for id: Int, in database: Database) throws -> String? {
|
||||
try database.prepare(table.filter(rowId == id).limit(1)).map { row in
|
||||
row[rowUnitString]
|
||||
}.first
|
||||
}
|
||||
}
|
87
HealthImport/Model/Sample.swift
Normal file
87
HealthImport/Model/Sample.swift
Normal file
@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
enum SampleDataType: RawRepresentable {
|
||||
|
||||
case unknown(Int)
|
||||
|
||||
init(rawValue: Int) {
|
||||
switch rawValue {
|
||||
|
||||
default:
|
||||
self = .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: Int {
|
||||
switch self {
|
||||
case .unknown(let value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SampleDataType: Equatable {
|
||||
|
||||
}
|
||||
|
||||
extension SampleDataType: Hashable {
|
||||
|
||||
}
|
||||
|
||||
struct Sample {
|
||||
|
||||
let startDate: Date
|
||||
|
||||
let endDate: Date
|
||||
|
||||
let dataType: SampleDataType
|
||||
|
||||
let quantity: Double
|
||||
|
||||
let originalQuantity: Double?
|
||||
|
||||
let originalUnit: String?
|
||||
}
|
||||
|
||||
extension Sample {
|
||||
|
||||
private static let table = Table("samples")
|
||||
|
||||
private static let columnDataId = Expression<Int>("data_id")
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let columnStartDate = Expression<Double>("start_date")
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let columnEndDate = Expression<Double>("end_date")
|
||||
|
||||
private static let columnDataType = Expression<Int>("data_type")
|
||||
|
||||
static func samples(from start: Date, to end: Date, in database: Database) throws -> [Sample] {
|
||||
let start = start.timeIntervalSinceReferenceDate
|
||||
let end = end.timeIntervalSinceReferenceDate
|
||||
return try database.prepare(table.filter(columnStartDate >= start && columnEndDate <= end)).compactMap { (row: Row) -> Sample? in
|
||||
let dataId = row[columnDataId]
|
||||
guard let quantity = try Sample.quantity(for: dataId, in: database) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let unit = try quantity.unit.map { try Sample.unit(for: $0, in: database) }
|
||||
|
||||
return Sample(
|
||||
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
|
||||
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
|
||||
dataType: .init(rawValue: row[columnDataType]),
|
||||
quantity: quantity.quantity,
|
||||
originalQuantity: quantity.original,
|
||||
originalUnit: unit)
|
||||
}
|
||||
}
|
||||
|
||||
static func sampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
|
||||
let start = start.timeIntervalSinceReferenceDate
|
||||
let end = end.timeIntervalSinceReferenceDate
|
||||
return try database.scalar(table.filter(columnStartDate >= start && columnEndDate <= end).count)
|
||||
}
|
||||
}
|
85
HealthImport/Model/Workout+SQLite.swift
Normal file
85
HealthImport/Model/Workout+SQLite.swift
Normal file
@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Workout {
|
||||
|
||||
private static let table = Table("workouts")
|
||||
|
||||
// INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
private static let columnDataId = Expression<Int>("data_id")
|
||||
|
||||
// REAL
|
||||
private static let columnTotalDistance = Expression<Double?>("total_distance")
|
||||
|
||||
// INTEGER
|
||||
private static let columnGoalType = Expression<Int?>("goal_type")
|
||||
|
||||
// REAL
|
||||
private static let columnGoal = Expression<Double?>("goal")
|
||||
|
||||
// INTEGER
|
||||
private static let columnCondenserVersion = Expression<Int?>("condenser_version")
|
||||
|
||||
// REAL
|
||||
private static let columnCondenserDate = Expression<Double?>("condenser_date")
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Workout] {
|
||||
let metadataKeys = try Metadata.allKeys(in: database)
|
||||
|
||||
return try database.prepare(table).map { row in
|
||||
let id = row[columnDataId]
|
||||
|
||||
let events = try WorkoutEvent.events(for: id, in: database)
|
||||
let activities = try WorkoutActivity.activities(for: id, in: database)
|
||||
let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys)
|
||||
return .init(
|
||||
id: id,
|
||||
totalDistance: row[columnTotalDistance],
|
||||
goalType: row[columnGoalType],
|
||||
goal: row[columnGoal],
|
||||
condenserVersion: row[columnCondenserVersion],
|
||||
condenserDate: row[columnCondenserDate].map { Date.init(timeIntervalSinceReferenceDate: $0) },
|
||||
events: events,
|
||||
activities: activities,
|
||||
metadata: metadata)
|
||||
}
|
||||
}
|
||||
|
||||
static func createTable(in database: Database) throws {
|
||||
try database.run(table.create { t in
|
||||
t.column(columnDataId, primaryKey: .autoincrement)
|
||||
t.column(columnTotalDistance)
|
||||
t.column(columnGoalType)
|
||||
t.column(columnGoal)
|
||||
t.column(columnCondenserVersion)
|
||||
t.column(columnCondenserDate)
|
||||
})
|
||||
// try database.execute("CREATE TABLE workouts (data_id INTEGER PRIMARY KEY AUTOINCREMENT, total_distance REAL, goal_type INTEGER, goal REAL, condenser_version INTEGER, condenser_date REAL)")
|
||||
}
|
||||
|
||||
func insert(in database: Database) throws {
|
||||
try Workout.insert(self, in: database)
|
||||
}
|
||||
|
||||
private static func insert(_ element: Workout, in database: Database) throws {
|
||||
let rowid = try database.run(table.insert(
|
||||
columnTotalDistance <- element.totalDistance,
|
||||
columnGoalType <- element.goalType,
|
||||
columnGoalType <- element.goalType,
|
||||
columnGoal <- element.goal,
|
||||
columnCondenserVersion <- element.condenserVersion,
|
||||
columnCondenserDate <- element.condenserDate?.timeIntervalSinceReferenceDate)
|
||||
)
|
||||
let dataId = Int(rowid)
|
||||
for event in element.events {
|
||||
try event.insert(in: database, dataId: dataId)
|
||||
}
|
||||
for activity in element.activities {
|
||||
try activity.insert(in: database, dataId: dataId)
|
||||
}
|
||||
for (key, value) in element.metadata {
|
||||
try Metadata.insert(value, for: key, of: dataId, in: database)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ struct Workout {
|
||||
|
||||
let activities: [WorkoutActivity]
|
||||
|
||||
let metadata: OrderedDictionary<String, MetadataValue>
|
||||
let metadata: OrderedDictionary<Metadata.Key, Metadata.Value>
|
||||
|
||||
var firstActivityDate: Date? {
|
||||
activities.map { $0.startDate }.min()
|
||||
@ -53,7 +53,7 @@ struct Workout {
|
||||
activities.first?.activityType.description ?? "Unknown activity"
|
||||
}
|
||||
|
||||
init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [String : MetadataValue] = [:]) {
|
||||
init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [Metadata.Key : Metadata.Value] = [:]) {
|
||||
self.id = id
|
||||
self.totalDistance = totalDistance
|
||||
self.goalType = goalType
|
||||
@ -66,21 +66,6 @@ struct Workout {
|
||||
}
|
||||
}
|
||||
|
||||
extension Workout {
|
||||
|
||||
init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity], metadata: [String : MetadataValue]) {
|
||||
self.id = entry.dataId
|
||||
self.totalDistance = entry.totalDistance
|
||||
self.goalType = entry.goalType
|
||||
self.goal = entry.goal
|
||||
self.condenserVersion = entry.condenserVersion
|
||||
self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) }
|
||||
self.events = events.map(WorkoutEvent.init)
|
||||
self.activities = activities.map(WorkoutActivity.init)
|
||||
self.metadata = .init(uniqueKeys: metadata.keys, values: metadata.values)
|
||||
}
|
||||
}
|
||||
|
||||
extension Workout: Identifiable { }
|
||||
|
||||
extension Workout: Equatable {
|
||||
|
93
HealthImport/Model/WorkoutActivity+SQLite.swift
Normal file
93
HealthImport/Model/WorkoutActivity+SQLite.swift
Normal file
@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension WorkoutActivity {
|
||||
|
||||
private static let table = Table("workout_activities")
|
||||
|
||||
private static let columnId = Expression<Int>("ROWID")
|
||||
|
||||
private static let columnUUID = Expression<Data>("uuid")
|
||||
|
||||
private static let columnOwnerId = Expression<Int>("owner_id")
|
||||
|
||||
private static let columnIsPrimaryActivity = Expression<Bool>("is_primary_activity")
|
||||
|
||||
private static let columnActivityType = Expression<Int>("activity_type")
|
||||
|
||||
private static let columnLocationType = Expression<Int>("location_type")
|
||||
|
||||
private static let columnSwimmingLocationType = Expression<Int>("swimming_location_type")
|
||||
|
||||
private static let columnLapLength = Expression<Data?>("lap_length")
|
||||
|
||||
private static let columnStartDate = Expression<Double>("start_date")
|
||||
|
||||
private static let columnEndDate = Expression<Double>("end_date")
|
||||
|
||||
private static let columnDuration = Expression<Double>("duration")
|
||||
|
||||
private static let columnMetadata = Expression<Data?>("metadata")
|
||||
|
||||
private static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(from)
|
||||
}
|
||||
|
||||
private static func from(row: Row) -> WorkoutActivity {
|
||||
.init(
|
||||
id: row[columnId],
|
||||
uuid: row[columnUUID],
|
||||
isPrimaryActivity: row[columnIsPrimaryActivity],
|
||||
activityType: .init(rawValue: UInt(row[columnActivityType]))!,
|
||||
locationType: .init(rawValue: row[columnLocationType])!,
|
||||
swimmingLocationType: .init(rawValue: row[columnSwimmingLocationType])!,
|
||||
lapLength: row[columnLapLength],
|
||||
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
|
||||
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
|
||||
duration: row[columnDuration],
|
||||
metadata: row[columnMetadata])
|
||||
}
|
||||
|
||||
static func activities(for workoutId: Int, in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table.filter(columnOwnerId == workoutId)).map(from)
|
||||
}
|
||||
|
||||
static func createTable(in database: Connection) throws {
|
||||
//try database.execute("CREATE TABLE workout_activities (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE NOT NULL, owner_id INTEGER NOT NULL REFERENCES workouts(data_id) ON DELETE CASCADE, is_primary_activity INTEGER NOT NULL, activity_type INTEGER NOT NULL, location_type INTEGER NOT NULL, swimming_location_type INTEGER NOT NULL, lap_length BLOB, start_date REAL NOT NULL, end_date REAL NOT NULL, duration REAL NOT NULL, metadata BLOB)")
|
||||
try database.run(table.create { t in
|
||||
t.column(columnId, primaryKey: .autoincrement)
|
||||
t.column(columnUUID)
|
||||
t.column(columnOwnerId, references: Table("workouts"), Expression<Int>("data_id"))
|
||||
t.column(columnIsPrimaryActivity)
|
||||
t.column(columnActivityType)
|
||||
t.column(columnLocationType)
|
||||
t.column(columnSwimmingLocationType)
|
||||
t.column(columnLapLength)
|
||||
t.column(columnStartDate)
|
||||
t.column(columnEndDate)
|
||||
t.column(columnDuration)
|
||||
t.column(columnMetadata)
|
||||
})
|
||||
}
|
||||
|
||||
func insert(in database: Connection, dataId: Int) throws {
|
||||
try WorkoutActivity.insert(self, dataId: dataId, in: database)
|
||||
}
|
||||
|
||||
private static func insert(_ element: WorkoutActivity, dataId: Int, in database: Connection) throws {
|
||||
try database.run(table.insert(
|
||||
columnUUID <- element.uuid,
|
||||
columnOwnerId <- dataId,
|
||||
columnIsPrimaryActivity <- element.isPrimaryActivity,
|
||||
columnActivityType <- Int(element.activityType.rawValue),
|
||||
columnLocationType <- element.locationType.rawValue,
|
||||
columnSwimmingLocationType <- element.swimmingLocationType.rawValue,
|
||||
columnLapLength <- element.lapLength,
|
||||
columnStartDate <- element.startDate.timeIntervalSinceReferenceDate,
|
||||
columnEndDate <- element.endDate.timeIntervalSinceReferenceDate,
|
||||
columnDuration <- element.duration,
|
||||
columnMetadata <- element.metadata)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -3,6 +3,8 @@ import HealthKit
|
||||
|
||||
struct WorkoutActivity {
|
||||
|
||||
private let id: Int
|
||||
|
||||
let uuid: Data
|
||||
|
||||
let isPrimaryActivity: Bool
|
||||
@ -15,6 +17,7 @@ struct WorkoutActivity {
|
||||
|
||||
let lapLength: Data?
|
||||
|
||||
#warning("Fix timezone for dates")
|
||||
let startDate: Date
|
||||
|
||||
let endDate: Date
|
||||
@ -22,28 +25,44 @@ struct WorkoutActivity {
|
||||
let duration: TimeInterval
|
||||
|
||||
let metadata: Data?
|
||||
}
|
||||
|
||||
extension WorkoutActivity {
|
||||
init(id: Int, uuid: Data, isPrimaryActivity: Bool, activityType: HKWorkoutActivityType, locationType: HKWorkoutSessionLocationType, swimmingLocationType: HKWorkoutSwimmingLocationType, lapLength: Data?, startDate: Date, endDate: Date, duration: TimeInterval, metadata: Data?) {
|
||||
self.id = id
|
||||
self.uuid = uuid
|
||||
self.isPrimaryActivity = isPrimaryActivity
|
||||
self.activityType = activityType
|
||||
self.locationType = locationType
|
||||
self.swimmingLocationType = swimmingLocationType
|
||||
self.lapLength = lapLength
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
self.duration = duration
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
init(entry: DBWorkoutActivity) {
|
||||
self.uuid = entry.uuid
|
||||
self.isPrimaryActivity = entry.isPrimaryActivity
|
||||
self.activityType = .init(rawValue: UInt(entry.activityType))!
|
||||
self.locationType = .init(rawValue: entry.locationType)!
|
||||
self.swimmingLocationType = .init(rawValue: entry.swimmingLocationType)!
|
||||
self.lapLength = entry.lapLength
|
||||
self.startDate = Date(timeIntervalSinceReferenceDate: entry.startDate)
|
||||
self.endDate = Date(timeIntervalSinceReferenceDate: entry.endDate)
|
||||
self.duration = entry.duration
|
||||
self.metadata = entry.metadata
|
||||
func locationSamples(in database: Database) throws -> [LocationSample] {
|
||||
try LocationSample.locationSamples(from: startDate, to: endDate, in: database)
|
||||
}
|
||||
|
||||
func locationSampleCount(in database: Database) throws -> Int {
|
||||
try LocationSample.locationSampleCount(from: startDate, to: endDate, in: database)
|
||||
}
|
||||
|
||||
func sampleCount(in database: Database) throws -> Int {
|
||||
try Sample.sampleCount(from: startDate, to: endDate, in: database)
|
||||
}
|
||||
|
||||
func samples(in database: Database) throws -> [SampleDataType : Sample] {
|
||||
try Sample.samples(from: startDate, to: endDate, in: database).reduce(into: [:]) {
|
||||
$0[$1.dataType] = $1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutActivity: Equatable {
|
||||
|
||||
static func == (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool {
|
||||
lhs.uuid == rhs.uuid
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,6 +76,6 @@ extension WorkoutActivity: Comparable {
|
||||
extension WorkoutActivity: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(uuid)
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
79
HealthImport/Model/WorkoutEvent+SQLite.swift
Normal file
79
HealthImport/Model/WorkoutEvent+SQLite.swift
Normal file
@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension WorkoutEvent {
|
||||
|
||||
private static let table = Table("workout_events")
|
||||
|
||||
// INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
private static let columnRowId = Expression<Int>("ROW_ID")
|
||||
|
||||
// owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE
|
||||
private static let columnOwnerId = Expression<Int>("owner_id")
|
||||
|
||||
// date REAL NOT NULL
|
||||
private static let columnDate = Expression<Double>("date")
|
||||
|
||||
// type INTEGER NOT NULL
|
||||
private static let columnType = Expression<Int>("type")
|
||||
|
||||
// duration REAL NOT NULL
|
||||
private static let columnDuration = Expression<Double>("duration")
|
||||
|
||||
// metadata BLOB
|
||||
private static let columnMetadata = Expression<Data?>("metadata")
|
||||
|
||||
// session_uuid BLOB
|
||||
private static let columnSessionUUID = Expression<Data?>("session_uuid")
|
||||
|
||||
// error BLOB
|
||||
private static let columnError = Expression<Data?>("error")
|
||||
|
||||
static func readAll(in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table).map(from)
|
||||
}
|
||||
|
||||
static func events(for workoutId: Int, in database: Connection) throws -> [Self] {
|
||||
try database.prepare(table.filter(columnOwnerId == workoutId)).map(from)
|
||||
}
|
||||
|
||||
private static func from(row: Row) -> WorkoutEvent {
|
||||
.init(
|
||||
date: Date(timeIntervalSinceReferenceDate: row[columnDate]),
|
||||
type: .init(rawValue: row[columnType])!,
|
||||
duration: row[columnDuration],
|
||||
metadata: row[columnMetadata],
|
||||
sessionUUID: row[columnSessionUUID],
|
||||
error: row[columnError])
|
||||
}
|
||||
|
||||
static func createTable(in database: Database) throws {
|
||||
// try database.execute("CREATE TABLE workout_events (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE, date REAL NOT NULL, type INTEGER NOT NULL, duration REAL NOT NULL, metadata BLOB, session_uuid BLOB, error BLOB)")
|
||||
try database.run(table.create { t in
|
||||
t.column(columnRowId, primaryKey: .autoincrement)
|
||||
t.column(columnOwnerId, references: Table("workouts"), Expression<Int>("data_id"))
|
||||
t.column(columnDate)
|
||||
t.column(columnType)
|
||||
t.column(columnDuration)
|
||||
t.column(columnMetadata)
|
||||
t.column(columnSessionUUID)
|
||||
t.column(columnError)
|
||||
})
|
||||
}
|
||||
|
||||
func insert(in database: Database, dataId: Int) throws {
|
||||
try WorkoutEvent.insert(self, dataId: dataId, in: database)
|
||||
}
|
||||
|
||||
private static func insert(_ element: WorkoutEvent, dataId: Int, in database: Database) throws {
|
||||
try database.run(table.insert(
|
||||
columnOwnerId <- dataId,
|
||||
columnDate <- element.date.timeIntervalSinceReferenceDate,
|
||||
columnType <- element.type.rawValue,
|
||||
columnDuration <- element.duration,
|
||||
columnMetadata <- element.metadata,
|
||||
columnSessionUUID <- element.sessionUUID,
|
||||
columnError <- element.error)
|
||||
)
|
||||
}
|
||||
}
|
@ -17,18 +17,6 @@ struct WorkoutEvent {
|
||||
|
||||
}
|
||||
|
||||
extension WorkoutEvent {
|
||||
|
||||
init(entry: DBWorkoutEvent) {
|
||||
self.date = Date(timeIntervalSinceReferenceDate: entry.date)
|
||||
self.type = .init(rawValue: entry.type)!
|
||||
self.duration = entry.duration
|
||||
self.metadata = entry.metadata
|
||||
self.sessionUUID = entry.sessionUUID
|
||||
self.error = entry.error
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutEvent: Equatable {
|
||||
|
||||
static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool {
|
||||
|
28
HealthImport/Preview Content/HealthDatabase+Mock.swift
Normal file
28
HealthImport/Preview Content/HealthDatabase+Mock.swift
Normal file
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension HealthDatabase {
|
||||
|
||||
static func mock() -> HealthDatabase {
|
||||
|
||||
do {
|
||||
let database = try makeDatabase()
|
||||
return .init(database: database)
|
||||
} catch {
|
||||
print(error)
|
||||
fatalError("Failed to create mock database: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeDatabase() throws -> Connection {
|
||||
let database = try Connection(.inMemory)
|
||||
|
||||
try Workout.createTable(in: database)
|
||||
try WorkoutEvent.createTable(in: database)
|
||||
try WorkoutActivity.createTable(in: database)
|
||||
try Metadata.createTables(in: database)
|
||||
|
||||
try Workout.mock1.insert(in: database)
|
||||
return database
|
||||
}
|
||||
}
|
17
HealthImport/Preview Content/Location+Mock.swift
Normal file
17
HealthImport/Preview Content/Location+Mock.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
extension LocationSample {
|
||||
|
||||
static let mock: LocationSample = .init(
|
||||
coordinate: .init(latitude: 52.27124117, longitude: 10.53865853),
|
||||
altitude: 2340, // m
|
||||
horizontalAccuracy: 3.5, // m
|
||||
verticalAccuracy: 5.5, // m
|
||||
course: 41.7, // deg
|
||||
courseAccuracy: 2.3, // deg
|
||||
speed: 3.4, // m/s
|
||||
speedAccuracy: 0.3,
|
||||
timestamp: Date(timeIntervalSinceReferenceDate: 657034001),
|
||||
sourceInfo: CLLocationSourceInformation())
|
||||
}
|
34
HealthImport/Preview Content/Metadata+Mock.swift
Normal file
34
HealthImport/Preview Content/Metadata+Mock.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
extension Metadata {
|
||||
|
||||
static let mock1: [Key : Value] = [
|
||||
.indoorWorkout : .number(value: 0.0),
|
||||
.elevationAscended : .numerical(value: 100564.0, unit: "cm"),
|
||||
.timeZone : .string(value: "Europe/Berlin"),
|
||||
.weatherHumidity : .numerical(value: 84.0, unit: "%"),
|
||||
.weatherTemperature : .numerical(value: 20.8219999999961, unit: "degF"),
|
||||
.averageMETs : .numerical(value: 4.12470993502559, unit: "kcal/hr·kg"),
|
||||
.privateWeatherCondition : .number(value: 4.0),
|
||||
.privateWorkoutActivityMoveMode : .number(value: 1.0),
|
||||
.privateWorkoutMaxGroundElevation : .numerical(value: 3188.78609748806, unit: "m"),
|
||||
.privateWorkoutWeatherSourceName : .string(value: "Apple Weather"),
|
||||
.privateWorkoutHeartRateZones : .data(value: .init(hex: "62706c6973743030a5010a121a22d402030405060708095f1012636f6e66696775726174696f6e436f756e745f10116c6f776572446973706c6179426f756e645f10117570706572446973706c6179426f756e645f1012636f6e66696775726174696f6e496e64657810052300000000000000002340604000000000001000d40b0c0d0e060f10115f1012636f6e66696775726174696f6e436f756e745f10116c6f776572446973706c6179426f756e645f10117570706572446973706c6179426f756e645f1012636f6e66696775726174696f6e496e6465782340604000000000002340620000000000001001d413141516061718195f1012636f6e66696775726174696f6e436f756e745f10116c6f776572446973706c6179426f756e645f10117570706572446973706c6179426f756e645f1012636f6e66696775726174696f6e496e646578234062000000000000234063c000000000001002d41b1c1d1e061f20215f1012636f6e66696775726174696f6e436f756e745f10116c6f776572446973706c6179426f756e645f10117570706572446973706c6179426f756e645f1012636f6e66696775726174696f6e496e646578234063c000000000002340658000000000001003d423242526062728295f1012636f6e66696775726174696f6e436f756e745f10116c6f776572446973706c6179426f756e645f10117570706572446973706c6179426f756e645f1012636f6e66696775726174696f6e496e64657823406580000000000023406740000000000010040008000e0017002c004000540069006b0074007d007f0088009d00b100c500da00e300ec00ee00f7010c0120013401490152015b015d0166017b018f01a301b801c101ca01cc01d501ea01fe02120227023002390000000000000201000000000000002a0000000000000000000000000000023b")!),
|
||||
.privateWorkoutMinHeartRate : .numerical(value: 1.05, unit: "count/s"),
|
||||
.privateMetricPlatterStatistics : .string(value: """
|
||||
{"currentMetricPlatterType":"standard","currentMetricPlatterElapsedTime":17991.661971926689,"metricPlatterAccumulatedTime":{"standard":5039.4511208534241,"activityRings":31.905926942825317,"elevation":12920.30492413044}}
|
||||
"""),
|
||||
.privateWorkoutWasInDaytime : .number(value: 1.0),
|
||||
.privateWorkoutWeatherLocationCoordinatesLatitude : .number(value: 47.0850386887433),
|
||||
.privateWorkoutMinGroundElevation : .numerical(value: 2147.15929389872, unit: "m"),
|
||||
.privateWorkoutConfiguration : .data(value: """
|
||||
{"strings":{},"type":2,"data":"eyJ0eXBlIjoxLCJnb2FsIjoiWW5Cc2FYTjBNRERVQVFJREJBVXNMUzVZSkc5aWFtVmpkSE5ZSkhabGNuTnBiMjVaSkdGeVkyaHBkbVZ5VkNSMGIzQ29CZ2NRRmhvYkpDaFZKRzUxYkd6VUNBa0tDd3dORGc5ZkVCMU9URk5sYzNOcGIyNUJZM1JwZG1sMGVVZHZZV3hSZFdGdWRHbDBlVjhRSjA1TVUyVnpjMmx2YmtGamRHbDJhWFI1UjI5aGJFZHZZV3hVZVhCbFNXUmxiblJwWm1sbGNsWWtZMnhoYzNOZkVCcE9URk5sYzNOcGIyNUJZM1JwZG1sMGVVZHZZV3hXWVd4MVpZQUNFQUtBQnlOQTAxWUFBQUFBQU5NUkVnb1RGQlZZVm1Gc2RXVkxaWGxYVlc1cGRFdGxlU05BMDFZQUFBQUFBSUFEZ0FiU0NoY1lHVjhRRDBoTFZXNXBkRk4wY21sdVowdGxlWUFGZ0FSUmM5SWNIUjRqV0NSamJHRnpjMlZ6V2lSamJHRnpjMjVoYldXa0h5QWhJbHBJUzFScGJXVlZibWwwV2toTFFtRnpaVlZ1YVhSV1NFdFZibWwwV0U1VFQySnFaV04wV2toTFZHbHRaVlZ1YVhUU0hCMGxKNkltSWxwSVMxRjFZVzUwYVhSNVdraExVWFZoYm5ScGRIblNIQjBwSzZJcUlsOFFGVTVNVTJWemMybHZia0ZqZEdsMmFYUjVSMjloYkY4UUZVNU1VMlZ6YzJsdmJrRmpkR2wyYVhSNVIyOWhiQklBQVlhZ1h4QVBUbE5MWlhsbFpFRnlZMmhwZG1WeTBTOHdWSEp2YjNTQUFRQUlBQkVBR2dBakFDMEFNZ0E3QUVFQVNnQnFBSlFBbXdDNEFMb0F2QUMrQU1jQXpnRFhBTjhBNkFEcUFPd0E4UUVEQVFVQkJ3RUpBUTRCRndFaUFTY0JNZ0U5QVVRQlRRRllBVjBCWUFGckFYWUJld0YrQVpZQnJnR3pBY1VCeUFITkFBQUFBQUFBQWdFQUFBQUFBQUFBTVFBQUFBQUFBQUFBQUFBQUFBQUFBYzg9IiwidXVpZCI6IjQxQTAzQkFCLTlDMjktNDhGMC1CRTZELTFFOEJDREZCMjVEOSIsIm9jY3VycmVuY2UiOnsiY291bnQiOjAsImNyZWF0aW9uRGF0ZSI6Njg0NjY0MzE2LjkyODk5ODM1LCJtb2RpZmljYXRpb25EYXRlIjo3MDIxMDc0MDMuOTU0MjE2OTYsImNvdW50TW9kaWZpY2F0aW9uRGF0ZSI6Njg0NjY0MzE2LjkyODk5ODM1fSwiYWN0aXZpdHlUeXBlIjoiWW5Cc2FYTjBNRERVQVFJREJBVWtKU1pZSkc5aWFtVmpkSE5ZSkhabGNuTnBiMjVaSkdGeVkyaHBkbVZ5VkNSMGIzQ2xCZ2NVR2lCVkpHNTFiR3pXQ0FrS0N3d05EZzhPRVJJVFh4QWZSa2xWU1ZkdmNtdHZkWFJCWTNScGRtbDBlVlI1Y0dWSmMwbHVaRzl2Y2w4UUgwWkpWVWxYYjNKcmIzVjBRV04wYVhacGRIbFVlWEJsVFdWMFlXUmhkR0ZmRUNkR1NWVkpWMjl5YTI5MWRFRmpkR2wyYVhSNVZIbHdaVkJoY25SUFprMTFiSFJwYzNCdmNuUldKR05zWVhOelh4QWhSa2xWU1ZkdmNtdHZkWFJCWTNScGRtbDBlVlI1Y0dWSlpHVnVkR2xtYVdWeVh4QW9Ua3hUWlhOemFXOXVRWFY0YVd4cFlYSjVRV04wYVhacGRIbFVlWEJsU1dSbGJuUnBabWxsY2dpQUFnaUFCQkFZRUFEVEZRc1dGeGdaV2s1VExtOWlhbVZqZEhOWFRsTXVhMlY1YzZDQUE2RFNHeHdkSGxna1kyeGhjM05sYzFva1kyeGhjM051WVcxbG9oNGZYRTVUUkdsamRHbHZibUZ5ZVZoT1UwOWlhbVZqZE5JYkhDRWpvaUlmWHhBWFJrbFZTVmR2Y210dmRYUkJZM1JwZG1sMGVWUjVjR1ZmRUJkR1NWVkpWMjl5YTI5MWRFRmpkR2wyYVhSNVZIbHdaUklBQVlhZ1h4QVBUbE5MWlhsbFpFRnlZMmhwZG1WeTBTY29WSEp2YjNTQUFRQUlBQkVBR2dBakFDMEFNZ0E0QUQ0QVN3QnRBSThBdVFEQUFPUUJEd0VRQVJJQkV3RVZBUmNCR1FFZ0FTc0JNd0UwQVRZQk53RThBVVVCVUFGVEFXQUJhUUZ1QVhFQml3R2xBYW9CdkFHXC9BY1FBQUFBQUFBQUNBUUFBQUFBQUFBQXBBQUFBQUFBQUFBQUFBQUFBQUFBQnhnPT0ifQ==","objectModificationDate":702107514.08573401,"version":1,"numbers":{"configuration_type":1,"goal_type":2},"uuid":"41A03BAB-9C29-48F0-BE6D-1E8BCDFB25D9","objectState":0}
|
||||
""".data(using: .utf8)!),
|
||||
.privateWorkoutWeatherLocationCoordinatesLongitude : .number(value: 11.1684857327149),
|
||||
.privateWorkoutHeartRateZonesConfigurationType : .number(value: 0.0),
|
||||
.privateWorkoutAverageHeartRate : .numerical(value: 1.83192725788713, unit: "count/s"),
|
||||
.privateWorkoutExtendedMode : .number(value: 0.0),
|
||||
.privateWorkoutMaxHeartRate : .numerical(value: 2.58333333333333, unit: "count/s"),
|
||||
.privateWorkoutElapsedTimeInHeartRateZones : .data(value: .init(hex: "62706c6973743030d30102030405065131513251302340ad88000014000023407c37d7626000002340d637c27a270000080f1113151e270000000000000101000000000000000700000000000000000000000000000030")!)
|
||||
]
|
||||
}
|
16
HealthImport/Preview Content/Workout+Mock.swift
Normal file
16
HealthImport/Preview Content/Workout+Mock.swift
Normal file
@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
extension Workout {
|
||||
|
||||
static var mock1: Workout {
|
||||
.init(id: 8196339,
|
||||
totalDistance: 16.7620435816585,
|
||||
goalType: 2,
|
||||
goal: 19800.0,
|
||||
condenserVersion: 3,
|
||||
condenserDate: Date(timeIntervalSinceReferenceDate: 716801471.790011),
|
||||
events: WorkoutEvent.mock1,
|
||||
activities: [.mock1],
|
||||
metadata: Metadata.mock1)
|
||||
}
|
||||
}
|
18
HealthImport/Preview Content/WorkoutActivity+Mock.swift
Normal file
18
HealthImport/Preview Content/WorkoutActivity+Mock.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
extension WorkoutActivity {
|
||||
|
||||
static var mock1: WorkoutActivity = .init(
|
||||
id: 744,
|
||||
uuid: Data(hex: "0e0019a803d541e7b240feb7a360911a")!,
|
||||
isPrimaryActivity: true,
|
||||
activityType: .init(rawValue: 24)!,
|
||||
locationType: .init(rawValue: 3)!,
|
||||
swimmingLocationType: .init(rawValue: 0)!,
|
||||
lapLength: nil,
|
||||
startDate: .init(timeIntervalSinceReferenceDate: 702107518.84307),
|
||||
endDate: .init(timeIntervalSinceReferenceDate: 702143189.432644),
|
||||
duration: 27405.1830769777,
|
||||
metadata: nil)
|
||||
}
|
38
HealthImport/Preview Content/WorkoutEvent+Mock.swift
Normal file
38
HealthImport/Preview Content/WorkoutEvent+Mock.swift
Normal file
@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
extension WorkoutEvent {
|
||||
|
||||
static var mock1: [WorkoutEvent] {
|
||||
[
|
||||
.init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307),
|
||||
type: .init(rawValue: 7)!,
|
||||
duration: 1114.56374406815,
|
||||
metadata: .init(hex: mock1Event1Metadata)!,
|
||||
sessionUUID: nil,
|
||||
error: nil),
|
||||
.init(date: .init(timeIntervalSinceReferenceDate: 702107518.84307),
|
||||
type: .init(rawValue: 7)!,
|
||||
duration: 1972.17168283463,
|
||||
metadata: .init(hex: mock1Event2Metadata)!,
|
||||
sessionUUID: nil,
|
||||
error: nil),
|
||||
.init(date: .init(timeIntervalSinceReferenceDate: 702112942.707113),
|
||||
type: .init(rawValue: 1)!,
|
||||
duration: 0.0,
|
||||
metadata: nil,
|
||||
sessionUUID: nil,
|
||||
error: nil),
|
||||
.init(date: .init(timeIntervalSinceReferenceDate: 702113161.221132),
|
||||
type: .init(rawValue: 2)!,
|
||||
duration: 0.0,
|
||||
metadata: nil,
|
||||
sessionUUID: nil,
|
||||
error: nil),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private let mock1Event1Metadata = "0a370a275f484b507269766174654d65746164617461546f74616c44697374616e63655175616e74697479320c090000000000408f4012016d0a240a205f484b507269766174654d6574616461746149735061727469616c53706c697420000a3d0a2d5f484b507269766174654d6574616461746153706c69744163746976654475726174696f6e5175616e74697479320c098d1f2246416a91401201730a370a275f484b507269766174654d6574616461746153706c697444697374616e63655175616e74697479320c090000000000408f4012016d0a280a245f484b50726976617465576f726b6f75745365676d656e744576656e745375627479706520010a2a0a265f484b507269766174654d6574616461746153706c69744d6561737572696e6753797374656d2001"
|
||||
|
||||
private let mock1Event2Metadata = "0a370a275f484b507269766174654d65746164617461546f74616c44697374616e63655175616e74697479320c094c3789416025994012016d0a240a205f484b507269766174654d6574616461746149735061727469616c53706c697420000a3d0a2d5f484b507269766174654d6574616461746153706c69744163746976654475726174696f6e5175616e74697479320c09882da1cdafd09e401201730a370a275f484b507269766174654d6574616461746153706c697444697374616e63655175616e74697479320c094c3789416025994012016d0a280a245f484b50726976617465576f726b6f75745365676d656e744576656e745375627479706520010a2a0a265f484b507269766174654d6574616461746153706c69744d6561737572696e6753797374656d2002"
|
59
HealthImport/Support/Data+Extensions.swift
Normal file
59
HealthImport/Support/Data+Extensions.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
|
||||
public var hex: String {
|
||||
return map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
// Convert 0 ... 9, a ... f, A ...F to their decimal value,
|
||||
// return nil for all other input characters
|
||||
private func decodeNibble(_ u: UInt16) -> UInt8? {
|
||||
switch(u) {
|
||||
case 0x30 ... 0x39:
|
||||
return UInt8(u - 0x30)
|
||||
case 0x41 ... 0x46:
|
||||
return UInt8(u - 0x41 + 10)
|
||||
case 0x61 ... 0x66:
|
||||
return UInt8(u - 0x61 + 10)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public init?(hex string: String) {
|
||||
let utf16 = string.utf16
|
||||
self.init(capacity: utf16.count/2)
|
||||
|
||||
var i = utf16.startIndex
|
||||
guard utf16.count % 2 == 0 else {
|
||||
return nil
|
||||
}
|
||||
while i != utf16.endIndex {
|
||||
guard let hi = decodeNibble(utf16[i]),
|
||||
let lo = decodeNibble(utf16[utf16.index(i, offsetBy: 1, limitedBy: utf16.endIndex)!]) else {
|
||||
return nil
|
||||
}
|
||||
var value = hi << 4 + lo
|
||||
self.append(&value, count: 1)
|
||||
i = utf16.index(i, offsetBy: 2, limitedBy: utf16.endIndex)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
|
||||
|
||||
func convert<T>(into value: T) -> T {
|
||||
withUnsafeBytes {
|
||||
$0.baseAddress!.load(as: T.self)
|
||||
}
|
||||
}
|
||||
|
||||
init<T>(from value: T) {
|
||||
var target = value
|
||||
self = Swift.withUnsafeBytes(of: &target) {
|
||||
Data($0)
|
||||
}
|
||||
}
|
||||
}
|
@ -22,6 +22,13 @@ private let timeFormatter: DateFormatter = {
|
||||
return df
|
||||
}()
|
||||
|
||||
private let timeAndDataWithSecondsFormatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .short
|
||||
df.timeStyle = .medium
|
||||
return df
|
||||
}()
|
||||
|
||||
extension Date {
|
||||
|
||||
var durationSinceNowText: String {
|
||||
@ -60,4 +67,8 @@ extension Date {
|
||||
var timeAndDateText: String {
|
||||
dateFormatter.string(from: self)
|
||||
}
|
||||
|
||||
var timeAndDateWithSecondsText: String {
|
||||
timeAndDataWithSecondsFormatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ extension HKWorkoutEventType: CustomStringConvertible {
|
||||
case .lap: return "Lap"
|
||||
case .marker: return "Marker"
|
||||
case .motionPaused: return "Motion Paused"
|
||||
case .motionResumed: return "Motino Resumed"
|
||||
case .motionResumed: return "Motion Resumed"
|
||||
case .segment: return "Segment"
|
||||
case .pauseOrResumeRequest: return "Pause or Resume Request"
|
||||
@unknown default:
|
||||
|
@ -2,10 +2,17 @@ import Foundation
|
||||
|
||||
extension Optional {
|
||||
|
||||
func map<T>(_ transform: (Wrapped) -> T) -> T? {
|
||||
func map<T>(_ transform: (Wrapped) throws -> T) rethrows -> T? {
|
||||
guard let self else {
|
||||
return nil
|
||||
}
|
||||
return transform(self)
|
||||
return try transform(self)
|
||||
}
|
||||
|
||||
func map<T>(_ transform: (Wrapped) throws -> T?) rethrows -> T? {
|
||||
guard let self else {
|
||||
return nil
|
||||
}
|
||||
return try transform(self)
|
||||
}
|
||||
}
|
||||
|
13
HealthImport/Support/Sequence+Extensions.swift
Normal file
13
HealthImport/Support/Sequence+Extensions.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
extension Sequence {
|
||||
|
||||
func sorted<T>(ascending: Bool = true, using conversion: (Element) -> T) -> [Element] where T: Comparable {
|
||||
if ascending {
|
||||
return sorted { conversion($0) < conversion($1) }
|
||||
} else {
|
||||
return sorted { conversion($0) > conversion($1) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -12,6 +12,31 @@ extension Double {
|
||||
}
|
||||
return String(format: "%.1f m", self / 1000)
|
||||
}
|
||||
|
||||
var lengthAsMeter: String {
|
||||
guard self < 1000 else {
|
||||
return String(format: "%.2f km", self / 1000)
|
||||
}
|
||||
guard self < 100 else {
|
||||
return String(format: "%.0f m", self)
|
||||
}
|
||||
return String(format: "%.1f m", self)
|
||||
}
|
||||
|
||||
var meter: String {
|
||||
guard self < 100 else {
|
||||
return String(format: "%.0f m", self)
|
||||
}
|
||||
return String(format: "%.1f m", self)
|
||||
}
|
||||
|
||||
var speedAsMetersPerSecond: String {
|
||||
String(format: "%.1f m/s", self)
|
||||
}
|
||||
|
||||
func asDegrees(decimals: Int = 1) -> String {
|
||||
String(format: "%.\(decimals)f°", self)
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeInterval {
|
||||
|
@ -18,15 +18,6 @@ struct WorkoutDetailView: View {
|
||||
DetailRow("Condenser Version", value: workout.condenserVersion)
|
||||
DetailRow("Condenser Date", date: workout.condenserDate)
|
||||
}
|
||||
if !workout.events.isEmpty {
|
||||
Section("Events") {
|
||||
ForEach(workout.events, id: \.date) { event in
|
||||
NavigationLink(value: event) {
|
||||
DetailRow(event.type.description, date: event.date)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !workout.activities.isEmpty {
|
||||
Section("Activities") {
|
||||
ForEach(workout.activities, id: \.startDate) { activity in
|
||||
@ -38,15 +29,27 @@ struct WorkoutDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !workout.events.isEmpty {
|
||||
Section("Events") {
|
||||
ForEach(workout.events, id: \.date) { event in
|
||||
NavigationLink(value: event) {
|
||||
DetailRow(event.type.description, date: event.date)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !workout.metadata.isEmpty {
|
||||
Section("Metadata") {
|
||||
ForEach(workout.metadata.elements, id:\.key) { (key, value) in
|
||||
DetailRow(key, value: value)
|
||||
DetailRow(key.description, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(workout.typeString)
|
||||
.navigationDestination(for: WorkoutActivity.self) { activity in
|
||||
ActivityDetailView(activity: activity)
|
||||
.environmentObject(database)
|
||||
}
|
||||
.navigationDestination(for: WorkoutEvent.self) { event in
|
||||
EventDetailView(event: event)
|
||||
@ -56,35 +59,8 @@ struct WorkoutDetailView: View {
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
WorkoutDetailView(workout: .init(
|
||||
id: 123,
|
||||
totalDistance: 234.5,
|
||||
goalType: 3,
|
||||
goal: 345.6,
|
||||
condenserVersion: 1,
|
||||
condenserDate: .now,
|
||||
events: [
|
||||
.init(
|
||||
date: .now,
|
||||
type: .pause,
|
||||
duration: 12.3,
|
||||
metadata: .init(repeating: 42, count: 2),
|
||||
sessionUUID: .init(repeating: 42, count: 3),
|
||||
error: nil)
|
||||
],
|
||||
activities: [
|
||||
.init(
|
||||
uuid: .init(repeating: 42, count: 3),
|
||||
isPrimaryActivity: true,
|
||||
activityType: .running,
|
||||
locationType: .outdoor,
|
||||
swimmingLocationType: .unknown,
|
||||
lapLength: .init(repeating: 42, count: 3),
|
||||
startDate: .now.addingTimeInterval(-100),
|
||||
endDate: .now,
|
||||
duration: 100.0,
|
||||
metadata: .init(repeating: 42, count: 3))
|
||||
]))
|
||||
WorkoutDetailView(workout: .mock1)
|
||||
.environmentObject(HealthDatabase.mock())
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user