From 08825f84a140fbbbfc3f937ec78a831b336a9c44 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Fri, 15 Mar 2024 10:51:15 +0100 Subject: [PATCH] Use HealthDB framework, load databases --- HealthImport.xcodeproj/project.pbxproj | 102 +++++--- .../xcshareddata/swiftpm/Package.resolved | 24 +- HealthImport/ActivityDetailView.swift | 19 +- HealthImport/HealthImportApp.swift | 81 ++++--- HealthImport/Info.plist | 82 +++++++ HealthImport/Model/DatabaseFile.swift | 30 +++ HealthImport/Model/DatabaseList.swift | 170 +++++++++++++ HealthImport/Model/HealthDatabase.swift | 44 ++++ .../Preview Content/HealthDatabase+Mock.swift | 24 +- .../Preview Content/Workout+Mock.swift | 16 +- .../Support/FileManager+Directory.swift | 11 + .../Support/HKWorkout+Extensions.swift | 21 ++ HealthImport/Support/Workout+Extensions.swift | 31 ++- HealthImport/Tabs/DatabasesTab.swift | 164 +++++++++++++ .../WorkoutTab.swift} | 57 +++-- HealthImport/Test.swift | 227 ++++++++++++++++++ HealthImport/WorkoutDetailView.swift | 180 ++++++++++---- HealthImport/WorkoutEventsView.swift | 2 +- 18 files changed, 1111 insertions(+), 174 deletions(-) create mode 100644 HealthImport/Info.plist create mode 100644 HealthImport/Model/DatabaseFile.swift create mode 100644 HealthImport/Model/DatabaseList.swift create mode 100644 HealthImport/Model/HealthDatabase.swift create mode 100644 HealthImport/Support/FileManager+Directory.swift create mode 100644 HealthImport/Support/HKWorkout+Extensions.swift create mode 100644 HealthImport/Tabs/DatabasesTab.swift rename HealthImport/{ContentView.swift => Tabs/WorkoutTab.swift} (65%) create mode 100644 HealthImport/Test.swift diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 826aa4a..373c185 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */; }; - 8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850025C2B5C273C00E7D4DB /* ContentView.swift */; }; + 8850025D2B5C273C00E7D4DB /* WorkoutTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850025C2B5C273C00E7D4DB /* WorkoutTab.swift */; }; 8850025F2B5C273E00E7D4DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8850025E2B5C273E00E7D4DB /* Assets.xcassets */; }; 885002622B5C273E00E7D4DB /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 885002612B5C273E00E7D4DB /* Preview Assets.xcassets */; }; 8850026C2B5C278600E7D4DB /* healthdb_secure.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */; }; @@ -37,12 +37,18 @@ 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 */; }; - E2A38EA12B99FFDD00BAD02E /* HKDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = E2A38EA02B99FFDD00BAD02E /* HKDatabase */; }; E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */; }; E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */; }; E2A38EA82B9C6EE800BAD02E /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */; }; E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */; }; E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */; }; + E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552882BA2194400BF5E9B /* DatabasesTab.swift */; }; + E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */; }; + E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */; }; + E2E552902BA236A000BF5E9B /* DatabaseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5528F2BA236A000BF5E9B /* DatabaseList.swift */; }; + E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E552912BA236D000BF5E9B /* DatabaseFile.swift */; }; + E2E552992BA3748500BF5E9B /* HKDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = E2E552982BA3748500BF5E9B /* HKDatabase */; }; + E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */; }; E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; }; E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -50,7 +56,7 @@ /* Begin PBXFileReference section */ 885002572B5C273C00E7D4DB /* HealthImport.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthImport.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthImportApp.swift; sourceTree = ""; }; - 8850025C2B5C273C00E7D4DB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8850025C2B5C273C00E7D4DB /* WorkoutTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutTab.swift; sourceTree = ""; }; 8850025E2B5C273E00E7D4DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 885002612B5C273E00E7D4DB /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = ""; }; @@ -78,6 +84,13 @@ E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHealthStoreView.swift; sourceTree = ""; }; E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsView.swift; sourceTree = ""; }; E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMetadataView.swift; sourceTree = ""; }; + E2E552882BA2194400BF5E9B /* DatabasesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasesTab.swift; sourceTree = ""; }; + E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = ""; }; + E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Directory.swift"; sourceTree = ""; }; + E2E5528F2BA236A000BF5E9B /* DatabaseList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseList.swift; sourceTree = ""; }; + E2E552912BA236D000BF5E9B /* DatabaseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFile.swift; sourceTree = ""; }; + E2E552932BA23B8F00BF5E9B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkout+Extensions.swift"; sourceTree = ""; }; E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -89,7 +102,7 @@ files = ( 885002A62B5D296700E7D4DB /* Collections in Frameworks */, E20881D32B76912000D41D95 /* HealthKitExtensions in Frameworks */, - E2A38EA12B99FFDD00BAD02E /* HKDatabase in Frameworks */, + E2E552992BA3748500BF5E9B /* HKDatabase in Frameworks */, 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */, 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */, 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */, @@ -120,10 +133,12 @@ 885002592B5C273C00E7D4DB /* HealthImport */ = { isa = PBXGroup; children = ( + E2E552932BA23B8F00BF5E9B /* Info.plist */, + E2E5528A2BA21BFB00BF5E9B /* Model */, E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */, 8850026A2B5C276B00E7D4DB /* Resources */, 8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */, - 8850025C2B5C273C00E7D4DB /* ContentView.swift */, + E2E552872BA2193B00BF5E9B /* Tabs */, E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */, 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */, E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */, @@ -175,10 +190,31 @@ E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */, E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */, E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */, + E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */, + E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */, ); path = Support; sourceTree = ""; }; + E2E552872BA2193B00BF5E9B /* Tabs */ = { + isa = PBXGroup; + children = ( + E2E552882BA2194400BF5E9B /* DatabasesTab.swift */, + 8850025C2B5C273C00E7D4DB /* WorkoutTab.swift */, + ); + path = Tabs; + sourceTree = ""; + }; + E2E5528A2BA21BFB00BF5E9B /* Model */ = { + isa = PBXGroup; + children = ( + E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */, + E2E5528F2BA236A000BF5E9B /* DatabaseList.swift */, + E2E552912BA236D000BF5E9B /* DatabaseFile.swift */, + ); + path = Model; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -202,8 +238,8 @@ 885002A92B5D296700E7D4DB /* OrderedCollections */, E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */, E20881D22B76912000D41D95 /* HealthKitExtensions */, - E2A38EA02B99FFDD00BAD02E /* HKDatabase */, E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */, + E2E552982BA3748500BF5E9B /* HKDatabase */, ); productName = HealthImport; productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */; @@ -238,8 +274,8 @@ 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */, E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */, E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */, - E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */, E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, + E2E552972BA3748500BF5E9B /* XCRemoteSwiftPackageReference "HealthDB" */, ); productRefGroup = 885002582B5C273C00E7D4DB /* Products */; projectDirPath = ""; @@ -270,9 +306,10 @@ files = ( E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */, E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */, + E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */, E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */, E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */, - 8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */, + 8850025D2B5C273C00E7D4DB /* WorkoutTab.swift in Sources */, 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */, 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */, E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */, @@ -282,13 +319,18 @@ E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */, + E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */, E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */, E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */, E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */, + E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */, + E2E552902BA236A000BF5E9B /* DatabaseList.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, + E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */, + E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */, E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */, E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */, E20881D52B76944A00D41D95 /* Test.swift in Sources */, @@ -432,13 +474,16 @@ DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSHealthShareUsageDescription = "Manage all the health data your choose."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Manage all the health data your choose."; + INFOPLIST_FILE = HealthImport/Info.plist; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Manage all the health data you choose."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Manage all the health data you choose."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportsDocumentBrowser = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -464,13 +509,16 @@ DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSHealthShareUsageDescription = "Manage all the health data your choose."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Manage all the health data your choose."; + INFOPLIST_FILE = HealthImport/Info.plist; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Manage all the health data you choose."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Manage all the health data you choose."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportsDocumentBrowser = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -528,16 +576,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/christophhagen/HealthKitExtensions"; requirement = { - branch = main; - kind = branch; - }; - }; - E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/christophhagen/iOSHealthDBInterface"; - requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 0.3.3; }; }; E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { @@ -548,6 +588,14 @@ minimumVersion = 5.2.0; }; }; + E2E552972BA3748500BF5E9B /* XCRemoteSwiftPackageReference "HealthDB" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/christophhagen/HealthDB"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.2.0; + }; + }; E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-protobuf.git"; @@ -584,16 +632,16 @@ package = E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */; productName = HealthKitExtensions; }; - E2A38EA02B99FFDD00BAD02E /* HKDatabase */ = { - isa = XCSwiftPackageProductDependency; - package = E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */; - productName = HKDatabase; - }; E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; productName = SFSafeSymbols; }; + E2E552982BA3748500BF5E9B /* HKDatabase */ = { + isa = XCSwiftPackageProductDependency; + package = E2E552972BA3748500BF5E9B /* XCRemoteSwiftPackageReference "HealthDB" */; + productName = HKDatabase; + }; E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */ = { isa = XCSwiftPackageProductDependency; package = E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */; diff --git a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index badb438..de000f7 100644 --- a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,22 +1,22 @@ { - "originHash" : "b4e05748d8500bbff1c8ae286dbcad777cbcbcfd5780e4d633cf669d8ce257fb", + "originHash" : "5b8e27ff27b74293d3ae2085172fcc80a2317825fae6f3e7879caab9728af319", "pins" : [ + { + "identity" : "healthdb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/christophhagen/HealthDB", + "state" : { + "revision" : "b1f45d1abf47a13696fba9670db24fe6ca7fab53", + "version" : "0.2.1" + } + }, { "identity" : "healthkitextensions", "kind" : "remoteSourceControl", "location" : "https://github.com/christophhagen/HealthKitExtensions", "state" : { - "branch" : "main", - "revision" : "02ce75960a2b3fd1d2b7d2c620f519342956690c" - } - }, - { - "identity" : "ioshealthdbinterface", - "kind" : "remoteSourceControl", - "location" : "https://github.com/christophhagen/iOSHealthDBInterface", - "state" : { - "branch" : "main", - "revision" : "b5acf75f1d5a166cc7a92ebf040160e6471d8ff1" + "revision" : "18ee575892e6cc429c74c7bc3f156cc6791b220f", + "version" : "0.3.3" } }, { diff --git a/HealthImport/ActivityDetailView.swift b/HealthImport/ActivityDetailView.swift index 3a945ab..dc549b8 100644 --- a/HealthImport/ActivityDetailView.swift +++ b/HealthImport/ActivityDetailView.swift @@ -5,6 +5,11 @@ import CoreLocation struct ActivityDetailView: View { + @EnvironmentObject + var database: HealthDatabase + + let workout: Workout + let activity: HKWorkoutActivity @State var locations: [CLLocation] = [] @@ -61,9 +66,15 @@ struct ActivityDetailView: View { } private func load() { + guard let store = database.store else { + return + } Task { do { - let samples = try HealthDatabase.shared.locationSamples(for: activity) + guard let route = try store.route(associatedWith: workout) else { + return + } + let samples = try store.locations(associatedWith: route) .sorted { $0.timestamp } //let sampleCount = try HealthDatabase.shared.sampleCount(for: activity) DispatchQueue.main.async { @@ -78,8 +89,8 @@ struct ActivityDetailView: View { } #Preview { - HealthDatabase.shared = .mock() - return NavigationStack { - ActivityDetailView(activity: .mock1) + NavigationStack { + ActivityDetailView(workout: .mock1, activity: .mock1) + .environmentObject(HealthDatabase.mock) } } diff --git a/HealthImport/HealthImportApp.swift b/HealthImport/HealthImportApp.swift index 65bf4aa..ed779e8 100644 --- a/HealthImport/HealthImportApp.swift +++ b/HealthImport/HealthImportApp.swift @@ -1,41 +1,62 @@ import SwiftUI import HKDatabase +import SFSafeSymbols + +private enum TabSelection: Int { + case databases = 0 + case workouts = 1 + case samples = 2 +} @main struct HealthImportApp: App { + @State + var database = HealthDatabase() + + @State + private var selection: TabSelection = .databases + + @State + private var databaseList = DatabaseList() + + init() { + performStartup() + } + + private func performStartup() { + Task { + databaseList.load() + DispatchQueue.main.async { + // Go back to main queue so that list will be updated + guard let databaseToLoad = databaseList.databases.first(where: { $0.isDefault }) else { + print("No default database to load") + return + } + Task { + print("Loading database \(databaseToLoad.file)") + guard database.load(database: databaseToLoad) else { + return + } + DispatchQueue.main.async { + selection = .workouts + } + } + } + } + } + var body: some Scene { WindowGroup { - ContentView() + TabView(selection: $selection) { + WorkoutTab() + .environmentObject(database) + .tabItem { Label("Workouts", systemSymbol: .figureRun) } + .tag(TabSelection.workouts) + DatabasesTab(database: database, databases: databaseList) + .tabItem {Label("Databases", systemSymbol: .archivebox) } + .tag(TabSelection.databases) + } } } } - -extension HealthDatabase { - - static var shared: HealthDatabase = .init() -} - -private extension HealthDatabase { - - static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite") - - convenience init() { - let bundleUrl = HealthDatabase.databaseFileUrl! - let local = FileManager.default.documentDirectory.appendingPathComponent("db.sqlite") - if !FileManager.default.fileExists(atPath: local.path) { - try! FileManager.default.copyItem(at: bundleUrl, to: local) - } - try! self.init(fileUrl: local) - } -} - -extension FileManager { - - var documentDirectory: URL { - try! url( - for: .documentDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true) - } -} diff --git a/HealthImport/Info.plist b/HealthImport/Info.plist new file mode 100644 index 0000000..dfd648f --- /dev/null +++ b/HealthImport/Info.plist @@ -0,0 +1,82 @@ + + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Apple Health SQLite Database + CFBundleTypeRole + Editor + LSHandlerRank + Owner + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.database + public.data + + UTTypeDescription + Apple Health SQLite Database + UTTypeIconFiles + + UTTypeIdentifier + com.apple.sqlite3.database + UTTypeReferenceURL + https://christophhagen.de + UTTypeTagSpecification + + public.filename-extension + + sqlite + sqlite3 + + public.mime-type + + application/x-sqlite3 + application/vnd.sqlite3 + application/octet-stream + + + + + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.database + public.data + + UTTypeDescription + Apple Health SQLite Database + UTTypeIconFiles + + UTTypeIdentifier + com.apple.sqlite3.database + UTTypeReferenceURL + https://christophhagen.de + UTTypeTagSpecification + + public.filename-extension + + sqlite + sqlite3 + SQLITE + + public.mime-type + + application/x-sqlite3 + application/vnd.sqlite3 + application/octet-stream + + + + + + diff --git a/HealthImport/Model/DatabaseFile.swift b/HealthImport/Model/DatabaseFile.swift new file mode 100644 index 0000000..e651a2f --- /dev/null +++ b/HealthImport/Model/DatabaseFile.swift @@ -0,0 +1,30 @@ +import Foundation + +struct DatabaseFile { + + let file: String + + var name: String + + var isDefault: Bool + + var url: URL { + FileManager.default.documentDirectory.appendingPathComponent(file) + } +} + +extension DatabaseFile: Codable { + +} + +extension DatabaseFile: Identifiable { + + var id: String { name } +} + +extension DatabaseFile: Equatable { + + static func ==(lhs: DatabaseFile, rhs: DatabaseFile) -> Bool { + lhs.file == rhs.file + } +} diff --git a/HealthImport/Model/DatabaseList.swift b/HealthImport/Model/DatabaseList.swift new file mode 100644 index 0000000..5792d02 --- /dev/null +++ b/HealthImport/Model/DatabaseList.swift @@ -0,0 +1,170 @@ +import Foundation +import SwiftUI + +final class DatabaseList: ObservableObject { + + @AppStorage("databaseList") + var databaseListData: Data? + + @Published + var databases: [DatabaseFile] = [] + + private var isLoaded = false + + init() { } + + func load() { + guard !isLoaded else { + return + } + isLoaded = true + guard let databaseListData else { + print("No database list") + return + } + let loaded: [DatabaseFile] + do { + loaded = try JSONDecoder() + .decode([DatabaseFile].self, from: databaseListData) + print("Found \(loaded.count) databases") + } catch { + print("Failed to load databases: \(error)") + loaded = [] + } + let missing = loadDatabases(missingFrom: loaded) + DispatchQueue.main.async { + self.databases = loaded + missing + } + if !missing.isEmpty { + saveDatabaseList() + } + } + + private func loadDatabases(missingFrom list: [DatabaseFile]) -> [DatabaseFile] { + let files: [URL] + do { + files = try FileManager.default.contentsOfDirectory(at: FileManager.default.documentDirectory, includingPropertiesForKeys: nil) + } catch { + print("Failed to read document directory: \(error)") + return [] + } + let missingDatabases = files.filter { file in + guard file.pathExtension == "sqlite" else { + return false + } + let fileName = file.lastPathComponent + return !list.contains { $0.file == fileName } + }.map { + DatabaseFile(file: $0.lastPathComponent, name: $0.lastPathComponent, isDefault: false) + } + guard !missingDatabases.isEmpty else { + return [] + } + print("Found \(missingDatabases.count) missing databases") + return missingDatabases + } + + @discardableResult + private func saveDatabaseList() -> Bool { + do { + print("Saving \(databases.count) databases") + databaseListData = try JSONEncoder().encode(databases) + return true + } catch { + print("Failed to save databases: \(error)") + return false + } + } + + func importDatabase(at url: URL) { + print("Importing database at \(url.path)") + let localUrl = findNextLocalUrl(for: url) + do { + print("Copying to \(localUrl.path)") + try FileManager.default.copyItem(at: url, to: localUrl) + } catch { + print("Failed to copy imported file: \(error)") + return + } + let database = DatabaseFile( + file: localUrl.lastPathComponent, + name: localUrl.lastPathComponent, + isDefault: false) + databases.append(database) + print("Successfully imported database") + saveDatabaseList() + } + + func findNextLocalUrl(for url: URL) -> URL { + let fileName = url.deletingPathExtension().lastPathComponent + let fileExtension = url.pathExtension + let documentDirectory = FileManager.default.documentDirectory + let normalUrl = documentDirectory.appendingPathComponent("\(fileName).\(fileExtension)") + guard FileManager.default.fileExists(atPath: normalUrl.path) else { + return normalUrl + } + var index = 0 + var url: URL + repeat { + index += 1 + url = documentDirectory.appendingPathComponent("\(fileName)-\(index).\(fileExtension)") + } while FileManager.default.fileExists(atPath: url.path) + return url + } + + func setAsDefault(database: DatabaseFile) { + guard let newDefault = databases.firstIndex(of: database) else { + return + } + if let oldDefault = databases.firstIndex(where: { $0.isDefault }) { + databases[oldDefault].isDefault = false + } + databases[newDefault].isDefault = true + saveDatabaseList() + } + + func update(name: String, for database: DatabaseFile) -> DatabaseFile? { + guard let index = databases.firstIndex(of: database) else { + return nil + } + databases[index].name = name + saveDatabaseList() + return databases[index] + } + + func add(database: DatabaseFile) { + defer { saveDatabaseList() } + guard let index = databases.firstIndex(of: database) else { + databases.append(database) + return + } + databases[index] = database + } + + func deleteDatabases(at indexSet: IndexSet) { + var deleted = IndexSet() + for index in indexSet { + if deleteDatabase(at: index) { + deleted.insert(index) + } + } + guard !deleted.isEmpty else { return } + databases.remove(atOffsets: deleted) + saveDatabaseList() + } + + private func deleteDatabase(at index: Int) -> Bool { + let database = databases[index] + let url = database.url + guard FileManager.default.fileExists(atPath: url.path) else { + return true + } + do { + try FileManager.default.removeItem(at: url) + return true + } catch { + print("Failed to delete database \(database.name): \(error)") + return false + } + } +} diff --git a/HealthImport/Model/HealthDatabase.swift b/HealthImport/Model/HealthDatabase.swift new file mode 100644 index 0000000..ccc4509 --- /dev/null +++ b/HealthImport/Model/HealthDatabase.swift @@ -0,0 +1,44 @@ +import Foundation +import HKDatabase + +final class HealthDatabase: ObservableObject { + + @Published + var store: HKDatabaseStoreWrapper? = nil + + @Published + var file: DatabaseFile? = nil + + init(store: HKDatabaseStoreWrapper? = nil) { + self.store = store + } + + @discardableResult + func load(database: DatabaseFile) -> Bool { + guard database != file else { + print("Same database not loaded again") + return true + } + close() + do { + let store = try HKDatabaseStoreWrapper(fileUrl: database.url) + DispatchQueue.main.async { + self.store = store + self.file = database + } + print("Opened database \(database.file)") + return true + } catch { + print("Failed to load database: \(error)") + return false + } + } + + func close() { + DispatchQueue.main.async { + //store.close() + self.store = nil + self.file = nil + } + } +} diff --git a/HealthImport/Preview Content/HealthDatabase+Mock.swift b/HealthImport/Preview Content/HealthDatabase+Mock.swift index e8da73b..0978c65 100644 --- a/HealthImport/Preview Content/HealthDatabase+Mock.swift +++ b/HealthImport/Preview Content/HealthDatabase+Mock.swift @@ -5,17 +5,29 @@ import HKDatabase extension HealthDatabase { - static func mock() -> HealthDatabase { + private static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite") + + static var mock: HealthDatabase { + let bundleUrl = HealthDatabase.databaseFileUrl! + let local = FileManager.default.documentDirectory.appendingPathComponent("db.sqlite") + if !FileManager.default.fileExists(atPath: local.path) { + try! FileManager.default.copyItem(at: bundleUrl, to: local) + } + let store = try! HKDatabaseStoreWrapper(fileUrl: local) + return .init(store: store) + } + + static var empty: HealthDatabase { do { let connection = try Connection(.inMemory) - let database = HealthDatabase(database: connection) - try database.createTables() - try database.insert(workout: .mock1) - return database + let store = HKDatabaseStore(database: connection) + try store.createTables() + try store.insert(workout: .mock1) + return .init(store: .init(wrapping: store)) } catch { print(error) - fatalError("Failed to create mock database: \(error)") + fatalError("Failed to create empty database: \(error)") } } } diff --git a/HealthImport/Preview Content/Workout+Mock.swift b/HealthImport/Preview Content/Workout+Mock.swift index e4d84af..f630fbc 100644 --- a/HealthImport/Preview Content/Workout+Mock.swift +++ b/HealthImport/Preview Content/Workout+Mock.swift @@ -6,14 +6,22 @@ import HealthKitExtensions extension Workout { static var mock1: Workout { - .init(id: 8196339, + let activity = HKWorkoutActivity.mock1 + return .init(dataId: 8196339, + startDate: activity.startDate, + endDate: activity.endDate!, totalDistance: 16.7620435816585, goalType: 2, goal: 19800.0, - condenserVersion: 3, - condenserDate: Date(timeIntervalSinceReferenceDate: 716801471.790011), events: HKWorkoutEvent.mock1, - activities: [.mock1], + activities: [activity], metadata: Metadata.mock1) } } + +extension Workout { + + var duration: TimeInterval { + endDate.timeIntervalSince(startDate) + } +} diff --git a/HealthImport/Support/FileManager+Directory.swift b/HealthImport/Support/FileManager+Directory.swift new file mode 100644 index 0000000..646db77 --- /dev/null +++ b/HealthImport/Support/FileManager+Directory.swift @@ -0,0 +1,11 @@ +import Foundation + +extension FileManager { + + var documentDirectory: URL { + try! url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true) + } +} diff --git a/HealthImport/Support/HKWorkout+Extensions.swift b/HealthImport/Support/HKWorkout+Extensions.swift new file mode 100644 index 0000000..93026d3 --- /dev/null +++ b/HealthImport/Support/HKWorkout+Extensions.swift @@ -0,0 +1,21 @@ +import Foundation +import HealthKit + +extension HKWorkout { + + var distance: HKQuantity? { + statistics(for: .init(distanceType))?.maximumQuantity() + } + + private var distanceType: HKQuantityTypeIdentifier { + switch workoutActivityType { + case .running, .walking, .hiking: return .distanceWalkingRunning + case .snowboarding, .snowSports, .downhillSkiing, .crossCountrySkiing: return .distanceDownhillSnowSports + case .cycling, .handCycling: return .distanceCycling + case .swimming, .paddleSports, .underwaterDiving: return .distanceSwimming + case .wheelchairRunPace, .wheelchairWalkPace: return .distanceWheelchair + default: + return .distanceWalkingRunning + } + } +} diff --git a/HealthImport/Support/Workout+Extensions.swift b/HealthImport/Support/Workout+Extensions.swift index c1953fc..96e24db 100644 --- a/HealthImport/Support/Workout+Extensions.swift +++ b/HealthImport/Support/Workout+Extensions.swift @@ -1,5 +1,6 @@ import Foundation import HKDatabase +import HealthKit private let df: DateFormatter = { let df = DateFormatter() @@ -9,28 +10,24 @@ private let df: DateFormatter = { return df }() +extension Workout: Identifiable { + + public var id: Int { + dataId + } +} + extension Workout { + var workoutActivityType: HKWorkoutActivityType { + workoutActivities.first!.workoutConfiguration.activityType + } + var typeString: String { - activities.first?.workoutConfiguration.activityType.description ?? "Unknown activity" + workoutActivityType.description } var dateString: String { - guard let firstAvailableDate else { - return "No date" - } - return df.string(from: firstAvailableDate) - } - - var firstActivityDate: Date? { - activities.map { $0.startDate }.min() - } - - var firstEventDate: Date? { - events.map { $0.dateInterval.start }.min() - } - - var firstAvailableDate: Date? { - [firstEventDate, firstActivityDate].compactMap { $0 }.min() + df.string(from: startDate) } } diff --git a/HealthImport/Tabs/DatabasesTab.swift b/HealthImport/Tabs/DatabasesTab.swift new file mode 100644 index 0000000..5e57e36 --- /dev/null +++ b/HealthImport/Tabs/DatabasesTab.swift @@ -0,0 +1,164 @@ +import SwiftUI +import HKDatabase +import SFSafeSymbols + +struct DatabasesTab: View { + + @ObservedObject + var database: HealthDatabase + + @ObservedObject + var databases: DatabaseList + + @State var navigationPath: NavigationPath = .init() + + @State + private var showFailedToOpenAlert = false + + @State + private var showDocumentPicker = false + + @State + private var showOptionsForSelectedDatabase = false + + @State + private var changedDatabaseName: String = "" + + @State + private var showChangeDatabaseNameAlert = false + + @State + private var selectedDatabase: DatabaseFile? = nil + + private var openCloseButtonText: String { + database.file == selectedDatabase ? "Close" : "Open" + } + + private var defaultSelectionButtonText: String { + (selectedDatabase?.isDefault ?? false) ? "Remove as default" : "Set as default" + } + + private func symbol(for database: DatabaseFile) -> SFSymbol { + self.database.file == database ? .circleInsetFilled : .circle + } + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + ForEach(databases.databases) { database in + HStack(alignment: .center) { + Image(systemSymbol: symbol(for: database)) + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Text(database.name) + .font(.headline) + if database.isDefault { + Text("(Default)") + .font(.caption) + } + } + Text(database.file) + .font(.caption) + } + } + .onTapGesture { + self.selectedDatabase = database + self.showOptionsForSelectedDatabase = true + } + }.onDelete(perform: databases.deleteDatabases) + } + .navigationTitle("Databases") + .toolbar { + Button(action: displayDocumentPicker) { + Label("Add", systemSymbol: .plus) + } + } + .fileImporter(isPresented: $showDocumentPicker, + allowedContentTypes: [.database], + onCompletion: handle) + .confirmationDialog("Change background", isPresented: $showOptionsForSelectedDatabase) { + Button(openCloseButtonText, action: openOrCloseSelectedDatabase) + Button("Rename", action: renameSelectedDatabase) + Button(defaultSelectionButtonText, action: toggleDefaultForSelectedDatabase) + Button("Cancel", role: .cancel) { } + } message: { + Text("Select a new color") + } + .alert("Update name", isPresented: $showChangeDatabaseNameAlert, actions: { + TextField("Name", text: $changedDatabaseName) + Button("Update", action: saveNewNameForSelectedDatabase) + Button("Cancel", role: .cancel, action: {}) + }, message: { + Text("Please enter the new name for the database") + }) + .alert("Failed to open", isPresented: $showFailedToOpenAlert) { + Button("Dismiss", role: .cancel, action: {}) + } message: { + Text("The selected database could not be opened. Make sure that it is a valid Health database.") + } + + }.onAppear(perform: databases.load) + } + + private func openOrCloseSelectedDatabase() { + guard let selectedDatabase else { return } + defer { self.selectedDatabase = nil } + + guard database.load(database: selectedDatabase) else { + return + } + } + + private func renameSelectedDatabase() { + guard let selectedDatabase else { return } + self.changedDatabaseName = selectedDatabase.name + self.showChangeDatabaseNameAlert = true + } + + private func saveNewNameForSelectedDatabase() { + defer { changedDatabaseName = "" } + guard let selectedDatabase else { return } + guard let updated = databases.update(name: changedDatabaseName, for: selectedDatabase) else { + return + } + if database.file == selectedDatabase { + // Update open database file + database.file = updated + } + } + + private func toggleDefaultForSelectedDatabase() { + guard let selectedDatabase else { return } + defer { self.selectedDatabase = nil } + databases.setAsDefault(database: selectedDatabase) + } + + private func displayDocumentPicker() { + showDocumentPicker = true + } + + private func handle(result: Result) { + do { + let selectedFile: URL = try result.get() + if selectedFile.startAccessingSecurityScopedResource() { + defer { selectedFile.stopAccessingSecurityScopedResource() } + databases.importDatabase(at: selectedFile) + } else { + print("No access to file") + } + } catch { + print("No file selected: \(error)") + } + } + + private func handlePicked(urls: [URL]) { + print("Files picked.") + for url in urls { + databases.importDatabase(at: url) + } + } +} + +#Preview { + DatabasesTab(database: HealthDatabase(), databases: .init()) +} diff --git a/HealthImport/ContentView.swift b/HealthImport/Tabs/WorkoutTab.swift similarity index 65% rename from HealthImport/ContentView.swift rename to HealthImport/Tabs/WorkoutTab.swift index a315008..e1dc39c 100644 --- a/HealthImport/ContentView.swift +++ b/HealthImport/Tabs/WorkoutTab.swift @@ -3,8 +3,11 @@ import HealthKit import HKDatabase import SFSafeSymbols -struct ContentView: View { - +struct WorkoutTab: View { + + @EnvironmentObject + var database: HealthDatabase + @State var navigationPath: NavigationPath = .init() @State var workouts: [Workout] = [] @@ -17,6 +20,7 @@ struct ContentView: View { NavigationLink(value: workout) { VStack(alignment: .leading) { Text(workout.typeString) + .font(.headline) Text(workout.dateString) .font(.caption) .foregroundStyle(.secondary) @@ -29,6 +33,9 @@ struct ContentView: View { .navigationDestination(for: Workout.self) { WorkoutDetailView(workout: $0) } + .refreshable { + reloadAsync() + } .toolbar { ToolbarItem { NavigationLink { @@ -37,37 +44,43 @@ struct ContentView: View { Image(systemSymbol: .magnifyingglass) } } - } - .onAppear(perform: getPermissions) + }.onChange(of: database.file, perform: { value in + reload() + }) } } - private func getPermissions() { + private func reload() { Task { - let store = HKHealthStore() - do { - let success = try await requestAllPermissions(in: store) - print("Has permissions: \(success)") - } catch { - print("Error getting permissions: \(error)") - return + reloadAsync() + } + } + + private func reloadAsync() { + guard let store = database.store else { + DispatchQueue.main.async { + self.workouts = [] } - do { - let workouts = try HealthDatabase.shared.readAllWorkouts() - DispatchQueue.main.async { - self.workouts = workouts.reversed() - } - } catch { - print("Error getting workouts: \(error)") - return + return + } + do { + let workouts = try store.workouts() + DispatchQueue.main.async { + self.workouts = workouts + print("Loaded \(workouts.count) workouts") + } + } catch { + print("Failed to load workouts: \(error)") + DispatchQueue.main.async { + self.workouts = [] } } } } #Preview { - HealthDatabase.shared = .mock() - return ContentView() + WorkoutTab() + .environmentObject(HealthDatabase.mock) } /* diff --git a/HealthImport/Test.swift b/HealthImport/Test.swift new file mode 100644 index 0000000..585d435 --- /dev/null +++ b/HealthImport/Test.swift @@ -0,0 +1,227 @@ +import Foundation +import HealthKit +import HealthKitExtensions + +func insertExamplesOfAllTypes() async throws { + + let store = HKHealthStore() + + guard try await requestAllPermissions(in: store) else { + return + } + + var startDate = Date(timeIntervalSinceReferenceDate: 700_000_000) + + try await insertCategoryTypes(in: store, startDate: &startDate) + try await insertQuantityTypes(in: store, startDate: &startDate) +} + +func requestAllPermissions(in store: HKHealthStore) async throws -> Bool { + + let writable: [HKSampleContainer.Type] = HKQuantityType.writableTypes + HKCorrelationType.writableTypes + HKCategoryType.writableTypes + let readable: [HKObjectContainer.Type] = HKQuantityType.readableTypes + HKCorrelationType.readableTypes + HKCategoryType.readableTypes + + try await store.requestAuthorization(toShare: writable, read: readable) + + var hasAllPermissions = true + writable.forEach { + if store.authorizationStatus(for: $0.objectType) != .sharingAuthorized { + print("Missing permission for \($0.objectType)") + hasAllPermissions = false + } + } + return hasAllPermissions +} + +private func insertCategoryTypes(in store: HKHealthStore, startDate: inout Date) async throws { + + func make(convert: (Date, Date) -> T) -> T where T: HKObjectContainer { + let result = convert(startDate, startDate.addingTimeInterval(1)) + print("\(startDate.timeIntervalSinceReferenceDate): \(T.objectType)") + startDate.addTimeInterval(1) + return result + } + let categorySamples: [HKCategorySampleContainer] = [ + make { MindfulSession(start: $0, end: $1) }, + make { HandwashingEvent(start: $0, end: $1) }, + make { ToothbrushingEvent(start: $0, end: $1) }, + make { CervicalMucusQuality(value: .creamy, start: $0, end: $1) }, + make { Contraceptive(value: .implant, start: $0, end: $1) }, + make { IntermenstrualBleeding(start: $0, end: $1) }, + make { Lactation(start: $0, end: $1) }, + make { MenstrualFlow(value: .heavy, cycleStart: true, start: $0, end: $1) }, + make { OvulationTestResult(value: .negative, start: $0, end: $1) }, + make { Pregnancy(start: $0, end: $1) }, + make { PregnancyTestResult(value: .positive, start: $0, end: $1) }, + make { SexualActivity(protectionUsed: true, start: $0, end: $1) }, + make { SleepAnalysis(value: .asleepREM, start: $0, end: $1) }, + make { AbdominalCramps(value: .moderate, start: $0, end: $1) }, + make { Acne(value: .moderate, start: $0, end: $1) }, + make { AppetiteChanges(value: .decreased, start: $0, end: $1) }, + make { BladderIncontinence(value: .moderate, start: $0, end: $1) }, + make { Bloating(value: .moderate, start: $0, end: $1) }, + make { BreastPain(value: .moderate, start: $0, end: $1) }, + make { ChestTightnessOrPain(value: .moderate, start: $0, end: $1) }, + make { Chills(value: .moderate, start: $0, end: $1) }, + make { Constipation(value: .moderate, start: $0, end: $1) }, + make { Coughing(value: .moderate, start: $0, end: $1) }, + make { Diarrhea(value: .moderate, start: $0, end: $1) }, + make { Dizziness(value: .moderate, start: $0, end: $1) }, + make { DrySkin(value: .moderate, start: $0, end: $1) }, + make { Fainting(value: .moderate, start: $0, end: $1) }, + make { Fatigue(value: .moderate, start: $0, end: $1) }, + make { Fever(value: .moderate, start: $0, end: $1) }, + make { GeneralizedBodyAche(value: .moderate, start: $0, end: $1) }, + make { HairLoss(value: .moderate, start: $0, end: $1) }, + make { Headache(value: .moderate, start: $0, end: $1) }, + make { Heartburn(value: .moderate, start: $0, end: $1) }, + make { HotFlashes(value: .moderate, start: $0, end: $1) }, + make { LossOfSmell(value: .moderate, start: $0, end: $1) }, + make { LossOfTaste(value: .moderate, start: $0, end: $1) }, + make { LowerBackPain(value: .moderate, start: $0, end: $1) }, + make { MemoryLapse(value: .moderate, start: $0, end: $1) }, + make { MoodChanges(value: .present, start: $0, end: $1) }, + make { Nausea(value: .moderate, start: $0, end: $1) }, + make { NightSweats(value: .moderate, start: $0, end: $1) }, + make { PelvicPain(value: .moderate, start: $0, end: $1) }, + make { RapidPoundingOrFlutteringHeartbeat(value: .moderate, start: $0, end: $1) }, + make { RunnyNose(value: .moderate, start: $0, end: $1) }, + make { ShortnessOfBreath(value: .moderate, start: $0, end: $1) }, + make { SinusCongestion(value: .moderate, start: $0, end: $1) }, + make { SkippedHeartbeat(value: .moderate, start: $0, end: $1) }, + make { SleepChanges(value: .present, start: $0, end: $1) }, + make { SoreThroat(value: .moderate, start: $0, end: $1) }, + make { VaginalDryness(value: .moderate, start: $0, end: $1) }, + make { Vomiting(value: .moderate, start: $0, end: $1) }, + make { Wheezing(value: .moderate, start: $0, end: $1) }, + ] + + print("Saving...") + try await store.save(categorySamples) + print("Done.") +} + +private func insertQuantityTypes(in store: HKHealthStore, startDate: inout Date) async throws { + + func make(convert: (Date, Date) -> T) -> T where T: HKObjectContainer { + let result = convert(startDate, startDate.addingTimeInterval(1)) + print("\(startDate.timeIntervalSinceReferenceDate): \(T.objectType)") + startDate.addTimeInterval(1) + return result + } + + let samples: [HKQuantitySampleContainer] = [ + make { BodyFatPercentage(value: 10.0, start: $0, end: $1) }, + make { BodyMass(value: 80.0, start: $0, end: $1) }, + make { BodyMassIndex(value: 25.0, start: $0, end: $1) }, + make { ElectrodermalActivity(value: 6.0, start: $0, end: $1) }, + make { Height(value: 1.80, start: $0, end: $1) }, + make { LeanBodyMass(value: 65.0, start: $0, end: $1) }, + make { WaistCircumference(value: 1.0, start: $0, end: $1) }, + make { ActiveEnergyBurned(value: 1.0, start: $0, end: $1) }, + make { BasalEnergyBurned(value: 1.0, start: $0, end: $1) }, + //make { CyclingCadence(value: 1.0, start: $0, end: $1) }, + //make { CyclingFunctionalThresholdPower(value: 1.0, start: $0, end: $1) }, + //make { CyclingPower(value: 1.0, start: $0, end: $1) }, + //make { CyclingSpeed(value: 1.0, start: $0, end: $1) }, + make { DistanceCycling(value: 1.0, start: $0, end: $1) }, + make { DistanceDownhillSnowSports(value: 1.0, start: $0, end: $1) }, + make { DistanceSwimming(value: 1.0, start: $0, end: $1) }, + make { DistanceWalkingRunning(value: 1.0, start: $0, end: $1) }, + make { DistanceWheelchair(value: 1.0, start: $0, end: $1) }, + make { FlightsClimbed(value: 1.0, start: $0, end: $1) }, + //make { PhysicalEffort(value: 1.0, start: $0, end: $1) }, + make { PushCount(value: 1.0, start: $0, end: $1) }, + make { RunningPower(value: 1.0, start: $0, end: $1) }, + make { RunningSpeed(value: 1.0, start: $0, end: $1) }, + make { StepCount(value: 1.0, start: $0, end: $1) }, + make { SwimmingStrokeCount(value: 1.0, start: $0, end: $1) }, + make { UnderwaterDepth(value: 1.0, start: $0, end: $1) }, + make { EnvironmentalAudioExposure(value: 1.0, start: $0, end: $1) }, + make { EnvironmentalSoundReduction(value: 1.0, start: $0, end: $1) }, + make { HeadphoneAudioExposure(value: 1.0, start: $0, end: $1) }, + make { HeartRate(countsPerSecond: 1.0, motionContext: .sedentary, start: $0, end: $1) }, + make { HeartRateRecoveryOneMinute(value: 1.0, start: $0, end: $1) }, + make { HeartRateVariabilitySDNN(value: 1.0, start: $0, end: $1) }, + make { PeripheralPerfusionIndex(value: 1.0, start: $0, end: $1) }, + make { RestingHeartRate(value: 1.0, start: $0, end: $1) }, + make { Vo2Max(value: 1.0, testType: .maxExercise, start: $0, end: $1) }, + make { RunningGroundContactTime(value: 1.0, start: $0, end: $1) }, + make { RunningStrideLength(value: 1.0, start: $0, end: $1) }, + make { RunningVerticalOscillation(value: 1.0, start: $0, end: $1) }, + make { SixMinuteWalkTestDistance(value: 1.0, start: $0, end: $1) }, + make { StairAscentSpeed(value: 1.0, start: $0, end: $1) }, + make { StairDescentSpeed(value: 1.0, start: $0, end: $1) }, + make { WalkingDoubleSupportPercentage(value: 1.0, start: $0, end: $1) }, + make { WalkingSpeed(value: 1.0, start: $0, end: $1) }, + make { WalkingStepLength(value: 1.0, start: $0, end: $1) }, + make { DietaryBiotin(value: 1.0, start: $0, end: $1) }, + make { DietaryCaffeine(value: 1.0, start: $0, end: $1) }, + make { DietaryCalcium(value: 1.0, start: $0, end: $1) }, + make { DietaryCarbohydrates(value: 1.0, start: $0, end: $1) }, + make { DietaryChloride(value: 1.0, start: $0, end: $1) }, + make { DietaryCholesterol(value: 1.0, start: $0, end: $1) }, + make { DietaryChromium(value: 1.0, start: $0, end: $1) }, + make { DietaryCopper(value: 1.0, start: $0, end: $1) }, + make { DietaryEnergyConsumed(value: 1.0, start: $0, end: $1) }, + make { DietaryFatMonounsaturated(value: 1.0, start: $0, end: $1) }, + make { DietaryFatPolyunsaturated(value: 1.0, start: $0, end: $1) }, + make { DietaryFatSaturated(value: 1.0, start: $0, end: $1) }, + make { DietaryFatTotal(value: 1.0, start: $0, end: $1) }, + make { DietaryFiber(value: 1.0, start: $0, end: $1) }, + make { DietaryFolate(value: 1.0, start: $0, end: $1) }, + make { DietaryIodine(value: 1.0, start: $0, end: $1) }, + make { DietaryIron(value: 1.0, start: $0, end: $1) }, + make { DietaryMagnesium(value: 1.0, start: $0, end: $1) }, + make { DietaryManganese(value: 1.0, start: $0, end: $1) }, + make { DietaryMolybdenum(value: 1.0, start: $0, end: $1) }, + make { DietaryNiacin(value: 1.0, start: $0, end: $1) }, + make { DietaryPantothenicAcid(value: 1.0, start: $0, end: $1) }, + make { DietaryPhosphorus(value: 1.0, start: $0, end: $1) }, + make { DietaryPotassium(value: 1.0, start: $0, end: $1) }, + make { DietaryProtein(value: 1.0, start: $0, end: $1) }, + make { DietaryRiboflavin(value: 1.0, start: $0, end: $1) }, + make { DietarySelenium(value: 1.0, start: $0, end: $1) }, + make { DietarySodium(value: 1.0, start: $0, end: $1) }, + make { DietarySugar(value: 1.0, start: $0, end: $1) }, + make { DietaryThiamin(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminA(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminB6(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminB12(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminC(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminD(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminE(value: 1.0, start: $0, end: $1) }, + make { DietaryVitaminK(value: 1.0, start: $0, end: $1) }, + make { DietaryWater(value: 1.0, start: $0, end: $1) }, + make { DietaryZinc(value: 1.0, start: $0, end: $1) }, + make { BloodPressureDiastolic(value: 1.0, start: $0, end: $1) }, + make { BloodPressureSystolic(value: 1.0, start: $0, end: $1) }, + make { InsulinDelivery(amount: 1.0, reason: .basal, start: $0, end: $1) }, + make { NumberOfAlcoholicBeverages(value: 1.0, start: $0, end: $1) }, + make { NumberOfTimesFallen(value: 1.0, start: $0, end: $1) }, + //make { TimeInDaylight(value: 1.0, start: $0, end: $1) }, + make { UvExposure(value: 1.0, start: $0, end: $1) }, + make { WaterTemperature(value: 1.0, start: $0, end: $1) }, + make { BasalBodyTemperature(value: 1.0, start: $0, end: $1) }, + make { ForcedExpiratoryVolume1(value: 1.0, start: $0, end: $1) }, + make { ForcedVitalCapacity(value: 1.0, start: $0, end: $1) }, + make { InhalerUsage(value: 1.0, start: $0, end: $1) }, + make { OxygenSaturation(value: 1.0, start: $0, end: $1) }, + make { PeakExpiratoryFlowRate(value: 1.0, start: $0, end: $1) }, + make { RespiratoryRate(value: 1.0, start: $0, end: $1) }, + make { BloodGlucose(value: 1.0, start: $0, end: $1) }, + make { BodyTemperature(value: 1.0, start: $0, end: $1) }, + ] + + let allowed = Set(HKQuantityType.writableTypes.map { $0.quantitySampleType }) + + samples.forEach { + if !allowed.contains($0.quantitySampleType) { + print("Can't write: \($0.quantitySampleType)") + } + } + + print("Saving...") + try await store.save(samples) + print("Done.") +} diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index 1ced2e7..e0c2aa3 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -6,13 +6,19 @@ import HealthKitExtensions import CoreLocation struct WorkoutDetailView: View { - - let workout: Workout + + @EnvironmentObject + var database: HealthDatabase private let store = HKHealthStore() + let workout: Workout + @State - var heartRateSamples: [HeartRate] = [] + private var healthWorkout: HKWorkout? + + @State + var heartRateSamplesInHealth: [HeartRate] = [] @State var heartRateSamplesInDatabase: [HeartRate] = [] @@ -20,30 +26,28 @@ struct WorkoutDetailView: View { @State var locationSamples: [CLLocation] = [] - - var body: some View { List { Section("Info") { DetailRow("ID", value: workout.id) DetailRow("Total Distance", kilometer: workout.totalDistance) + DetailRow("Duration", duration: workout.duration) DetailRow("Goal", value: workout.goal) } - if !workout.activities.isEmpty { + if !workout.workoutActivities.isEmpty { Section("Activities") { - ForEach(workout.activities, id: \.startDate) { activity in + ForEach(workout.workoutActivities, id: \.startDate) { activity in NavigationLink(value: activity) { DetailRow(activity.workoutConfiguration.activityType.description, date: activity.startDate) } - } } } - if !workout.events.isEmpty { + if !workout.workoutEvents.isEmpty { Section("Events") { - NavigationLink(value: workout.events) { - DetailRow("Events", value: workout.events.count) + NavigationLink(value: workout.workoutEvents) { + DetailRow("Events", value: workout.workoutEvents.count) } } } @@ -56,13 +60,19 @@ struct WorkoutDetailView: View { } } } - Section("Heart Rate") { - DetailRow("Count", value: "\(heartRateSamples.count)") - DetailRow("Range", value: "\(heartRateSamples.minimumHeartRate) - \(heartRateSamples.maximumHeartRate)") - DetailRow("Database count", value: "\(heartRateSamplesInDatabase.count)") - DetailRow("Database range", value: "\(heartRateSamplesInDatabase.minimumHeartRate) - \(heartRateSamplesInDatabase.maximumHeartRate)") + DetailRow("Samples", value: "\(heartRateSamplesInDatabase.count)") + DetailRow("Range", value: "\(heartRateSamplesInDatabase.minimumHeartRate) - \(heartRateSamplesInDatabase.maximumHeartRate)") } + if let healthWorkout { + Section("Matching health workout") { + DetailRow("Duration", value: healthWorkout.duration.durationString) + DetailRow("Distance", kilometer: healthWorkout.distance?.doubleValue(for: .meterUnit(with: .kilo))) + DetailRow("Heart rate samples", value: "\(heartRateSamplesInHealth.count)") + DetailRow("Heart rate range", value: "\(heartRateSamplesInHealth.minimumHeartRate) - \(heartRateSamplesInHealth.maximumHeartRate)") + } + } + if !locationSamples.isEmpty { Section("Locations") { @@ -72,7 +82,7 @@ struct WorkoutDetailView: View { } .navigationTitle(workout.typeString) .navigationDestination(for: HKWorkoutActivity.self) { activity in - ActivityDetailView(activity: activity) + ActivityDetailView(workout: workout, activity: activity) } .navigationDestination(for: [HKWorkoutEvent].self) { WorkoutEventsView(events: $0) @@ -82,17 +92,11 @@ struct WorkoutDetailView: View { private func loadSamples() { Task { + checkPermissionsAndSearchHealth() do { - let samples = try await self.loadHeartRateData() - DispatchQueue.main.async { - self.heartRateSamples = samples + guard let samples: [HeartRate] = try database.store?.samples(associatedWith: workout) else { + return } - print("Loaded \(samples.count) heart rate samples") - } catch { - print("Failed to load heart rate samples: \(error)") - } - do { - let samples = try self.queryDatabase() DispatchQueue.main.async { self.heartRateSamplesInDatabase = samples } @@ -103,42 +107,116 @@ struct WorkoutDetailView: View { } } - private func loadHeartRateData() async throws -> [HeartRate] { - let sort = SortDescriptor.init(\.endDate, order: .forward) - - guard let start = workout.firstActivityDate, - let end = workout.activities.compactMap({ $0.endDate }).max() else { - print("No dates to get heart rates") - return [] + private func checkPermissionsAndSearchHealth() { + Task { + do { + try await checkPermissionsAndFindWorkout() + } catch { + print("Failed to search for workout: \(error)") + } } - - print("Heart rates from \(start) to \(end)") - let predicate = HKQuery.predicateForSamples( - withStart: start, - end: end, - options: []) - - return try await store.read( - predicate: predicate, - sortDescriptors: [sort], - limit: nil) } - func queryDatabase() throws -> [HeartRate] { - guard let start = workout.firstActivityDate, - let end = workout.activities.compactMap({ $0.endDate }).max() else { - print("No dates to get heart rates") - return [] + private func checkPermissionsAndFindWorkout() async throws { + + switch store.authorizationStatus(for: .workoutType()) { + case .notDetermined: + try await requestWorkoutPermission() + try await checkPermissionsAndFindWorkout() + case .sharingAuthorized: + findWorkoutInHealth() + case .sharingDenied: + print("No permission to write workouts") + findWorkoutInHealth() + return + @unknown default: + print("Unknown permission for workouts") + return + } + } + + private func requestWorkoutPermission() async throws { + try await store.requestAuthorization(read: HKWorkout.self) + } + + private func findWorkoutInHealth() { + guard let activityType = workout.workoutActivities.first?.workoutConfiguration.activityType else { + print("No activity type to find workout") + return + } + let start = workout.startDate.addingTimeInterval(-60) + let end = workout.endDate.addingTimeInterval(60) + let workoutPredicate = HKQuery.predicateForWorkouts(with: activityType) + let timePredicate = HKQuery.predicateForSamples(withStart: start, end: end) + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [workoutPredicate, timePredicate]) + let sortDescriptor = NSSortDescriptor( + key: HKSampleSortIdentifierEndDate, + ascending: true) + + let query = HKSampleQuery( + sampleType: .workoutType(), + predicate: predicate, + limit: 0, + sortDescriptors: [sortDescriptor]) { _, samples, error in + if let error { + print("Failed to search for workout: \(error)") + } + guard let workout = samples?.first as? HKWorkout else { + print("No suitable workout found: \(samples?.count ?? 0)") + return + } + foundHealthStore(workout: workout) + } + + store.execute(query) + } + + private func foundHealthStore(workout: HKWorkout) { + print("Found matching workout in health") + DispatchQueue.main.async { + self.healthWorkout = workout + } + findHealthStoreHeartRates(for: workout) + } + + private func findHealthStoreHeartRates(for workout: HKWorkout) { + let forWorkout = HKQuery.predicateForObjects(from: workout) + let heartRate = HKQuantityType(.heartRate) + + let heartRateDescriptor = HKQueryDescriptor( + sampleType: heartRate, + predicate: forWorkout) + + let heartRateQuery = HKSampleQuery( + queryDescriptors: [heartRateDescriptor], + limit: HKObjectQueryNoLimit) + { query, samples, error in + if let error { + print("Failed to search for heart rates: \(error)") + } + guard let samples else { + print("No heart rate samples found in Health") + return + } + let heartRates = samples.map { HeartRate(sample: $0) } + processHealthStore(heartRateSamples: heartRates) } - return try HealthDatabase.shared.samples(from: start, to: end) + store.execute(heartRateQuery) + } + + private func processHealthStore(heartRateSamples: [HeartRate]) { + print("Found \(heartRateSamples.count) heart rate samples in Health") + DispatchQueue.main.async { + self.heartRateSamplesInHealth = heartRateSamples + } } } #Preview { - HealthDatabase.shared = .mock() return NavigationStack { WorkoutDetailView(workout: .mock1) + .environmentObject(HealthDatabase.mock) } } diff --git a/HealthImport/WorkoutEventsView.swift b/HealthImport/WorkoutEventsView.swift index f4df5bd..821fb6d 100644 --- a/HealthImport/WorkoutEventsView.swift +++ b/HealthImport/WorkoutEventsView.swift @@ -22,5 +22,5 @@ struct WorkoutEventsView: View { } #Preview { - WorkoutEventsView(events: Workout.mock1.events) + WorkoutEventsView(events: Workout.mock1.workoutEvents) }