Search samples, view metadata, events
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
91
HealthImport/SearchHealthStoreView.swift
Normal file
91
HealthImport/SearchHealthStoreView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
@@ -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<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 []
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
26
HealthImport/WorkoutEventsView.swift
Normal file
26
HealthImport/WorkoutEventsView.swift
Normal file
@@ -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)
|
||||
}
|
25
HealthImport/WorkoutMetadataView.swift
Normal file
25
HealthImport/WorkoutMetadataView.swift
Normal file
@@ -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)
|
||||
}
|
Reference in New Issue
Block a user