Use HealthDB framework, load databases
This commit is contained in:
parent
d1a7e2b441
commit
08825f84a1
@ -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 = "<group>"; };
|
||||
8850025C2B5C273C00E7D4DB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
8850025C2B5C273C00E7D4DB /* WorkoutTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutTab.swift; sourceTree = "<group>"; };
|
||||
8850025E2B5C273E00E7D4DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
885002612B5C273E00E7D4DB /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = "<group>"; };
|
||||
@ -78,6 +84,13 @@
|
||||
E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHealthStoreView.swift; sourceTree = "<group>"; };
|
||||
E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsView.swift; sourceTree = "<group>"; };
|
||||
E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMetadataView.swift; sourceTree = "<group>"; };
|
||||
E2E552882BA2194400BF5E9B /* DatabasesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasesTab.swift; sourceTree = "<group>"; };
|
||||
E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = "<group>"; };
|
||||
E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Directory.swift"; sourceTree = "<group>"; };
|
||||
E2E5528F2BA236A000BF5E9B /* DatabaseList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseList.swift; sourceTree = "<group>"; };
|
||||
E2E552912BA236D000BF5E9B /* DatabaseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFile.swift; sourceTree = "<group>"; };
|
||||
E2E552932BA23B8F00BF5E9B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkout+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
E2E552872BA2193B00BF5E9B /* Tabs */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2E552882BA2194400BF5E9B /* DatabasesTab.swift */,
|
||||
8850025C2B5C273C00E7D4DB /* WorkoutTab.swift */,
|
||||
);
|
||||
path = Tabs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2E5528A2BA21BFB00BF5E9B /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2E5528B2BA21C0700BF5E9B /* HealthDatabase.swift */,
|
||||
E2E5528F2BA236A000BF5E9B /* DatabaseList.swift */,
|
||||
E2E552912BA236D000BF5E9B /* DatabaseFile.swift */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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" */;
|
||||
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
82
HealthImport/Info.plist
Normal file
82
HealthImport/Info.plist
Normal file
@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Apple Health SQLite Database</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.database</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Apple Health SQLite Database</string>
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.apple.sqlite3.database</string>
|
||||
<key>UTTypeReferenceURL</key>
|
||||
<string>https://christophhagen.de</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>sqlite</string>
|
||||
<string>sqlite3</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<array>
|
||||
<string>application/x-sqlite3</string>
|
||||
<string>application/vnd.sqlite3</string>
|
||||
<string>application/octet-stream</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.database</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Apple Health SQLite Database</string>
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.apple.sqlite3.database</string>
|
||||
<key>UTTypeReferenceURL</key>
|
||||
<string>https://christophhagen.de</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>sqlite</string>
|
||||
<string>sqlite3</string>
|
||||
<string>SQLITE</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<array>
|
||||
<string>application/x-sqlite3</string>
|
||||
<string>application/vnd.sqlite3</string>
|
||||
<string>application/octet-stream</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
30
HealthImport/Model/DatabaseFile.swift
Normal file
30
HealthImport/Model/DatabaseFile.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
170
HealthImport/Model/DatabaseList.swift
Normal file
170
HealthImport/Model/DatabaseList.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
44
HealthImport/Model/HealthDatabase.swift
Normal file
44
HealthImport/Model/HealthDatabase.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
11
HealthImport/Support/FileManager+Directory.swift
Normal file
11
HealthImport/Support/FileManager+Directory.swift
Normal file
@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension FileManager {
|
||||
|
||||
var documentDirectory: URL {
|
||||
try! url(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil, create: true)
|
||||
}
|
||||
}
|
21
HealthImport/Support/HKWorkout+Extensions.swift
Normal file
21
HealthImport/Support/HKWorkout+Extensions.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
164
HealthImport/Tabs/DatabasesTab.swift
Normal file
164
HealthImport/Tabs/DatabasesTab.swift
Normal file
@ -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<URL, Error>) {
|
||||
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())
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
/*
|
227
HealthImport/Test.swift
Normal file
227
HealthImport/Test.swift
Normal file
@ -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<T>(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<T>(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.")
|
||||
}
|
@ -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<HKQuantitySample>.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -22,5 +22,5 @@ struct WorkoutEventsView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutEventsView(events: Workout.mock1.events)
|
||||
WorkoutEventsView(events: Workout.mock1.workoutEvents)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user