Refactor SQLite, add mock data

This commit is contained in:
Christoph Hagen 2024-01-25 13:48:00 +01:00
parent b89eb0103d
commit 218705a4d2
42 changed files with 1604 additions and 468 deletions

View File

@ -15,12 +15,10 @@
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002702B5C299900E7D4DB /* HealthDatabase.swift */; }; 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002702B5C299900E7D4DB /* HealthDatabase.swift */; };
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 885002762B5C2FC400E7D4DB /* SQLite */; }; 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 885002762B5C2FC400E7D4DB /* SQLite */; };
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002782B5C320400E7D4DB /* Optional+Extensions.swift */; }; 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002782B5C320400E7D4DB /* Optional+Extensions.swift */; };
8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */; }; 8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */; };
8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */; };
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027E2B5C36A700E7D4DB /* Workout.swift */; }; 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027E2B5C36A700E7D4DB /* Workout.swift */; };
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002842B5C7AD600E7D4DB /* WorkoutEvent.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 */; }; 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 */; }; 8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */; };
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.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 */; }; 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 */; }; 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 */; }; 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */; };
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.swift */; }; 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.swift */; };
8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */; }; 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */; };
885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */; };
885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A22B5D217600E7D4DB /* MetadataValue.swift */; }; 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A22B5D217600E7D4DB /* MetadataValue.swift */; };
885002A62B5D296700E7D4DB /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A52B5D296700E7D4DB /* Collections */; }; 885002A62B5D296700E7D4DB /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A52B5D296700E7D4DB /* Collections */; };
885002A82B5D296700E7D4DB /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A72B5D296700E7D4DB /* DequeModule */; }; 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A72B5D296700E7D4DB /* DequeModule */; };
885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A92B5D296700E7D4DB /* OrderedCollections */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -48,12 +64,10 @@
8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+SQLite.swift"; sourceTree = "<group>"; };
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutEvent.swift; sourceTree = "<group>"; };
8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataValue+SQLite.swift"; sourceTree = "<group>"; };
885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadataKey.swift; sourceTree = "<group>"; };
885002A22B5D217600E7D4DB /* MetadataValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataValue.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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -108,6 +140,8 @@
8850025C2B5C273C00E7D4DB /* ContentView.swift */, 8850025C2B5C273C00E7D4DB /* ContentView.swift */,
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */, 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
885002922B5D129300E7D4DB /* ActivityDetailView.swift */, 885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */,
E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */,
8850029C2B5D197300E7D4DB /* EventDetailView.swift */, 8850029C2B5D197300E7D4DB /* EventDetailView.swift */,
885002942B5D147100E7D4DB /* DetailRow.swift */, 885002942B5D147100E7D4DB /* DetailRow.swift */,
8850025E2B5C273E00E7D4DB /* Assets.xcassets */, 8850025E2B5C273E00E7D4DB /* Assets.xcassets */,
@ -124,6 +158,12 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
885002612B5C273E00E7D4DB /* Preview Assets.xcassets */, 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"; path = "Preview Content";
sourceTree = "<group>"; sourceTree = "<group>";
@ -139,11 +179,6 @@
885002802B5C37A800E7D4DB /* Database Entries */ = { 885002802B5C37A800E7D4DB /* Database Entries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */,
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */,
885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */,
8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */,
885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */,
); );
path = "Database Entries"; path = "Database Entries";
sourceTree = "<group>"; sourceTree = "<group>";
@ -151,10 +186,21 @@
885002812B5C37B700E7D4DB /* Model */ = { 885002812B5C37B700E7D4DB /* Model */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8850027E2B5C36A700E7D4DB /* Workout.swift */, E27BC6792B5D99AC003A8873 /* LocationSample.swift */,
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, E201EC7A2B6275CA005B83D3 /* Metadata.swift */,
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */, E201EC762B626FC1005B83D3 /* MetadataKey.swift */,
E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */,
885002A22B5D217600E7D4DB /* MetadataValue.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; path = Model;
sourceTree = "<group>"; sourceTree = "<group>";
@ -162,6 +208,7 @@
885002832B5C37C600E7D4DB /* Support */ = { 885002832B5C37C600E7D4DB /* Support */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */,
8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */, 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */,
885002782B5C320400E7D4DB /* Optional+Extensions.swift */, 885002782B5C320400E7D4DB /* Optional+Extensions.swift */,
885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */, 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */,
@ -169,6 +216,7 @@
885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */, 885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */,
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */, 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */,
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */, 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */,
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */,
); );
path = Support; path = Support;
sourceTree = "<group>"; sourceTree = "<group>";
@ -254,28 +302,44 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */,
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */, 885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */,
8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */, 8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */,
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */, 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */,
885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */, 885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */,
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */, 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */,
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.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 */, 8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */,
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */, 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */,
885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.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 */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */,
885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */,
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */, 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */,
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */, 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */,
8850027B2B5C35BF00E7D4DB /* DBWorkout.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 */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */, 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */,
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */,
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */, 8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */,
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */,
885002952B5D147100E7D4DB /* DetailRow.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 */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */, E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */,
885002892B5C873C00E7D4DB /* DBWorkoutActivity.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 */, 8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

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

View File

@ -2,8 +2,13 @@ import SwiftUI
struct ActivityDetailView: View { struct ActivityDetailView: View {
@EnvironmentObject
var database: HealthDatabase
let activity: WorkoutActivity let activity: WorkoutActivity
@State var locations: [LocationSample] = []
var body: some View { var body: some View {
List { List {
DetailRow("UUID", value: activity.uuid) DetailRow("UUID", value: activity.uuid)
@ -16,24 +21,39 @@ struct ActivityDetailView: View {
DetailRow("End", date: activity.endDate) DetailRow("End", date: activity.endDate)
DetailRow("Duration", duration: activity.duration) DetailRow("Duration", duration: activity.duration)
DetailRow("Metadata", value: activity.metadata) DetailRow("Metadata", value: activity.metadata)
if !locations.isEmpty {
NavigationLink(value: locations) {
DetailRow("Locations", value: "\(locations.count)")
}
} else {
DetailRow("Locations", value: "0")
}
} }
.navigationTitle("Activity") .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 { #Preview {
NavigationStack { NavigationStack {
ActivityDetailView(activity: .init( ActivityDetailView(activity: .mock1)
uuid: .init(repeating: 42, count: 3), .environmentObject(HealthDatabase.mock())
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))
)
} }
} }

View File

@ -3,11 +3,7 @@ import HealthKit
struct ContentView: View { struct ContentView: View {
static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite") @StateObject var database: HealthDatabase
@StateObject var database: HealthDatabase = {
try! .init(fileUrl: databaseFileUrl!)
}()
@State var navigationPath: NavigationPath = .init() @State var navigationPath: NavigationPath = .init()
@ -37,5 +33,5 @@ struct ContentView: View {
} }
#Preview { #Preview {
ContentView() ContentView(database: .mock())
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,13 +19,6 @@ struct EventDetailView: View {
#Preview { #Preview {
NavigationStack { NavigationStack {
EventDetailView(event: .init( EventDetailView(event: .mock1.first!)
date: .now,
type: .pause,
duration: 12.3,
metadata: .init(repeating: 42, count: 2),
sessionUUID: .init(repeating: 42, count: 3),
error: nil)
)
} }
} }

View File

@ -1,42 +1,67 @@
import Foundation import Foundation
import SQLite import SQLite
import CoreLocation
typealias Database = Connection
final class HealthDatabase: ObservableObject { final class HealthDatabase: ObservableObject {
let fileUrl: URL private let fileUrl: URL
let database: Connection private let database: Connection
@Published @Published
var workouts: [Workout] = [] 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.fileUrl = fileUrl
self.database = try Connection(fileUrl.path) self.database = database
DispatchQueue.global().async { DispatchQueue.global().async {
self.readAllWorkouts() self.readAllWorkouts()
} }
} }
func readAllWorkouts() { func readAllWorkouts() {
let workouts: [Workout]
do { do {
let dbWorkouts = try DBWorkout.readAll(in: database) workouts = try Workout.readAll(in: database)
let metadataKeys = try DBMetadataKey.readAll(in: database) } catch {
let workouts = try dbWorkouts.map { entry in print("Failed to read workouts: \(error)")
let events = try DBWorkoutEvent.events(for: entry.dataId, in: database) return
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)
} }
return Workout(entry: entry, events: events, activities: activities, metadata: metadata)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.workouts = workouts 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)
}
} }

View File

@ -1,17 +1,20 @@
//
// HealthImportApp.swift
// HealthImport
//
// Created by iMac on 20.01.24.
//
import SwiftUI import SwiftUI
@main @main
struct HealthImportApp: App { struct HealthImportApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { 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!)
}
}

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

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

View 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())
}
}

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

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

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

View 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]!)
}
}
}

View File

@ -1,6 +1,8 @@
import Foundation import Foundation
enum MetadataValue { extension Metadata {
enum Value {
case string(value: String) case string(value: String)
case number(value: Double) case number(value: Double)
@ -25,28 +27,20 @@ enum MetadataValue {
/// Uses only the `data_value` column /// Uses only the `data_value` column
case data = 4 case data = 4
} }
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 {
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!)
} }
} }
} }
extension MetadataValue: CustomStringConvertible { extension Metadata.Value: CustomStringConvertible {
var description: String { var description: String {
switch self { switch self {

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

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

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

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

View File

@ -28,7 +28,7 @@ struct Workout {
let activities: [WorkoutActivity] let activities: [WorkoutActivity]
let metadata: OrderedDictionary<String, MetadataValue> let metadata: OrderedDictionary<Metadata.Key, Metadata.Value>
var firstActivityDate: Date? { var firstActivityDate: Date? {
activities.map { $0.startDate }.min() activities.map { $0.startDate }.min()
@ -53,7 +53,7 @@ struct Workout {
activities.first?.activityType.description ?? "Unknown activity" 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.id = id
self.totalDistance = totalDistance self.totalDistance = totalDistance
self.goalType = goalType 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: Identifiable { }
extension Workout: Equatable { extension Workout: Equatable {

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

View File

@ -3,6 +3,8 @@ import HealthKit
struct WorkoutActivity { struct WorkoutActivity {
private let id: Int
let uuid: Data let uuid: Data
let isPrimaryActivity: Bool let isPrimaryActivity: Bool
@ -15,6 +17,7 @@ struct WorkoutActivity {
let lapLength: Data? let lapLength: Data?
#warning("Fix timezone for dates")
let startDate: Date let startDate: Date
let endDate: Date let endDate: Date
@ -22,28 +25,44 @@ struct WorkoutActivity {
let duration: TimeInterval let duration: TimeInterval
let metadata: Data? let metadata: Data?
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
} }
extension WorkoutActivity { func locationSamples(in database: Database) throws -> [LocationSample] {
try LocationSample.locationSamples(from: startDate, to: endDate, in: database)
}
init(entry: DBWorkoutActivity) { func locationSampleCount(in database: Database) throws -> Int {
self.uuid = entry.uuid try LocationSample.locationSampleCount(from: startDate, to: endDate, in: database)
self.isPrimaryActivity = entry.isPrimaryActivity }
self.activityType = .init(rawValue: UInt(entry.activityType))!
self.locationType = .init(rawValue: entry.locationType)! func sampleCount(in database: Database) throws -> Int {
self.swimmingLocationType = .init(rawValue: entry.swimmingLocationType)! try Sample.sampleCount(from: startDate, to: endDate, in: database)
self.lapLength = entry.lapLength }
self.startDate = Date(timeIntervalSinceReferenceDate: entry.startDate)
self.endDate = Date(timeIntervalSinceReferenceDate: entry.endDate) func samples(in database: Database) throws -> [SampleDataType : Sample] {
self.duration = entry.duration try Sample.samples(from: startDate, to: endDate, in: database).reduce(into: [:]) {
self.metadata = entry.metadata $0[$1.dataType] = $1
}
} }
} }
extension WorkoutActivity: Equatable { extension WorkoutActivity: Equatable {
static func == (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool { 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 { extension WorkoutActivity: Hashable {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(uuid) hasher.combine(id)
} }
} }

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

View File

@ -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 { extension WorkoutEvent: Equatable {
static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool { static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool {

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

View 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())
}

View 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")!)
]
}

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

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

View 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"

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

View File

@ -22,6 +22,13 @@ private let timeFormatter: DateFormatter = {
return df return df
}() }()
private let timeAndDataWithSecondsFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .medium
return df
}()
extension Date { extension Date {
var durationSinceNowText: String { var durationSinceNowText: String {
@ -60,4 +67,8 @@ extension Date {
var timeAndDateText: String { var timeAndDateText: String {
dateFormatter.string(from: self) dateFormatter.string(from: self)
} }
var timeAndDateWithSecondsText: String {
timeAndDataWithSecondsFormatter.string(from: self)
}
} }

View File

@ -10,7 +10,7 @@ extension HKWorkoutEventType: CustomStringConvertible {
case .lap: return "Lap" case .lap: return "Lap"
case .marker: return "Marker" case .marker: return "Marker"
case .motionPaused: return "Motion Paused" case .motionPaused: return "Motion Paused"
case .motionResumed: return "Motino Resumed" case .motionResumed: return "Motion Resumed"
case .segment: return "Segment" case .segment: return "Segment"
case .pauseOrResumeRequest: return "Pause or Resume Request" case .pauseOrResumeRequest: return "Pause or Resume Request"
@unknown default: @unknown default:

View File

@ -2,10 +2,17 @@ import Foundation
extension Optional { extension Optional {
func map<T>(_ transform: (Wrapped) -> T) -> T? { func map<T>(_ transform: (Wrapped) throws -> T) rethrows -> T? {
guard let self else { guard let self else {
return nil 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)
} }
} }

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

View File

@ -12,6 +12,31 @@ extension Double {
} }
return String(format: "%.1f m", self / 1000) 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)", self)
}
} }
extension TimeInterval { extension TimeInterval {

View File

@ -18,15 +18,6 @@ struct WorkoutDetailView: View {
DetailRow("Condenser Version", value: workout.condenserVersion) DetailRow("Condenser Version", value: workout.condenserVersion)
DetailRow("Condenser Date", date: workout.condenserDate) 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 { if !workout.activities.isEmpty {
Section("Activities") { Section("Activities") {
ForEach(workout.activities, id: \.startDate) { activity in 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 { if !workout.metadata.isEmpty {
Section("Metadata") {
ForEach(workout.metadata.elements, id:\.key) { (key, value) in ForEach(workout.metadata.elements, id:\.key) { (key, value) in
DetailRow(key, value: value) DetailRow(key.description, value: value)
}
} }
} }
} }
.navigationTitle(workout.typeString) .navigationTitle(workout.typeString)
.navigationDestination(for: WorkoutActivity.self) { activity in .navigationDestination(for: WorkoutActivity.self) { activity in
ActivityDetailView(activity: activity) ActivityDetailView(activity: activity)
.environmentObject(database)
} }
.navigationDestination(for: WorkoutEvent.self) { event in .navigationDestination(for: WorkoutEvent.self) { event in
EventDetailView(event: event) EventDetailView(event: event)
@ -56,35 +59,8 @@ struct WorkoutDetailView: View {
#Preview { #Preview {
NavigationStack { NavigationStack {
WorkoutDetailView(workout: .init( WorkoutDetailView(workout: .mock1)
id: 123, .environmentObject(HealthDatabase.mock())
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))
]))
} }
} }