From 218705a4d2ddfb86d0252020884b1c9908e9609e Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Thu, 25 Jan 2024 13:48:00 +0100 Subject: [PATCH] Refactor SQLite, add mock data --- HealthImport.xcodeproj/project.pbxproj | 110 ++++-- .../xcschemes/xcschememanagement.plist | 35 ++ HealthImport/ActivityDetailView.swift | 48 ++- HealthImport/ContentView.swift | 8 +- .../Database Entries/DBMetadata.swift | 57 --- .../Database Entries/DBMetadataKey.swift | 21 -- HealthImport/Database Entries/DBWorkout.swift | 45 --- .../Database Entries/DBWorkoutActivity.swift | 79 ----- .../Database Entries/DBWorkoutEvent.swift | 53 --- HealthImport/EventDetailView.swift | 9 +- HealthImport/HealthDatabase.swift | 69 ++-- HealthImport/HealthImportApp.swift | 19 +- HealthImport/LocationSampleDetailView.swift | 29 ++ HealthImport/LocationSampleListView.swift | 26 ++ HealthImport/Model/LocationSample.swift | 71 ++++ HealthImport/Model/Metadata.swift | 29 ++ HealthImport/Model/MetadataKey+SQLite.swift | 38 ++ HealthImport/Model/MetadataKey.swift | 325 ++++++++++++++++++ HealthImport/Model/MetadataValue+SQLite.swift | 124 +++++++ HealthImport/Model/MetadataValue.swift | 78 ++--- HealthImport/Model/Sample+Quantity.swift | 23 ++ HealthImport/Model/Sample+Unit.swift | 18 + HealthImport/Model/Sample.swift | 87 +++++ HealthImport/Model/Workout+SQLite.swift | 85 +++++ HealthImport/Model/Workout.swift | 21 +- .../Model/WorkoutActivity+SQLite.swift | 93 +++++ HealthImport/Model/WorkoutActivity.swift | 53 ++- HealthImport/Model/WorkoutEvent+SQLite.swift | 79 +++++ HealthImport/Model/WorkoutEvent.swift | 12 - .../Preview Content/HealthDatabase+Mock.swift | 28 ++ .../Preview Content/Location+Mock.swift | 17 + .../Preview Content/Metadata+Mock.swift | 34 ++ .../Preview Content/Workout+Mock.swift | 16 + .../WorkoutActivity+Mock.swift | 18 + .../Preview Content/WorkoutEvent+Mock.swift | 38 ++ HealthImport/Support/Data+Extensions.swift | 59 ++++ HealthImport/Support/Date+Extensions.swift | 11 + .../HKWorkoutEventType+Extensions.swift | 2 +- .../Support/Optional+Extensions.swift | 11 +- .../Support/Sequence+Extensions.swift | 13 + .../Support/TimeInterval+Extensions.swift | 25 ++ HealthImport/WorkoutDetailView.swift | 56 +-- 42 files changed, 1604 insertions(+), 468 deletions(-) create mode 100644 HealthImport.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist delete mode 100644 HealthImport/Database Entries/DBMetadata.swift delete mode 100644 HealthImport/Database Entries/DBMetadataKey.swift delete mode 100644 HealthImport/Database Entries/DBWorkout.swift delete mode 100644 HealthImport/Database Entries/DBWorkoutActivity.swift delete mode 100644 HealthImport/Database Entries/DBWorkoutEvent.swift create mode 100644 HealthImport/LocationSampleDetailView.swift create mode 100644 HealthImport/LocationSampleListView.swift create mode 100644 HealthImport/Model/LocationSample.swift create mode 100644 HealthImport/Model/Metadata.swift create mode 100644 HealthImport/Model/MetadataKey+SQLite.swift create mode 100644 HealthImport/Model/MetadataKey.swift create mode 100644 HealthImport/Model/MetadataValue+SQLite.swift create mode 100644 HealthImport/Model/Sample+Quantity.swift create mode 100644 HealthImport/Model/Sample+Unit.swift create mode 100644 HealthImport/Model/Sample.swift create mode 100644 HealthImport/Model/Workout+SQLite.swift create mode 100644 HealthImport/Model/WorkoutActivity+SQLite.swift create mode 100644 HealthImport/Model/WorkoutEvent+SQLite.swift create mode 100644 HealthImport/Preview Content/HealthDatabase+Mock.swift create mode 100644 HealthImport/Preview Content/Location+Mock.swift create mode 100644 HealthImport/Preview Content/Metadata+Mock.swift create mode 100644 HealthImport/Preview Content/Workout+Mock.swift create mode 100644 HealthImport/Preview Content/WorkoutActivity+Mock.swift create mode 100644 HealthImport/Preview Content/WorkoutEvent+Mock.swift create mode 100644 HealthImport/Support/Data+Extensions.swift create mode 100644 HealthImport/Support/Sequence+Extensions.swift diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index a46a9e5..9c66cba 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -15,12 +15,10 @@ 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002702B5C299900E7D4DB /* HealthDatabase.swift */; }; 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 885002762B5C2FC400E7D4DB /* SQLite */; }; 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002782B5C320400E7D4DB /* Optional+Extensions.swift */; }; - 8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */; }; - 8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */; }; + 8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */; }; 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027E2B5C36A700E7D4DB /* Workout.swift */; }; 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */; }; 885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */; }; - 885002892B5C873C00E7D4DB /* DBWorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */; }; 8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */; }; 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */; }; 8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */; }; @@ -31,12 +29,30 @@ 885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */; }; 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */; }; 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.swift */; }; - 8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */; }; - 885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */; }; + 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */; }; 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A22B5D217600E7D4DB /* MetadataValue.swift */; }; 885002A62B5D296700E7D4DB /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A52B5D296700E7D4DB /* Collections */; }; 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A72B5D296700E7D4DB /* DequeModule */; }; 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A92B5D296700E7D4DB /* OrderedCollections */; }; + E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC722B626A30005B83D3 /* WorkoutActivity+Mock.swift */; }; + E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC742B626B19005B83D3 /* Metadata+Mock.swift */; }; + E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC762B626FC1005B83D3 /* MetadataKey.swift */; }; + E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */; }; + E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7A2B6275CA005B83D3 /* Metadata.swift */; }; + E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; }; + E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */; }; + E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */; }; + E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */; }; + E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6832B5E76A4003A8873 /* Location+Mock.swift */; }; + E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6852B5FBF0B003A8873 /* Sample.swift */; }; + E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */; }; + E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* Sample+Unit.swift */; }; + E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */; }; + E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; }; + E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */; }; + E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6932B5FD587003A8873 /* Workout+Mock.swift */; }; + E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */; }; + E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -48,12 +64,10 @@ 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = ""; }; 885002702B5C299900E7D4DB /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = ""; }; 885002782B5C320400E7D4DB /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; - 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkout.swift; sourceTree = ""; }; - 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutEvent.swift; sourceTree = ""; }; + 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+SQLite.swift"; sourceTree = ""; }; 8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = ""; }; 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEvent.swift; sourceTree = ""; }; 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Extensions.swift"; sourceTree = ""; }; - 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBWorkoutActivity.swift; sourceTree = ""; }; 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivity.swift; sourceTree = ""; }; 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = ""; }; 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; @@ -64,9 +78,27 @@ 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = ""; }; 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; 8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = ""; }; - 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadata.swift; sourceTree = ""; }; - 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadataKey.swift; sourceTree = ""; }; + 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataValue+SQLite.swift"; sourceTree = ""; }; 885002A22B5D217600E7D4DB /* MetadataValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataValue.swift; sourceTree = ""; }; + E201EC722B626A30005B83D3 /* WorkoutActivity+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+Mock.swift"; sourceTree = ""; }; + E201EC742B626B19005B83D3 /* Metadata+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Mock.swift"; sourceTree = ""; }; + E201EC762B626FC1005B83D3 /* MetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataKey.swift; sourceTree = ""; }; + E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+SQLite.swift"; sourceTree = ""; }; + E201EC7A2B6275CA005B83D3 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; + E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = ""; }; + E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = ""; }; + E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleListView.swift; sourceTree = ""; }; + E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleDetailView.swift; sourceTree = ""; }; + E27BC6832B5E76A4003A8873 /* Location+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+Mock.swift"; sourceTree = ""; }; + E27BC6852B5FBF0B003A8873 /* Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sample.swift; sourceTree = ""; }; + E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Quantity.swift"; sourceTree = ""; }; + E27BC6892B5FC255003A8873 /* Sample+Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Unit.swift"; sourceTree = ""; }; + E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = ""; }; + E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = ""; }; + E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = ""; }; + E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = ""; }; + E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = ""; }; + E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -108,6 +140,8 @@ 8850025C2B5C273C00E7D4DB /* ContentView.swift */, 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */, 885002922B5D129300E7D4DB /* ActivityDetailView.swift */, + E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */, + E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */, 8850029C2B5D197300E7D4DB /* EventDetailView.swift */, 885002942B5D147100E7D4DB /* DetailRow.swift */, 8850025E2B5C273E00E7D4DB /* Assets.xcassets */, @@ -124,6 +158,12 @@ isa = PBXGroup; children = ( 885002612B5C273E00E7D4DB /* Preview Assets.xcassets */, + E27BC6832B5E76A4003A8873 /* Location+Mock.swift */, + E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */, + E27BC6932B5FD587003A8873 /* Workout+Mock.swift */, + E201EC742B626B19005B83D3 /* Metadata+Mock.swift */, + E201EC722B626A30005B83D3 /* WorkoutActivity+Mock.swift */, + E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */, ); path = "Preview Content"; sourceTree = ""; @@ -139,11 +179,6 @@ 885002802B5C37A800E7D4DB /* Database Entries */ = { isa = PBXGroup; children = ( - 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */, - 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */, - 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */, - 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */, - 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */, ); path = "Database Entries"; sourceTree = ""; @@ -151,10 +186,21 @@ 885002812B5C37B700E7D4DB /* Model */ = { isa = PBXGroup; children = ( - 8850027E2B5C36A700E7D4DB /* Workout.swift */, - 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, - 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */, + E27BC6792B5D99AC003A8873 /* LocationSample.swift */, + E201EC7A2B6275CA005B83D3 /* Metadata.swift */, + E201EC762B626FC1005B83D3 /* MetadataKey.swift */, + E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */, 885002A22B5D217600E7D4DB /* MetadataValue.swift */, + 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */, + E27BC6852B5FBF0B003A8873 /* Sample.swift */, + E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */, + E27BC6892B5FC255003A8873 /* Sample+Unit.swift */, + 8850027E2B5C36A700E7D4DB /* Workout.swift */, + 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */, + 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */, + E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */, + 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, + E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */, ); path = Model; sourceTree = ""; @@ -162,6 +208,7 @@ 885002832B5C37C600E7D4DB /* Support */ = { isa = PBXGroup; children = ( + E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */, 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */, 885002782B5C320400E7D4DB /* Optional+Extensions.swift */, 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */, @@ -169,6 +216,7 @@ 885002962B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift */, 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */, 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */, + E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */, ); path = Support; sourceTree = ""; @@ -254,28 +302,44 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */, 885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */, 8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */, 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */, 885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */, 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */, + E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */, 885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */, + E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */, + E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */, 8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */, 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */, 885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */, + E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */, + E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */, + E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */, - 885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */, 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */, + E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, - 8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */, - 8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */, + 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */, + E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */, + 8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */, + E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */, + E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */, 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */, + E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */, 8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */, + E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, + E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */, + E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, - 8850027D2B5C360300E7D4DB /* DBWorkoutEvent.swift in Sources */, - 885002892B5C873C00E7D4DB /* DBWorkoutActivity.swift in Sources */, + E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */, + E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */, + E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */, + E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */, 8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/HealthImport.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist b/HealthImport.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..7af10f9 --- /dev/null +++ b/HealthImport.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,35 @@ + + + + + SchemeUserState + + HealthImport.xcscheme_^#shared#^_ + + orderHint + 0 + + SQLite (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + SQLite (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + SQLite (Playground).xcscheme + + isShown + + orderHint + 1 + + + + diff --git a/HealthImport/ActivityDetailView.swift b/HealthImport/ActivityDetailView.swift index bd4c036..a34d4af 100644 --- a/HealthImport/ActivityDetailView.swift +++ b/HealthImport/ActivityDetailView.swift @@ -1,9 +1,14 @@ import SwiftUI struct ActivityDetailView: View { - + + @EnvironmentObject + var database: HealthDatabase + let activity: WorkoutActivity - + + @State var locations: [LocationSample] = [] + var body: some View { List { DetailRow("UUID", value: activity.uuid) @@ -16,24 +21,39 @@ struct ActivityDetailView: View { DetailRow("End", date: activity.endDate) DetailRow("Duration", duration: activity.duration) DetailRow("Metadata", value: activity.metadata) + if !locations.isEmpty { + NavigationLink(value: locations) { + DetailRow("Locations", value: "\(locations.count)") + } + } else { + DetailRow("Locations", value: "0") + } } .navigationTitle("Activity") + .navigationDestination(for: [LocationSample].self) { locations in + LocationSampleListView(samples: locations) + } + .onAppear(perform: load) + } + + private func load() { + Task { + do { + let samples = try database.locationSamples(for: activity) + .sorted { $0.timestamp } + DispatchQueue.main.async { + self.locations = samples + } + } catch { + print("Failed to load location samples for activity: \(error)") + } + } } } #Preview { NavigationStack { - ActivityDetailView(activity: .init( - uuid: .init(repeating: 42, count: 3), - isPrimaryActivity: true, - activityType: .running, - locationType: .outdoor, - swimmingLocationType: .unknown, - lapLength: .init(repeating: 42, count: 3), - startDate: .now.addingTimeInterval(-100), - endDate: .now, - duration: 100.0, - metadata: .init(repeating: 42, count: 3)) - ) + ActivityDetailView(activity: .mock1) + .environmentObject(HealthDatabase.mock()) } } diff --git a/HealthImport/ContentView.swift b/HealthImport/ContentView.swift index 4236022..39cdf7d 100644 --- a/HealthImport/ContentView.swift +++ b/HealthImport/ContentView.swift @@ -3,11 +3,7 @@ import HealthKit struct ContentView: View { - static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite") - - @StateObject var database: HealthDatabase = { - try! .init(fileUrl: databaseFileUrl!) - }() + @StateObject var database: HealthDatabase @State var navigationPath: NavigationPath = .init() @@ -37,5 +33,5 @@ struct ContentView: View { } #Preview { - ContentView() + ContentView(database: .mock()) } diff --git a/HealthImport/Database Entries/DBMetadata.swift b/HealthImport/Database Entries/DBMetadata.swift deleted file mode 100644 index 032594e..0000000 --- a/HealthImport/Database Entries/DBMetadata.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import SQLite - -struct DBMetadata { - - private static let table = Table("metadata_values") - - private static let rowKeyId = Expression("key_id") - - private static let rowObjectId = Expression("object_id") - - private static let rowValueType = Expression("value_type") - - private static let rowStringValue = Expression("string_value") - - private static let rowNumericalValue = Expression("numerical_value") - - private static let rowDateValue = Expression("date_value") - - private static let rowDataValue = Expression("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] - } -} - diff --git a/HealthImport/Database Entries/DBMetadataKey.swift b/HealthImport/Database Entries/DBMetadataKey.swift deleted file mode 100644 index 8f23d77..0000000 --- a/HealthImport/Database Entries/DBMetadataKey.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import SQLite - -struct DBMetadataKey { - - private static let table = Table("metadata_keys") - - private static let rowId = Expression("ROWID") - - private static let rowKey = Expression("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] - } - } -} diff --git a/HealthImport/Database Entries/DBWorkout.swift b/HealthImport/Database Entries/DBWorkout.swift deleted file mode 100644 index 1a6acbb..0000000 --- a/HealthImport/Database Entries/DBWorkout.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import SQLite - -struct DBWorkout { - - private static let table = Table("workouts") - - private static let rowDataId = Expression("data_id") - - private static let rowTotalDistance = Expression("total_distance") - - private static let rowGoalType = Expression("goal_type") - - private static let rowGoal = Expression("goal") - - private static let rowCondenserVersion = Expression("condenser_version") - - private static let rowCondenserDate = Expression("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] - } -} - diff --git a/HealthImport/Database Entries/DBWorkoutActivity.swift b/HealthImport/Database Entries/DBWorkoutActivity.swift deleted file mode 100644 index f7f78eb..0000000 --- a/HealthImport/Database Entries/DBWorkoutActivity.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import SQLite - -struct DBWorkoutActivity { - - private static let table = Table("workout_activities") - - private static let rowId = Expression("ROWID") - - private static let rowUUID = Expression("uuid") - - private static let rowOwnerId = Expression("owner_id") - - private static let rowIsPrimaryActivity = Expression("is_primary_activity") - - private static let rowActivityType = Expression("activity_type") - - private static let rowLocationType = Expression("location_type") - - private static let rowSwimmingLocationType = Expression("swimming_location_type") - - private static let rowLapLength = Expression("lap_length") - - private static let rowStartDate = Expression("start_date") - - private static let rowEndDate = Expression("end_date") - - private static let rowDuration = Expression("duration") - - private static let rowMetadata = Expression("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] - } -} diff --git a/HealthImport/Database Entries/DBWorkoutEvent.swift b/HealthImport/Database Entries/DBWorkoutEvent.swift deleted file mode 100644 index a37ec43..0000000 --- a/HealthImport/Database Entries/DBWorkoutEvent.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import SQLite - -struct DBWorkoutEvent { - - private static let table = Table("workout_events") - - private static let rowOwnerId = Expression("owner_id") - - private static let rowDate = Expression("date") - - private static let rowType = Expression("type") - - private static let rowDuration = Expression("duration") - - private static let rowMetadata = Expression("metadata") - - private static let rowSessionUUID = Expression("session_uuid") - - private static let rowError = Expression("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] - } -} diff --git a/HealthImport/EventDetailView.swift b/HealthImport/EventDetailView.swift index 0efd064..2ef0afd 100644 --- a/HealthImport/EventDetailView.swift +++ b/HealthImport/EventDetailView.swift @@ -19,13 +19,6 @@ struct EventDetailView: View { #Preview { NavigationStack { - EventDetailView(event: .init( - date: .now, - type: .pause, - duration: 12.3, - metadata: .init(repeating: 42, count: 2), - sessionUUID: .init(repeating: 42, count: 3), - error: nil) - ) + EventDetailView(event: .mock1.first!) } } diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift index d972a1f..f6b5a90 100644 --- a/HealthImport/HealthDatabase.swift +++ b/HealthImport/HealthDatabase.swift @@ -1,42 +1,67 @@ import Foundation import SQLite +import CoreLocation + +typealias Database = Connection final class HealthDatabase: ObservableObject { - let fileUrl: URL - - let database: Connection - + private let fileUrl: URL + + private let database: Connection + @Published var workouts: [Workout] = [] - init(fileUrl: URL) throws { + convenience init(fileUrl: URL) throws { + let database = try Connection(fileUrl.path) + self.init(fileUrl: fileUrl, database: database) + } + + init(fileUrl: URL, database: Connection) { self.fileUrl = fileUrl - self.database = try Connection(fileUrl.path) + self.database = database DispatchQueue.global().async { self.readAllWorkouts() } } - + func readAllWorkouts() { + let workouts: [Workout] do { - let dbWorkouts = try DBWorkout.readAll(in: database) - let metadataKeys = try DBMetadataKey.readAll(in: database) - let workouts = try dbWorkouts.map { entry in - let events = try DBWorkoutEvent.events(for: entry.dataId, in: database) - let activities = try DBWorkoutActivity.activities(for: entry.dataId, in: database) - let metadata: [String : MetadataValue] = try DBMetadata.metadata(for: entry.dataId, in: database).reduce(into: [:]) { dict, item in - let key = metadataKeys[item.keyId!]! - dict[key] = MetadataValue(entry: item) - } - return Workout(entry: entry, events: events, activities: activities, metadata: metadata) - } - - DispatchQueue.main.async { - self.workouts = workouts - } + workouts = try Workout.readAll(in: database) } catch { print("Failed to read workouts: \(error)") + return + } + DispatchQueue.main.async { + self.workouts = workouts } } + + 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) + } } diff --git a/HealthImport/HealthImportApp.swift b/HealthImport/HealthImportApp.swift index 276f9d7..fa9f76e 100644 --- a/HealthImport/HealthImportApp.swift +++ b/HealthImport/HealthImportApp.swift @@ -1,17 +1,20 @@ -// -// HealthImportApp.swift -// HealthImport -// -// Created by iMac on 20.01.24. -// - import SwiftUI @main struct HealthImportApp: App { + var body: some Scene { WindowGroup { - ContentView() + ContentView(database: .init()) } } } + +private extension HealthDatabase { + + static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite") + + convenience init() { + try! self.init(fileUrl: HealthDatabase.databaseFileUrl!) + } +} diff --git a/HealthImport/LocationSampleDetailView.swift b/HealthImport/LocationSampleDetailView.swift new file mode 100644 index 0000000..8255089 --- /dev/null +++ b/HealthImport/LocationSampleDetailView.swift @@ -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) + } +} diff --git a/HealthImport/LocationSampleListView.swift b/HealthImport/LocationSampleListView.swift new file mode 100644 index 0000000..3feffa9 --- /dev/null +++ b/HealthImport/LocationSampleListView.swift @@ -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]) + } +} diff --git a/HealthImport/Model/LocationSample.swift b/HealthImport/Model/LocationSample.swift new file mode 100644 index 0000000..caaa169 --- /dev/null +++ b/HealthImport/Model/LocationSample.swift @@ -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("series_identifier") + + private static let rowTimestamp = Expression("timestamp") + + private static let rowLongitude = Expression("longitude") + + private static let rowLatitude = Expression("latitude") + + private static let rowAltitude = Expression("altitude") + + private static let rowSpeed = Expression("speed") + + private static let rowCourse = Expression("course") + + private static let rowHorizontalAccuracy = Expression("horizontal_accuracy") + + private static let rowVerticalAccuracy = Expression("vertical_accuracy") + + private static let rowSpeedAccuracy = Expression("speed_accuracy") + + private static let rowCourseAccuracy = Expression("course_accuracy") + + private static let rowSignalEnvironment = Expression("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()) + } +} diff --git a/HealthImport/Model/Metadata.swift b/HealthImport/Model/Metadata.swift new file mode 100644 index 0000000..3be7b2e --- /dev/null +++ b/HealthImport/Model/Metadata.swift @@ -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) + } +} diff --git a/HealthImport/Model/MetadataKey+SQLite.swift b/HealthImport/Model/MetadataKey+SQLite.swift new file mode 100644 index 0000000..7805fdd --- /dev/null +++ b/HealthImport/Model/MetadataKey+SQLite.swift @@ -0,0 +1,38 @@ +import Foundation +import SQLite + +extension Metadata.Key { + + private static let table = Table("metadata_keys") + + private static let columnId = Expression("ROWID") + + private static let columnKey = Expression("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))) + } + +} diff --git a/HealthImport/Model/MetadataKey.swift b/HealthImport/Model/MetadataKey.swift new file mode 100644 index 0000000..29b439d --- /dev/null +++ b/HealthImport/Model/MetadataKey.swift @@ -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 + } + } +} diff --git a/HealthImport/Model/MetadataValue+SQLite.swift b/HealthImport/Model/MetadataValue+SQLite.swift new file mode 100644 index 0000000..e07a9f3 --- /dev/null +++ b/HealthImport/Model/MetadataValue+SQLite.swift @@ -0,0 +1,124 @@ +import Foundation +import SQLite + +extension Metadata.Value { + + private static let table = Table("metadata_values") + + private static let columnRowId = Expression("ROW_ID") + + private static let columnKeyId = Expression("key_id") + + private static let columnObjectId = Expression("object_id") + + private static let columnValueType = Expression("value_type") + + private static let columnStringValue = Expression("string_value") + + private static let columnNumericalValue = Expression("numerical_value") + + private static let columnDateValue = Expression("date_value") + + private static let columnDataValue = Expression("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]!) + } + } +} diff --git a/HealthImport/Model/MetadataValue.swift b/HealthImport/Model/MetadataValue.swift index 2294648..9ed5239 100644 --- a/HealthImport/Model/MetadataValue.swift +++ b/HealthImport/Model/MetadataValue.swift @@ -1,52 +1,46 @@ import Foundation -enum MetadataValue { - - case string(value: String) - case number(value: Double) - case date(value: Date) - case numerical(value: Double, unit: String) - case data(value: Data) - - enum ValueType: Int { - - /// Uses only the `string_value` column - case string = 0 - - /// Uses only the `numerical_value` column - case number = 1 - - /// Uses only the `date_value` column - case date = 2 - - /// Uses the `string_value` column for the unit, and the `numerical_value` column for the number - case numerical = 3 - - /// Uses only the `data_value` column - case data = 4 - } -} +extension Metadata { -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!) + enum Value { + + case string(value: String) + case number(value: Double) + case date(value: Date) + case numerical(value: Double, unit: String) + case data(value: Data) + + enum ValueType: Int { + + /// Uses only the `string_value` column + case string = 0 + + /// Uses only the `numerical_value` column + case number = 1 + + /// Uses only the `date_value` column + case date = 2 + + /// Uses the `string_value` column for the unit, and the `numerical_value` column for the number + case numerical = 3 + + /// Uses only the `data_value` column + 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: CustomStringConvertible { +extension Metadata.Value: CustomStringConvertible { var description: String { switch self { diff --git a/HealthImport/Model/Sample+Quantity.swift b/HealthImport/Model/Sample+Quantity.swift new file mode 100644 index 0000000..c3f10ad --- /dev/null +++ b/HealthImport/Model/Sample+Quantity.swift @@ -0,0 +1,23 @@ +import Foundation +import SQLite + +extension Sample { + + private static let table = Table("quantity_samples") + + private static let rowDataId = Expression("data_id") + + // NOTE: Technically optional + private static let rowQuantity = Expression("quantity") + + private static let rowOriginalQuantity = Expression("original_quantity") + + /// References `ROW_ID` on table `unit_strings` + private static let rowOriginalUnit = Expression("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 + } +} diff --git a/HealthImport/Model/Sample+Unit.swift b/HealthImport/Model/Sample+Unit.swift new file mode 100644 index 0000000..7ce331f --- /dev/null +++ b/HealthImport/Model/Sample+Unit.swift @@ -0,0 +1,18 @@ +import Foundation +import SQLite + +extension Sample { + + private static let table = Table("unit_strings") + + private static let rowId = Expression("ROW_ID") + + // - NOTE: Technically optional + private static let rowUnitString = Expression("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 + } +} diff --git a/HealthImport/Model/Sample.swift b/HealthImport/Model/Sample.swift new file mode 100644 index 0000000..e959bd8 --- /dev/null +++ b/HealthImport/Model/Sample.swift @@ -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("data_id") + + // NOTE: Technically optional + private static let columnStartDate = Expression("start_date") + + // NOTE: Technically optional + private static let columnEndDate = Expression("end_date") + + private static let columnDataType = Expression("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) + } +} diff --git a/HealthImport/Model/Workout+SQLite.swift b/HealthImport/Model/Workout+SQLite.swift new file mode 100644 index 0000000..2173af6 --- /dev/null +++ b/HealthImport/Model/Workout+SQLite.swift @@ -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("data_id") + + // REAL + private static let columnTotalDistance = Expression("total_distance") + + // INTEGER + private static let columnGoalType = Expression("goal_type") + + // REAL + private static let columnGoal = Expression("goal") + + // INTEGER + private static let columnCondenserVersion = Expression("condenser_version") + + // REAL + private static let columnCondenserDate = Expression("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) + } + } +} + diff --git a/HealthImport/Model/Workout.swift b/HealthImport/Model/Workout.swift index 8c8e497..62bca95 100644 --- a/HealthImport/Model/Workout.swift +++ b/HealthImport/Model/Workout.swift @@ -28,8 +28,8 @@ struct Workout { let activities: [WorkoutActivity] - let metadata: OrderedDictionary - + let metadata: OrderedDictionary + var firstActivityDate: Date? { activities.map { $0.startDate }.min() } @@ -53,7 +53,7 @@ struct Workout { activities.first?.activityType.description ?? "Unknown activity" } - init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [String : MetadataValue] = [:]) { + init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [Metadata.Key : Metadata.Value] = [:]) { self.id = id self.totalDistance = totalDistance self.goalType = goalType @@ -66,21 +66,6 @@ struct Workout { } } -extension Workout { - - init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity], metadata: [String : MetadataValue]) { - self.id = entry.dataId - self.totalDistance = entry.totalDistance - self.goalType = entry.goalType - self.goal = entry.goal - self.condenserVersion = entry.condenserVersion - self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) } - self.events = events.map(WorkoutEvent.init) - self.activities = activities.map(WorkoutActivity.init) - self.metadata = .init(uniqueKeys: metadata.keys, values: metadata.values) - } -} - extension Workout: Identifiable { } extension Workout: Equatable { diff --git a/HealthImport/Model/WorkoutActivity+SQLite.swift b/HealthImport/Model/WorkoutActivity+SQLite.swift new file mode 100644 index 0000000..8f3e3b8 --- /dev/null +++ b/HealthImport/Model/WorkoutActivity+SQLite.swift @@ -0,0 +1,93 @@ +import Foundation +import SQLite + +extension WorkoutActivity { + + private static let table = Table("workout_activities") + + private static let columnId = Expression("ROWID") + + private static let columnUUID = Expression("uuid") + + private static let columnOwnerId = Expression("owner_id") + + private static let columnIsPrimaryActivity = Expression("is_primary_activity") + + private static let columnActivityType = Expression("activity_type") + + private static let columnLocationType = Expression("location_type") + + private static let columnSwimmingLocationType = Expression("swimming_location_type") + + private static let columnLapLength = Expression("lap_length") + + private static let columnStartDate = Expression("start_date") + + private static let columnEndDate = Expression("end_date") + + private static let columnDuration = Expression("duration") + + private static let columnMetadata = Expression("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("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) + ) + } + +} diff --git a/HealthImport/Model/WorkoutActivity.swift b/HealthImport/Model/WorkoutActivity.swift index 62a49f4..5fcb731 100644 --- a/HealthImport/Model/WorkoutActivity.swift +++ b/HealthImport/Model/WorkoutActivity.swift @@ -2,7 +2,9 @@ import Foundation import HealthKit struct WorkoutActivity { - + + private let id: Int + let uuid: Data let isPrimaryActivity: Bool @@ -15,6 +17,7 @@ struct WorkoutActivity { let lapLength: Data? + #warning("Fix timezone for dates") let startDate: Date let endDate: Date @@ -22,28 +25,44 @@ struct WorkoutActivity { let duration: TimeInterval let metadata: Data? -} -extension WorkoutActivity { - - init(entry: DBWorkoutActivity) { - self.uuid = entry.uuid - self.isPrimaryActivity = entry.isPrimaryActivity - self.activityType = .init(rawValue: UInt(entry.activityType))! - self.locationType = .init(rawValue: entry.locationType)! - self.swimmingLocationType = .init(rawValue: entry.swimmingLocationType)! - self.lapLength = entry.lapLength - self.startDate = Date(timeIntervalSinceReferenceDate: entry.startDate) - self.endDate = Date(timeIntervalSinceReferenceDate: entry.endDate) - self.duration = entry.duration - self.metadata = entry.metadata + 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 + } + + func locationSamples(in database: Database) throws -> [LocationSample] { + try LocationSample.locationSamples(from: startDate, to: endDate, in: database) + } + + func locationSampleCount(in database: Database) throws -> Int { + try LocationSample.locationSampleCount(from: startDate, to: endDate, in: database) + } + + func sampleCount(in database: Database) throws -> Int { + try Sample.sampleCount(from: startDate, to: endDate, in: database) + } + + func samples(in database: Database) throws -> [SampleDataType : Sample] { + try Sample.samples(from: startDate, to: endDate, in: database).reduce(into: [:]) { + $0[$1.dataType] = $1 + } } } extension WorkoutActivity: Equatable { static func == (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool { - lhs.uuid == rhs.uuid + lhs.id == rhs.id } } @@ -57,6 +76,6 @@ extension WorkoutActivity: Comparable { extension WorkoutActivity: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(uuid) + hasher.combine(id) } } diff --git a/HealthImport/Model/WorkoutEvent+SQLite.swift b/HealthImport/Model/WorkoutEvent+SQLite.swift new file mode 100644 index 0000000..c51dcd6 --- /dev/null +++ b/HealthImport/Model/WorkoutEvent+SQLite.swift @@ -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("ROW_ID") + + // owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE + private static let columnOwnerId = Expression("owner_id") + + // date REAL NOT NULL + private static let columnDate = Expression("date") + + // type INTEGER NOT NULL + private static let columnType = Expression("type") + + // duration REAL NOT NULL + private static let columnDuration = Expression("duration") + + // metadata BLOB + private static let columnMetadata = Expression("metadata") + + // session_uuid BLOB + private static let columnSessionUUID = Expression("session_uuid") + + // error BLOB + private static let columnError = Expression("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("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) + ) + } +} diff --git a/HealthImport/Model/WorkoutEvent.swift b/HealthImport/Model/WorkoutEvent.swift index b451052..db6f499 100644 --- a/HealthImport/Model/WorkoutEvent.swift +++ b/HealthImport/Model/WorkoutEvent.swift @@ -17,18 +17,6 @@ struct WorkoutEvent { } -extension WorkoutEvent { - - init(entry: DBWorkoutEvent) { - self.date = Date(timeIntervalSinceReferenceDate: entry.date) - self.type = .init(rawValue: entry.type)! - self.duration = entry.duration - self.metadata = entry.metadata - self.sessionUUID = entry.sessionUUID - self.error = entry.error - } -} - extension WorkoutEvent: Equatable { static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool { diff --git a/HealthImport/Preview Content/HealthDatabase+Mock.swift b/HealthImport/Preview Content/HealthDatabase+Mock.swift new file mode 100644 index 0000000..1f25692 --- /dev/null +++ b/HealthImport/Preview Content/HealthDatabase+Mock.swift @@ -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 + } +} diff --git a/HealthImport/Preview Content/Location+Mock.swift b/HealthImport/Preview Content/Location+Mock.swift new file mode 100644 index 0000000..23412e5 --- /dev/null +++ b/HealthImport/Preview Content/Location+Mock.swift @@ -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()) +} diff --git a/HealthImport/Preview Content/Metadata+Mock.swift b/HealthImport/Preview Content/Metadata+Mock.swift new file mode 100644 index 0000000..014d6d8 --- /dev/null +++ b/HealthImport/Preview Content/Metadata+Mock.swift @@ -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")!) + ] +} diff --git a/HealthImport/Preview Content/Workout+Mock.swift b/HealthImport/Preview Content/Workout+Mock.swift new file mode 100644 index 0000000..d54833b --- /dev/null +++ b/HealthImport/Preview Content/Workout+Mock.swift @@ -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) + } +} diff --git a/HealthImport/Preview Content/WorkoutActivity+Mock.swift b/HealthImport/Preview Content/WorkoutActivity+Mock.swift new file mode 100644 index 0000000..db8f748 --- /dev/null +++ b/HealthImport/Preview Content/WorkoutActivity+Mock.swift @@ -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) +} diff --git a/HealthImport/Preview Content/WorkoutEvent+Mock.swift b/HealthImport/Preview Content/WorkoutEvent+Mock.swift new file mode 100644 index 0000000..b439070 --- /dev/null +++ b/HealthImport/Preview Content/WorkoutEvent+Mock.swift @@ -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" diff --git a/HealthImport/Support/Data+Extensions.swift b/HealthImport/Support/Data+Extensions.swift new file mode 100644 index 0000000..2bb060b --- /dev/null +++ b/HealthImport/Support/Data+Extensions.swift @@ -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(into value: T) -> T { + withUnsafeBytes { + $0.baseAddress!.load(as: T.self) + } + } + + init(from value: T) { + var target = value + self = Swift.withUnsafeBytes(of: &target) { + Data($0) + } + } +} diff --git a/HealthImport/Support/Date+Extensions.swift b/HealthImport/Support/Date+Extensions.swift index c3515ce..fcd6aad 100644 --- a/HealthImport/Support/Date+Extensions.swift +++ b/HealthImport/Support/Date+Extensions.swift @@ -22,6 +22,13 @@ private let timeFormatter: DateFormatter = { return df }() +private let timeAndDataWithSecondsFormatter: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .medium + return df +}() + extension Date { var durationSinceNowText: String { @@ -60,4 +67,8 @@ extension Date { var timeAndDateText: String { dateFormatter.string(from: self) } + + var timeAndDateWithSecondsText: String { + timeAndDataWithSecondsFormatter.string(from: self) + } } diff --git a/HealthImport/Support/HKWorkoutEventType+Extensions.swift b/HealthImport/Support/HKWorkoutEventType+Extensions.swift index 9fc8863..0e50084 100644 --- a/HealthImport/Support/HKWorkoutEventType+Extensions.swift +++ b/HealthImport/Support/HKWorkoutEventType+Extensions.swift @@ -10,7 +10,7 @@ extension HKWorkoutEventType: CustomStringConvertible { case .lap: return "Lap" case .marker: return "Marker" case .motionPaused: return "Motion Paused" - case .motionResumed: return "Motino Resumed" + case .motionResumed: return "Motion Resumed" case .segment: return "Segment" case .pauseOrResumeRequest: return "Pause or Resume Request" @unknown default: diff --git a/HealthImport/Support/Optional+Extensions.swift b/HealthImport/Support/Optional+Extensions.swift index 898b57e..35a24aa 100644 --- a/HealthImport/Support/Optional+Extensions.swift +++ b/HealthImport/Support/Optional+Extensions.swift @@ -2,10 +2,17 @@ import Foundation extension Optional { - func map(_ transform: (Wrapped) -> T) -> T? { + func map(_ transform: (Wrapped) throws -> T) rethrows -> T? { guard let self else { return nil } - return transform(self) + return try transform(self) + } + + func map(_ transform: (Wrapped) throws -> T?) rethrows -> T? { + guard let self else { + return nil + } + return try transform(self) } } diff --git a/HealthImport/Support/Sequence+Extensions.swift b/HealthImport/Support/Sequence+Extensions.swift new file mode 100644 index 0000000..d82cc96 --- /dev/null +++ b/HealthImport/Support/Sequence+Extensions.swift @@ -0,0 +1,13 @@ +import Foundation + +extension Sequence { + + func sorted(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) } + } + } + +} diff --git a/HealthImport/Support/TimeInterval+Extensions.swift b/HealthImport/Support/TimeInterval+Extensions.swift index 247677b..e8eeed3 100644 --- a/HealthImport/Support/TimeInterval+Extensions.swift +++ b/HealthImport/Support/TimeInterval+Extensions.swift @@ -12,6 +12,31 @@ extension Double { } return String(format: "%.1f m", self / 1000) } + + var lengthAsMeter: String { + guard self < 1000 else { + return String(format: "%.2f km", self / 1000) + } + guard self < 100 else { + return String(format: "%.0f m", self) + } + return String(format: "%.1f m", self) + } + + var meter: String { + guard self < 100 else { + return String(format: "%.0f m", self) + } + return String(format: "%.1f m", self) + } + + var speedAsMetersPerSecond: String { + String(format: "%.1f m/s", self) + } + + func asDegrees(decimals: Int = 1) -> String { + String(format: "%.\(decimals)f°", self) + } } extension TimeInterval { diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index c69718e..52265fe 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -18,15 +18,6 @@ struct WorkoutDetailView: View { DetailRow("Condenser Version", value: workout.condenserVersion) DetailRow("Condenser Date", date: workout.condenserDate) } - if !workout.events.isEmpty { - Section("Events") { - ForEach(workout.events, id: \.date) { event in - NavigationLink(value: event) { - DetailRow(event.type.description, date: event.date) - } - } - } - } if !workout.activities.isEmpty { Section("Activities") { ForEach(workout.activities, id: \.startDate) { activity in @@ -38,15 +29,27 @@ struct WorkoutDetailView: View { } } } + if !workout.events.isEmpty { + Section("Events") { + ForEach(workout.events, id: \.date) { event in + NavigationLink(value: event) { + DetailRow(event.type.description, date: event.date) + } + } + } + } if !workout.metadata.isEmpty { - ForEach(workout.metadata.elements, id:\.key) { (key, value) in - DetailRow(key, value: value) + Section("Metadata") { + ForEach(workout.metadata.elements, id:\.key) { (key, value) in + DetailRow(key.description, value: value) + } } } } .navigationTitle(workout.typeString) .navigationDestination(for: WorkoutActivity.self) { activity in ActivityDetailView(activity: activity) + .environmentObject(database) } .navigationDestination(for: WorkoutEvent.self) { event in EventDetailView(event: event) @@ -56,35 +59,8 @@ struct WorkoutDetailView: View { #Preview { NavigationStack { - WorkoutDetailView(workout: .init( - id: 123, - totalDistance: 234.5, - goalType: 3, - goal: 345.6, - condenserVersion: 1, - condenserDate: .now, - events: [ - .init( - date: .now, - type: .pause, - duration: 12.3, - metadata: .init(repeating: 42, count: 2), - sessionUUID: .init(repeating: 42, count: 3), - error: nil) - ], - activities: [ - .init( - uuid: .init(repeating: 42, count: 3), - isPrimaryActivity: true, - activityType: .running, - locationType: .outdoor, - swimmingLocationType: .unknown, - lapLength: .init(repeating: 42, count: 3), - startDate: .now.addingTimeInterval(-100), - endDate: .now, - duration: 100.0, - metadata: .init(repeating: 42, count: 3)) - ])) + WorkoutDetailView(workout: .mock1) + .environmentObject(HealthDatabase.mock()) } }