Use HealthDB framework, load databases
This commit is contained in:
parent
d1a7e2b441
commit
08825f84a1
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */; };
|
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 */; };
|
8850025F2B5C273E00E7D4DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8850025E2B5C273E00E7D4DB /* Assets.xcassets */; };
|
||||||
885002622B5C273E00E7D4DB /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 885002612B5C273E00E7D4DB /* Preview 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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */; };
|
||||||
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */; };
|
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */; };
|
||||||
E2A38EA82B9C6EE800BAD02E /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */; };
|
E2A38EA82B9C6EE800BAD02E /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */; };
|
||||||
E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */; };
|
E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */; };
|
||||||
E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.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 */; };
|
E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; };
|
||||||
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; };
|
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -50,7 +56,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
885002572B5C273C00E7D4DB /* HealthImport.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthImport.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -89,7 +102,7 @@
|
|||||||
files = (
|
files = (
|
||||||
885002A62B5D296700E7D4DB /* Collections in Frameworks */,
|
885002A62B5D296700E7D4DB /* Collections in Frameworks */,
|
||||||
E20881D32B76912000D41D95 /* HealthKitExtensions in Frameworks */,
|
E20881D32B76912000D41D95 /* HealthKitExtensions in Frameworks */,
|
||||||
E2A38EA12B99FFDD00BAD02E /* HKDatabase in Frameworks */,
|
E2E552992BA3748500BF5E9B /* HKDatabase in Frameworks */,
|
||||||
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */,
|
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */,
|
||||||
885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */,
|
885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */,
|
||||||
885002A82B5D296700E7D4DB /* DequeModule in Frameworks */,
|
885002A82B5D296700E7D4DB /* DequeModule in Frameworks */,
|
||||||
@ -120,10 +133,12 @@
|
|||||||
885002592B5C273C00E7D4DB /* HealthImport */ = {
|
885002592B5C273C00E7D4DB /* HealthImport */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E2E552932BA23B8F00BF5E9B /* Info.plist */,
|
||||||
|
E2E5528A2BA21BFB00BF5E9B /* Model */,
|
||||||
E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */,
|
E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */,
|
||||||
8850026A2B5C276B00E7D4DB /* Resources */,
|
8850026A2B5C276B00E7D4DB /* Resources */,
|
||||||
8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */,
|
8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */,
|
||||||
8850025C2B5C273C00E7D4DB /* ContentView.swift */,
|
E2E552872BA2193B00BF5E9B /* Tabs */,
|
||||||
E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */,
|
E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */,
|
||||||
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
|
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
|
||||||
E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */,
|
E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */,
|
||||||
@ -175,10 +190,31 @@
|
|||||||
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */,
|
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */,
|
||||||
E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */,
|
E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */,
|
||||||
E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */,
|
E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */,
|
||||||
|
E2E5528D2BA21C5900BF5E9B /* FileManager+Directory.swift */,
|
||||||
|
E2E5529A2BA3935600BF5E9B /* HKWorkout+Extensions.swift */,
|
||||||
);
|
);
|
||||||
path = Support;
|
path = Support;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -202,8 +238,8 @@
|
|||||||
885002A92B5D296700E7D4DB /* OrderedCollections */,
|
885002A92B5D296700E7D4DB /* OrderedCollections */,
|
||||||
E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */,
|
E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */,
|
||||||
E20881D22B76912000D41D95 /* HealthKitExtensions */,
|
E20881D22B76912000D41D95 /* HealthKitExtensions */,
|
||||||
E2A38EA02B99FFDD00BAD02E /* HKDatabase */,
|
|
||||||
E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */,
|
E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */,
|
||||||
|
E2E552982BA3748500BF5E9B /* HKDatabase */,
|
||||||
);
|
);
|
||||||
productName = HealthImport;
|
productName = HealthImport;
|
||||||
productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */;
|
productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */;
|
||||||
@ -238,8 +274,8 @@
|
|||||||
885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */,
|
885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */,
|
||||||
E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
|
E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
|
||||||
E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */,
|
E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */,
|
||||||
E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */,
|
|
||||||
E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
||||||
|
E2E552972BA3748500BF5E9B /* XCRemoteSwiftPackageReference "HealthDB" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 885002582B5C273C00E7D4DB /* Products */;
|
productRefGroup = 885002582B5C273C00E7D4DB /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -270,9 +306,10 @@
|
|||||||
files = (
|
files = (
|
||||||
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */,
|
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */,
|
||||||
E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */,
|
E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */,
|
||||||
|
E2E552922BA236D000BF5E9B /* DatabaseFile.swift in Sources */,
|
||||||
E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */,
|
E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */,
|
||||||
E27BC6982B5FD76F003A8873 /* Data+Extensions.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 */,
|
8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */,
|
||||||
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
|
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
|
||||||
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */,
|
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */,
|
||||||
@ -282,13 +319,18 @@
|
|||||||
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
|
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
|
||||||
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
|
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
|
||||||
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
|
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
|
||||||
|
E2E5529B2BA3935600BF5E9B /* HKWorkout+Extensions.swift in Sources */,
|
||||||
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
|
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
|
||||||
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
|
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
|
||||||
E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */,
|
E2A38EAA2B9C862600BAD02E /* WorkoutEventsView.swift in Sources */,
|
||||||
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
|
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
|
||||||
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */,
|
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */,
|
||||||
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,
|
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,
|
||||||
|
E2E552892BA2194400BF5E9B /* DatabasesTab.swift in Sources */,
|
||||||
|
E2E552902BA236A000BF5E9B /* DatabaseList.swift in Sources */,
|
||||||
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
|
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
|
||||||
|
E2E5528C2BA21C0700BF5E9B /* HealthDatabase.swift in Sources */,
|
||||||
|
E2E5528E2BA21C5900BF5E9B /* FileManager+Directory.swift in Sources */,
|
||||||
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */,
|
E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */,
|
||||||
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
|
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
|
||||||
E20881D52B76944A00D41D95 /* Test.swift in Sources */,
|
E20881D52B76944A00D41D95 /* Test.swift in Sources */,
|
||||||
@ -432,13 +474,16 @@
|
|||||||
DEVELOPMENT_TEAM = H8WR4M6QQ4;
|
DEVELOPMENT_TEAM = H8WR4M6QQ4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Manage all the health data your choose.";
|
INFOPLIST_FILE = HealthImport/Info.plist;
|
||||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Manage all the health data your choose.";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -464,13 +509,16 @@
|
|||||||
DEVELOPMENT_TEAM = H8WR4M6QQ4;
|
DEVELOPMENT_TEAM = H8WR4M6QQ4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Manage all the health data your choose.";
|
INFOPLIST_FILE = HealthImport/Info.plist;
|
||||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Manage all the health data your choose.";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -528,16 +576,8 @@
|
|||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/christophhagen/HealthKitExtensions";
|
repositoryURL = "https://github.com/christophhagen/HealthKitExtensions";
|
||||||
requirement = {
|
requirement = {
|
||||||
branch = main;
|
kind = upToNextMajorVersion;
|
||||||
kind = branch;
|
minimumVersion = 0.3.3;
|
||||||
};
|
|
||||||
};
|
|
||||||
E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/christophhagen/iOSHealthDBInterface";
|
|
||||||
requirement = {
|
|
||||||
branch = main;
|
|
||||||
kind = branch;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
|
E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
|
||||||
@ -548,6 +588,14 @@
|
|||||||
minimumVersion = 5.2.0;
|
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" */ = {
|
E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/apple/swift-protobuf.git";
|
repositoryURL = "https://github.com/apple/swift-protobuf.git";
|
||||||
@ -584,16 +632,16 @@
|
|||||||
package = E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */;
|
package = E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */;
|
||||||
productName = HealthKitExtensions;
|
productName = HealthKitExtensions;
|
||||||
};
|
};
|
||||||
E2A38EA02B99FFDD00BAD02E /* HKDatabase */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */;
|
|
||||||
productName = HKDatabase;
|
|
||||||
};
|
|
||||||
E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */ = {
|
E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
package = E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
||||||
productName = SFSafeSymbols;
|
productName = SFSafeSymbols;
|
||||||
};
|
};
|
||||||
|
E2E552982BA3748500BF5E9B /* HKDatabase */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E2E552972BA3748500BF5E9B /* XCRemoteSwiftPackageReference "HealthDB" */;
|
||||||
|
productName = HKDatabase;
|
||||||
|
};
|
||||||
E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */ = {
|
E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */;
|
package = E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */;
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "b4e05748d8500bbff1c8ae286dbcad777cbcbcfd5780e4d633cf669d8ce257fb",
|
"originHash" : "5b8e27ff27b74293d3ae2085172fcc80a2317825fae6f3e7879caab9728af319",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "healthdb",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/christophhagen/HealthDB",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b1f45d1abf47a13696fba9670db24fe6ca7fab53",
|
||||||
|
"version" : "0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "healthkitextensions",
|
"identity" : "healthkitextensions",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/christophhagen/HealthKitExtensions",
|
"location" : "https://github.com/christophhagen/HealthKitExtensions",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"revision" : "18ee575892e6cc429c74c7bc3f156cc6791b220f",
|
||||||
"revision" : "02ce75960a2b3fd1d2b7d2c620f519342956690c"
|
"version" : "0.3.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "ioshealthdbinterface",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/christophhagen/iOSHealthDBInterface",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "b5acf75f1d5a166cc7a92ebf040160e6471d8ff1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -5,6 +5,11 @@ import CoreLocation
|
|||||||
|
|
||||||
struct ActivityDetailView: View {
|
struct ActivityDetailView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
var database: HealthDatabase
|
||||||
|
|
||||||
|
let workout: Workout
|
||||||
|
|
||||||
let activity: HKWorkoutActivity
|
let activity: HKWorkoutActivity
|
||||||
|
|
||||||
@State var locations: [CLLocation] = []
|
@State var locations: [CLLocation] = []
|
||||||
@ -61,9 +66,15 @@ struct ActivityDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func load() {
|
private func load() {
|
||||||
|
guard let store = database.store else {
|
||||||
|
return
|
||||||
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
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 }
|
.sorted { $0.timestamp }
|
||||||
//let sampleCount = try HealthDatabase.shared.sampleCount(for: activity)
|
//let sampleCount = try HealthDatabase.shared.sampleCount(for: activity)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -78,8 +89,8 @@ struct ActivityDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
HealthDatabase.shared = .mock()
|
NavigationStack {
|
||||||
return NavigationStack {
|
ActivityDetailView(workout: .mock1, activity: .mock1)
|
||||||
ActivityDetailView(activity: .mock1)
|
.environmentObject(HealthDatabase.mock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,41 +1,62 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HKDatabase
|
import HKDatabase
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
|
private enum TabSelection: Int {
|
||||||
|
case databases = 0
|
||||||
|
case workouts = 1
|
||||||
|
case samples = 2
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct HealthImportApp: App {
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
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 {
|
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 {
|
do {
|
||||||
let connection = try Connection(.inMemory)
|
let connection = try Connection(.inMemory)
|
||||||
let database = HealthDatabase(database: connection)
|
let store = HKDatabaseStore(database: connection)
|
||||||
try database.createTables()
|
try store.createTables()
|
||||||
try database.insert(workout: .mock1)
|
try store.insert(workout: .mock1)
|
||||||
return database
|
return .init(store: .init(wrapping: store))
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
print(error)
|
||||||
fatalError("Failed to create mock database: \(error)")
|
fatalError("Failed to create empty database: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,14 +6,22 @@ import HealthKitExtensions
|
|||||||
extension Workout {
|
extension Workout {
|
||||||
|
|
||||||
static var mock1: 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,
|
totalDistance: 16.7620435816585,
|
||||||
goalType: 2,
|
goalType: 2,
|
||||||
goal: 19800.0,
|
goal: 19800.0,
|
||||||
condenserVersion: 3,
|
|
||||||
condenserDate: Date(timeIntervalSinceReferenceDate: 716801471.790011),
|
|
||||||
events: HKWorkoutEvent.mock1,
|
events: HKWorkoutEvent.mock1,
|
||||||
activities: [.mock1],
|
activities: [activity],
|
||||||
metadata: Metadata.mock1)
|
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 Foundation
|
||||||
import HKDatabase
|
import HKDatabase
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
private let df: DateFormatter = {
|
private let df: DateFormatter = {
|
||||||
let df = DateFormatter()
|
let df = DateFormatter()
|
||||||
@ -9,28 +10,24 @@ private let df: DateFormatter = {
|
|||||||
return df
|
return df
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
extension Workout: Identifiable {
|
||||||
|
|
||||||
|
public var id: Int {
|
||||||
|
dataId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Workout {
|
extension Workout {
|
||||||
|
|
||||||
|
var workoutActivityType: HKWorkoutActivityType {
|
||||||
|
workoutActivities.first!.workoutConfiguration.activityType
|
||||||
|
}
|
||||||
|
|
||||||
var typeString: String {
|
var typeString: String {
|
||||||
activities.first?.workoutConfiguration.activityType.description ?? "Unknown activity"
|
workoutActivityType.description
|
||||||
}
|
}
|
||||||
|
|
||||||
var dateString: String {
|
var dateString: String {
|
||||||
guard let firstAvailableDate else {
|
df.string(from: startDate)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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,7 +3,10 @@ import HealthKit
|
|||||||
import HKDatabase
|
import HKDatabase
|
||||||
import SFSafeSymbols
|
import SFSafeSymbols
|
||||||
|
|
||||||
struct ContentView: View {
|
struct WorkoutTab: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
var database: HealthDatabase
|
||||||
|
|
||||||
@State var navigationPath: NavigationPath = .init()
|
@State var navigationPath: NavigationPath = .init()
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ struct ContentView: View {
|
|||||||
NavigationLink(value: workout) {
|
NavigationLink(value: workout) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(workout.typeString)
|
Text(workout.typeString)
|
||||||
|
.font(.headline)
|
||||||
Text(workout.dateString)
|
Text(workout.dateString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -29,6 +33,9 @@ struct ContentView: View {
|
|||||||
.navigationDestination(for: Workout.self) {
|
.navigationDestination(for: Workout.self) {
|
||||||
WorkoutDetailView(workout: $0)
|
WorkoutDetailView(workout: $0)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
reloadAsync()
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@ -37,37 +44,43 @@ struct ContentView: View {
|
|||||||
Image(systemSymbol: .magnifyingglass)
|
Image(systemSymbol: .magnifyingglass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}.onChange(of: database.file, perform: { value in
|
||||||
.onAppear(perform: getPermissions)
|
reload()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getPermissions() {
|
private func reload() {
|
||||||
Task {
|
Task {
|
||||||
let store = HKHealthStore()
|
reloadAsync()
|
||||||
do {
|
|
||||||
let success = try await requestAllPermissions(in: store)
|
|
||||||
print("Has permissions: \(success)")
|
|
||||||
} catch {
|
|
||||||
print("Error getting permissions: \(error)")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
do {
|
}
|
||||||
let workouts = try HealthDatabase.shared.readAllWorkouts()
|
|
||||||
|
private func reloadAsync() {
|
||||||
|
guard let store = database.store else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.workouts = workouts.reversed()
|
self.workouts = []
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let workouts = try store.workouts()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.workouts = workouts
|
||||||
|
print("Loaded \(workouts.count) workouts")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Error getting workouts: \(error)")
|
print("Failed to load workouts: \(error)")
|
||||||
return
|
DispatchQueue.main.async {
|
||||||
|
self.workouts = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
HealthDatabase.shared = .mock()
|
WorkoutTab()
|
||||||
return ContentView()
|
.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.")
|
||||||
|
}
|
@ -7,12 +7,18 @@ import CoreLocation
|
|||||||
|
|
||||||
struct WorkoutDetailView: View {
|
struct WorkoutDetailView: View {
|
||||||
|
|
||||||
let workout: Workout
|
@EnvironmentObject
|
||||||
|
var database: HealthDatabase
|
||||||
|
|
||||||
private let store = HKHealthStore()
|
private let store = HKHealthStore()
|
||||||
|
|
||||||
|
let workout: Workout
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var heartRateSamples: [HeartRate] = []
|
private var healthWorkout: HKWorkout?
|
||||||
|
|
||||||
|
@State
|
||||||
|
var heartRateSamplesInHealth: [HeartRate] = []
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var heartRateSamplesInDatabase: [HeartRate] = []
|
var heartRateSamplesInDatabase: [HeartRate] = []
|
||||||
@ -20,30 +26,28 @@ struct WorkoutDetailView: View {
|
|||||||
@State
|
@State
|
||||||
var locationSamples: [CLLocation] = []
|
var locationSamples: [CLLocation] = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
DetailRow("ID", value: workout.id)
|
DetailRow("ID", value: workout.id)
|
||||||
DetailRow("Total Distance", kilometer: workout.totalDistance)
|
DetailRow("Total Distance", kilometer: workout.totalDistance)
|
||||||
|
DetailRow("Duration", duration: workout.duration)
|
||||||
DetailRow("Goal", value: workout.goal)
|
DetailRow("Goal", value: workout.goal)
|
||||||
}
|
}
|
||||||
if !workout.activities.isEmpty {
|
if !workout.workoutActivities.isEmpty {
|
||||||
Section("Activities") {
|
Section("Activities") {
|
||||||
ForEach(workout.activities, id: \.startDate) { activity in
|
ForEach(workout.workoutActivities, id: \.startDate) { activity in
|
||||||
NavigationLink(value: activity) {
|
NavigationLink(value: activity) {
|
||||||
DetailRow(activity.workoutConfiguration.activityType.description,
|
DetailRow(activity.workoutConfiguration.activityType.description,
|
||||||
date: activity.startDate)
|
date: activity.startDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !workout.events.isEmpty {
|
if !workout.workoutEvents.isEmpty {
|
||||||
Section("Events") {
|
Section("Events") {
|
||||||
NavigationLink(value: workout.events) {
|
NavigationLink(value: workout.workoutEvents) {
|
||||||
DetailRow("Events", value: workout.events.count)
|
DetailRow("Events", value: workout.workoutEvents.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,13 +60,19 @@ struct WorkoutDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Heart Rate") {
|
Section("Heart Rate") {
|
||||||
DetailRow("Count", value: "\(heartRateSamples.count)")
|
DetailRow("Samples", value: "\(heartRateSamplesInDatabase.count)")
|
||||||
DetailRow("Range", value: "\(heartRateSamples.minimumHeartRate) - \(heartRateSamples.maximumHeartRate)")
|
DetailRow("Range", value: "\(heartRateSamplesInDatabase.minimumHeartRate) - \(heartRateSamplesInDatabase.maximumHeartRate)")
|
||||||
DetailRow("Database count", value: "\(heartRateSamplesInDatabase.count)")
|
|
||||||
DetailRow("Database 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 {
|
if !locationSamples.isEmpty {
|
||||||
Section("Locations") {
|
Section("Locations") {
|
||||||
@ -72,7 +82,7 @@ struct WorkoutDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(workout.typeString)
|
.navigationTitle(workout.typeString)
|
||||||
.navigationDestination(for: HKWorkoutActivity.self) { activity in
|
.navigationDestination(for: HKWorkoutActivity.self) { activity in
|
||||||
ActivityDetailView(activity: activity)
|
ActivityDetailView(workout: workout, activity: activity)
|
||||||
}
|
}
|
||||||
.navigationDestination(for: [HKWorkoutEvent].self) {
|
.navigationDestination(for: [HKWorkoutEvent].self) {
|
||||||
WorkoutEventsView(events: $0)
|
WorkoutEventsView(events: $0)
|
||||||
@ -82,17 +92,11 @@ struct WorkoutDetailView: View {
|
|||||||
|
|
||||||
private func loadSamples() {
|
private func loadSamples() {
|
||||||
Task {
|
Task {
|
||||||
|
checkPermissionsAndSearchHealth()
|
||||||
do {
|
do {
|
||||||
let samples = try await self.loadHeartRateData()
|
guard let samples: [HeartRate] = try database.store?.samples(associatedWith: workout) else {
|
||||||
DispatchQueue.main.async {
|
return
|
||||||
self.heartRateSamples = samples
|
|
||||||
}
|
}
|
||||||
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 {
|
DispatchQueue.main.async {
|
||||||
self.heartRateSamplesInDatabase = samples
|
self.heartRateSamplesInDatabase = samples
|
||||||
}
|
}
|
||||||
@ -103,42 +107,116 @@ struct WorkoutDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadHeartRateData() async throws -> [HeartRate] {
|
private func checkPermissionsAndSearchHealth() {
|
||||||
let sort = SortDescriptor<HKQuantitySample>.init(\.endDate, order: .forward)
|
Task {
|
||||||
|
do {
|
||||||
guard let start = workout.firstActivityDate,
|
try await checkPermissionsAndFindWorkout()
|
||||||
let end = workout.activities.compactMap({ $0.endDate }).max() else {
|
} catch {
|
||||||
print("No dates to get heart rates")
|
print("Failed to search for workout: \(error)")
|
||||||
return []
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print("Heart rates from \(start) to \(end)")
|
private func checkPermissionsAndFindWorkout() async throws {
|
||||||
let predicate = HKQuery.predicateForSamples(
|
|
||||||
withStart: start,
|
|
||||||
end: end,
|
|
||||||
options: [])
|
|
||||||
|
|
||||||
return try await store.read(
|
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,
|
predicate: predicate,
|
||||||
sortDescriptors: [sort],
|
limit: 0,
|
||||||
limit: nil)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func queryDatabase() throws -> [HeartRate] {
|
store.execute(query)
|
||||||
guard let start = workout.firstActivityDate,
|
|
||||||
let end = workout.activities.compactMap({ $0.endDate }).max() else {
|
|
||||||
print("No dates to get heart rates")
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return try HealthDatabase.shared.samples(from: start, to: end)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.execute(heartRateQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processHealthStore(heartRateSamples: [HeartRate]) {
|
||||||
|
print("Found \(heartRateSamples.count) heart rate samples in Health")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.heartRateSamplesInHealth = heartRateSamples
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
HealthDatabase.shared = .mock()
|
|
||||||
return NavigationStack {
|
return NavigationStack {
|
||||||
WorkoutDetailView(workout: .mock1)
|
WorkoutDetailView(workout: .mock1)
|
||||||
|
.environmentObject(HealthDatabase.mock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,5 +22,5 @@ struct WorkoutEventsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
WorkoutEventsView(events: Workout.mock1.events)
|
WorkoutEventsView(events: Workout.mock1.workoutEvents)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user