Use HealthDB framework, load databases

This commit is contained in:
Christoph Hagen 2024-03-15 10:51:15 +01:00
parent d1a7e2b441
commit 08825f84a1
18 changed files with 1111 additions and 174 deletions

View File

@ -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" */;

View File

@ -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"
}
},
{

View File

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

View File

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

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

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

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

View File

@ -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)")
}
}
}

View File

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

View File

@ -0,0 +1,11 @@
import Foundation
extension FileManager {
var documentDirectory: URL {
try! url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
}

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

View File

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

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

View File

@ -3,7 +3,10 @@ import HealthKit
import HKDatabase
import SFSafeSymbols
struct ContentView: View {
struct WorkoutTab: View {
@EnvironmentObject
var database: HealthDatabase
@State var navigationPath: NavigationPath = .init()
@ -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()
}
do {
let workouts = try HealthDatabase.shared.readAllWorkouts()
}
private func reloadAsync() {
guard let store = database.store else {
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 {
print("Error getting workouts: \(error)")
return
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
View 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.")
}

View File

@ -7,12 +7,18 @@ 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: [])
private func checkPermissionsAndFindWorkout() async throws {
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,
sortDescriptors: [sort],
limit: nil)
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)
}
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 []
store.execute(query)
}
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 {
HealthDatabase.shared = .mock()
return NavigationStack {
WorkoutDetailView(workout: .mock1)
.environmentObject(HealthDatabase.mock)
}
}

View File

@ -22,5 +22,5 @@ struct WorkoutEventsView: View {
}
#Preview {
WorkoutEventsView(events: Workout.mock1.events)
WorkoutEventsView(events: Workout.mock1.workoutEvents)
}