diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 5256b92..826aa4a 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -39,6 +39,10 @@ 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 */; }; 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 */ @@ -71,6 +75,9 @@ E27BC6952B5FD61D003A8873 /* WorkoutEvent+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+Mock.swift"; sourceTree = ""; }; E27BC6972B5FD76F003A8873 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; E2A38EA22B9A024500BAD02E /* Workout+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Extensions.swift"; sourceTree = ""; }; + E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHealthStoreView.swift; sourceTree = ""; }; + E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsView.swift; sourceTree = ""; }; + E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMetadataView.swift; sourceTree = ""; }; E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; E2FDFF342B6E59030080A7B3 /* HealthImport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HealthImport.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -86,6 +93,7 @@ 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */, 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */, 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */, + E2A38EA82B9C6EE800BAD02E /* SFSafeSymbols in Frameworks */, E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -116,7 +124,10 @@ 8850026A2B5C276B00E7D4DB /* Resources */, 8850025A2B5C273C00E7D4DB /* HealthImportApp.swift */, 8850025C2B5C273C00E7D4DB /* ContentView.swift */, + E2A38EA42B9C6EA900BAD02E /* SearchHealthStoreView.swift */, 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */, + E2A38EAB2B9C8E4B00BAD02E /* WorkoutMetadataView.swift */, + E2A38EA92B9C862600BAD02E /* WorkoutEventsView.swift */, 885002922B5D129300E7D4DB /* ActivityDetailView.swift */, E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */, E201EC7E2B629B4C005B83D3 /* SampleListView.swift */, @@ -192,6 +203,7 @@ E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */, E20881D22B76912000D41D95 /* HealthKitExtensions */, E2A38EA02B99FFDD00BAD02E /* HKDatabase */, + E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */, ); productName = HealthImport; productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */; @@ -227,6 +239,7 @@ E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */, E20881D12B76912000D41D95 /* XCRemoteSwiftPackageReference "HealthKitExtensions" */, E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */, + E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, ); productRefGroup = 885002582B5C273C00E7D4DB /* Products */; projectDirPath = ""; @@ -257,6 +270,7 @@ files = ( E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */, E2A38EA32B9A024500BAD02E /* Workout+Extensions.swift in Sources */, + E2A38EAC2B9C8E4B00BAD02E /* WorkoutMetadataView.swift in Sources */, E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */, 8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */, 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */, @@ -270,10 +284,12 @@ E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.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 */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, + E2A38EA52B9C6EA900BAD02E /* SearchHealthStoreView.swift in Sources */, E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */, E20881D52B76944A00D41D95 /* Test.swift in Sources */, E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */, @@ -512,8 +528,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/christophhagen/HealthKitExtensions"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.3.0; + branch = main; + kind = branch; }; }; E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */ = { @@ -524,6 +540,14 @@ kind = branch; }; }; + E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.2.0; + }; + }; E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-protobuf.git"; @@ -565,6 +589,11 @@ package = E2A38E9F2B99FFDD00BAD02E /* XCRemoteSwiftPackageReference "iOSHealthDBInterface" */; productName = HKDatabase; }; + E2A38EA72B9C6EE800BAD02E /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = E2A38EA62B9C6EE800BAD02E /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */ = { isa = XCSwiftPackageProductDependency; package = E2FDFF1E2B6BE34C0080A7B3 /* XCRemoteSwiftPackageReference "swift-protobuf" */; diff --git a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e509030..badb438 100644 --- a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "d0067804897f78a2b51cdf96069649aae9f635cdf94333101762cb0c84fd39ae", + "originHash" : "b4e05748d8500bbff1c8ae286dbcad777cbcbcfd5780e4d633cf669d8ce257fb", "pins" : [ { "identity" : "healthkitextensions", "kind" : "remoteSourceControl", "location" : "https://github.com/christophhagen/HealthKitExtensions", "state" : { - "revision" : "88625ad3480ceea974e47f61e1abb05a84896c73", - "version" : "0.3.0" + "branch" : "main", + "revision" : "02ce75960a2b3fd1d2b7d2c620f519342956690c" } }, { @@ -16,7 +16,16 @@ "location" : "https://github.com/christophhagen/iOSHealthDBInterface", "state" : { "branch" : "main", - "revision" : "d638c367f3bbdbffefdf0a4dde8fc0afd73bbaad" + "revision" : "b5acf75f1d5a166cc7a92ebf040160e6471d8ff1" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "afd0a1da4ed62bab1413caa6dd6b60a7a7089ed2", + "version" : "5.2.0" } }, { diff --git a/HealthImport/ContentView.swift b/HealthImport/ContentView.swift index a3669ee..a315008 100644 --- a/HealthImport/ContentView.swift +++ b/HealthImport/ContentView.swift @@ -1,6 +1,7 @@ import SwiftUI import HealthKit import HKDatabase +import SFSafeSymbols struct ContentView: View { @@ -30,20 +31,35 @@ struct ContentView: View { } .toolbar { ToolbarItem { - Button(action: addWorkouts) { - Text("Add") + NavigationLink { + SearchHealthStoreView() + } label: { + Image(systemSymbol: .magnifyingglass) } } } + .onAppear(perform: getPermissions) } } - private func addWorkouts() { + private func getPermissions() { Task { + let store = HKHealthStore() do { - try await insertExamplesOfAllTypes() + let success = try await requestAllPermissions(in: store) + print("Has permissions: \(success)") } catch { - print("Failed: \(error)") + print("Error getting permissions: \(error)") + return + } + do { + let workouts = try HealthDatabase.shared.readAllWorkouts() + DispatchQueue.main.async { + self.workouts = workouts.reversed() + } + } catch { + print("Error getting workouts: \(error)") + return } } } diff --git a/HealthImport/HealthImportApp.swift b/HealthImport/HealthImportApp.swift index d206623..65bf4aa 100644 --- a/HealthImport/HealthImportApp.swift +++ b/HealthImport/HealthImportApp.swift @@ -1,8 +1,6 @@ import SwiftUI import HKDatabase -#warning("Load workouts from database and samples from HealthKit") - @main struct HealthImportApp: App { @@ -23,6 +21,21 @@ private extension HealthDatabase { static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite") convenience init() { - try! self.init(fileUrl: HealthDatabase.databaseFileUrl!) + let bundleUrl = HealthDatabase.databaseFileUrl! + let local = FileManager.default.documentDirectory.appendingPathComponent("db.sqlite") + if !FileManager.default.fileExists(atPath: local.path) { + try! FileManager.default.copyItem(at: bundleUrl, to: local) + } + try! self.init(fileUrl: local) + } +} + +extension FileManager { + + var documentDirectory: URL { + try! url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true) } } diff --git a/HealthImport/SearchHealthStoreView.swift b/HealthImport/SearchHealthStoreView.swift new file mode 100644 index 0000000..42675b6 --- /dev/null +++ b/HealthImport/SearchHealthStoreView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import HealthKit +import HealthKitExtensions + +struct SearchHealthStoreView: View { + + @State + var start: Date = .now.addingTimeInterval(-86400) + + @State + var end: Date = .now + + @State + var quantity: HKQuantityTypeIdentifier = .heartRate + + @State + var isRunningQuery = false + + @State + var samples: [HKQuantitySample] = [] + + private let store = HKHealthStore() + + var body: some View { + VStack { + DatePicker("Start date", selection: $start) + DatePicker("End date", selection: $end) + Picker("Quantity", selection: $quantity) { + ForEach(HKQuantityTypeIdentifier.allCases) { id in + Text(id.description).tag(id) + } + + } + Button(action: performQuery) { + Text("Search") + .padding() + } + .disabled(isRunningQuery) + Text("\(samples.count) samples found") + } + .padding() + .navigationTitle("Search") + } + + func performQuery() { + isRunningQuery = true + Task { + let samples = try await queryHealthStore() + DispatchQueue.main.async { + self.samples = samples + self.isRunningQuery = false + } + } + } + + func queryHealthStore() async throws -> [HKQuantitySample] { + let sortByDate = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true) + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: []) + + let samples = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKQuantitySample], Error>) in + let query = HKSampleQuery( + sampleType: HKQuantityType(quantity), + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [sortByDate]) { query, samples, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let samples = samples else { + continuation.resume(returning: []) + return + } + continuation.resume(returning: samples as! [HKQuantitySample]) + } + store.execute(query) + } + return samples + } +} + +#Preview { + SearchHealthStoreView() +} + +extension HKQuantityTypeIdentifier: Identifiable { + + public var id: String { + rawValue + } +} diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index 1d9ef03..1ced2e7 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -2,14 +2,25 @@ import SwiftUI import Collections import HealthKit import HKDatabase +import HealthKitExtensions +import CoreLocation struct WorkoutDetailView: View { let workout: Workout - var metadata: [(key: String, value: Any)] { - workout.metadata.sorted { $0.key } - } + private let store = HKHealthStore() + + @State + var heartRateSamples: [HeartRate] = [] + + @State + var heartRateSamplesInDatabase: [HeartRate] = [] + + @State + var locationSamples: [CLLocation] = [] + + var body: some View { List { @@ -31,28 +42,96 @@ struct WorkoutDetailView: View { } if !workout.events.isEmpty { Section("Events") { - ForEach(workout.events) { event in - NavigationLink(value: event) { - DetailRow(event.type.description, date: event.dateInterval.start) - } + NavigationLink(value: workout.events) { + DetailRow("Events", value: workout.events.count) } } } if !workout.metadata.isEmpty { Section("Metadata") { - ForEach(metadata, id:\.key) { (key, value) in - DetailRow("\(key)", value: "\(value)") + NavigationLink { + WorkoutMetadataView(metadata: workout.metadata) + } label: { + DetailRow("Metadata", value: workout.metadata.count) } } } + + 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)") + } + + if !locationSamples.isEmpty { + Section("Locations") { + DetailRow("Count", value: "\(locationSamples.count)") + } + } } .navigationTitle(workout.typeString) .navigationDestination(for: HKWorkoutActivity.self) { activity in ActivityDetailView(activity: activity) } - .navigationDestination(for: HKWorkoutEvent.self) { event in - EventDetailView(event: event) + .navigationDestination(for: [HKWorkoutEvent].self) { + WorkoutEventsView(events: $0) } + .onAppear(perform: loadSamples) + } + + private func loadSamples() { + Task { + do { + let samples = try await self.loadHeartRateData() + DispatchQueue.main.async { + 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 { + self.heartRateSamplesInDatabase = samples + } + print("Loaded \(samples.count) heart rate samples from database") + } catch { + print("Failed to load heart rate samples from database: \(error)") + } + } + } + + private func loadHeartRateData() async throws -> [HeartRate] { + let sort = SortDescriptor.init(\.endDate, order: .forward) + + guard let start = workout.firstActivityDate, + let end = workout.activities.compactMap({ $0.endDate }).max() else { + print("No dates to get heart rates") + return [] + } + + print("Heart rates from \(start) to \(end)") + let predicate = HKQuery.predicateForSamples( + withStart: start, + end: end, + options: []) + + return try await store.read( + predicate: predicate, + sortDescriptors: [sort], + limit: nil) + } + + func queryDatabase() throws -> [HeartRate] { + guard let start = workout.firstActivityDate, + let end = workout.activities.compactMap({ $0.endDate }).max() else { + print("No dates to get heart rates") + return [] + } + + return try HealthDatabase.shared.samples(from: start, to: end) } } @@ -67,3 +146,25 @@ extension String: Identifiable { public var id: Self { self } } + +private extension Array where Element == HeartRate { + + var minimumHeartRate: Int { + self.min { + $0.value < $1.value + }?.beatsPerMinute ?? 0 + } + + var maximumHeartRate: Int { + self.max { + $0.value < $1.value + }?.beatsPerMinute ?? 0 + } +} + +private extension HeartRate { + + var beatsPerMinute: Int { + quantity.doubleValue(for: .count().unitDivided(by: .minute())).roundedInt + } +} diff --git a/HealthImport/WorkoutEventsView.swift b/HealthImport/WorkoutEventsView.swift new file mode 100644 index 0000000..f4df5bd --- /dev/null +++ b/HealthImport/WorkoutEventsView.swift @@ -0,0 +1,26 @@ +import SwiftUI +import HKDatabase +import HealthKit + +struct WorkoutEventsView: View { + + let events: [HKWorkoutEvent] + + var body: some View { + List { + ForEach(events) { event in + NavigationLink(value: event) { + DetailRow(event.type.description, date: event.dateInterval.start) + } + } + } + .navigationTitle("Events") + .navigationDestination(for: HKWorkoutEvent.self) { event in + EventDetailView(event: event) + } + } +} + +#Preview { + WorkoutEventsView(events: Workout.mock1.events) +} diff --git a/HealthImport/WorkoutMetadataView.swift b/HealthImport/WorkoutMetadataView.swift new file mode 100644 index 0000000..4e1f605 --- /dev/null +++ b/HealthImport/WorkoutMetadataView.swift @@ -0,0 +1,25 @@ +import SwiftUI +import HKDatabase + +struct WorkoutMetadataView: View { + + let metadata: [String : Any] + + private var metadataFields: [(key: String, value: Any)] { + metadata.sorted { $0.key } + } + + var body: some View { + List { + ForEach(metadataFields, id:\.key) { (key, value) in + let keyString = HKMetadataKey.describe(key: key) ?? HKMetadataPrivateKey.describe(key: key) ?? key + DetailRow(keyString, value: "\(value)") + } + } + .navigationTitle("Metadata") + } +} + +#Preview { + WorkoutMetadataView(metadata: Workout.mock1.metadata) +}