diff --git a/BeeKit/HeathKit/HealthKitConfig.swift b/BeeKit/HeathKit/HealthKitConfig.swift index 73ad41fa..33df8e64 100644 --- a/BeeKit/HeathKit/HealthKitConfig.swift +++ b/BeeKit/HeathKit/HealthKitConfig.swift @@ -57,6 +57,8 @@ public enum HealthKitConfig { // Other QuantityHealthKitMetric(humanText: "Time in Daylight", databaseString: "timeInDaylight", category: .Other, hkQuantityTypeIdentifier: .timeInDaylight), + ToothbrushingDailyMinutesHealthKitMetric(), + ToothbrushingDailySessionsHealthKitMetric(), ] } } diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift new file mode 100644 index 00000000..54cc518d --- /dev/null +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift @@ -0,0 +1,35 @@ +import Foundation +import HealthKit + +/// tracks toothbrushing, in number of (decimal) minutes per day (daystamp) +class ToothbrushingDailyMinutesHealthKitMetric: CategoryHealthKitMetric { + private static let healthkitMetric = ["toothbrushing", "minutes-per-day"].joined(separator: "|") + + init(humanText: String = "Teethbrushing (in minutes per day)", + databaseString: String = ToothbrushingDailyMinutesHealthKitMetric.healthkitMetric, + category: HealthKitCategory = .Other) { + super.init(humanText: humanText, + databaseString: databaseString, + category: category, + hkSampleType: HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)!) + } + + override func units(healthStore : HKHealthStore) async throws -> HKUnit { + HKUnit.second() + } + + override func valueInAppropriateUnits(rawValue: Double) -> Double { + // raw seconds into minutes + rawValue / 60 + } + + override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { + try await super.recentDataPoints(days: days, deadline: deadline, healthStore: healthStore) + .map { + NewDataPoint(requestid: $0.requestid, + daystamp: $0.daystamp, + value: $0.value, + comment: "Auto-entered via Apple Health (\(Self.healthkitMetric))") + } + } +} diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift new file mode 100755 index 00000000..4906c448 --- /dev/null +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift @@ -0,0 +1,86 @@ +// Part of BeeSwift. Copyright Beeminder + +import Foundation +import HealthKit + +/// tracks toothbrushing, in number of sessions per day (daystamp) +class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { + private static let healthkitMetric = ["toothbrushing", "sessions-per-day"].joined(separator: "|") + private static let hkSampleType = HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)! + + init(humanText: String = "Teethbrushing (in sessions per day)", + databaseString: String = ToothbrushingDailySessionsHealthKitMetric.healthkitMetric, + category: HealthKitCategory = .Other) { + super.init(humanText: humanText, + databaseString: databaseString, + category: category, + hkSampleType: Self.hkSampleType) + } + + override func units(healthStore : HKHealthStore) async throws -> HKUnit { + .count() + } + + override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { + let todayDaystamp = Daystamp.now(deadline: deadline) + let startDaystamp = todayDaystamp - days + + let predicate = HKQuery.predicateForSamples(withStart: startDaystamp.start(deadline: deadline), + end: todayDaystamp.end(deadline: deadline)) + + let samples = try await queryHealthStore(healthStore, predicate: predicate) + + let dailyCounts = calculateDailyCounts(samples: samples) + + let datapoints = makeDatapoints(dailyCounts: dailyCounts, deadline: deadline) + + return datapoints + } + + func queryHealthStore(_ healthStore: HKHealthStore, predicate: NSPredicate) async throws -> [HKCategorySample] { + try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation<[HKSample], Error>) in + let query = HKSampleQuery(sampleType: sampleType(), + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)], + resultsHandler: { (query, samples, error) in + if let error { + continuation.resume(throwing: error) + } else if let samples { + continuation.resume(returning: samples) + } else { + continuation.resume(throwing: HealthKitError("HKSampleQuery did not return samples")) + } + }) + healthStore.execute(query) + }) + .compactMap { $0 as? HKCategorySample } + } + + func calculateDailyCounts(samples: [HKCategorySample]) -> [(Date, Int)] { + let calendar = Calendar.autoupdatingCurrent + let groupedByDay = Dictionary(grouping: samples, by: { sample in + calendar.startOfDay(for: sample.startDate) + }) + + let dailyCounts = groupedByDay + .map { ($0, $1.count) } + .sorted { $0.0 < $1.0 } + + return dailyCounts + } + + func makeDatapoints(dailyCounts: [(Date, Int)], deadline: Int) -> [any BeeDataPoint] { + let datapoints = dailyCounts.map({ (date, numberOfEntries) in + let daystamp = Daystamp(fromDate: date, deadline: deadline) + let requestID = "apple-heath-" + daystamp.description + + return NewDataPoint(requestid: requestID, + daystamp: daystamp, + value: NSNumber(value: numberOfEntries), + comment: "Auto-entered via Apple Health (\(Self.healthkitMetric))") + }) + + return datapoints + } +} diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 1d26997c..6a3210ab 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 9B1CB1B02D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1CB1AF2D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift */; }; + 9B1CB1B12D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1CB1AE2D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift */; }; 9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */; }; 9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; }; 9BFB27E92CFE770F0056D10D /* FreshnessIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */; }; @@ -216,6 +218,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 9B1CB1AE2D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingDailyMinutesHealthKitMetric.swift; sourceTree = ""; }; + 9B1CB1AF2D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingDailySessionsHealthKitMetric.swift; sourceTree = ""; }; 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkGenerator.swift; sourceTree = ""; }; 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreshnessIndicatorView.swift; sourceTree = ""; }; @@ -407,6 +411,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9B7D44642D12C2F3003B62B1 /* Toothbrushing */ = { + isa = PBXGroup; + children = ( + 9B1CB1AE2D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift */, + 9B1CB1AF2D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift */, + ); + path = Toothbrushing; + sourceTree = ""; + }; A106AD8B1AF1F62800C434E8 /* Managers */ = { isa = PBXGroup; children = ( @@ -621,6 +634,7 @@ E4E6426E290E27CB004F3EA9 /* HeathKit */ = { isa = PBXGroup; children = ( + 9B7D44642D12C2F3003B62B1 /* Toothbrushing */, A1E618FF1E86980900D8ED93 /* HealthKitConfig.swift */, E4E642832910C442004F3EA9 /* CategoryHealthKitMetric.swift */, E4E642872910D055004F3EA9 /* MindfulSessionHealthKitMetric.swift */, @@ -1074,6 +1088,8 @@ E4B0A33128C194C900055EA7 /* AddDataIntents.intentdefinition in Sources */, E458C8132AD11C94000DCA5C /* HealthKitMetric.swift in Sources */, E458C80F2AD11C80000DCA5C /* HealthKitConfig.swift in Sources */, + 9B1CB1B02D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift in Sources */, + 9B1CB1B12D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift in Sources */, E458C8262AD11E12000DCA5C /* BSTextField.swift in Sources */, E458C8022AD11BB9000DCA5C /* CurrentUserManager.swift in Sources */, E458C8182AD11CAC000DCA5C /* TimeAsleepHealthKitMetric.swift in Sources */, diff --git a/BeeSwift/Settings/RemoveHKMetricViewController.swift b/BeeSwift/Settings/RemoveHKMetricViewController.swift index 29df4edc..9ab3b68f 100644 --- a/BeeSwift/Settings/RemoveHKMetricViewController.swift +++ b/BeeSwift/Settings/RemoveHKMetricViewController.swift @@ -48,7 +48,7 @@ class RemoveHKMetricViewController: UIViewController { attrString.append(NSMutableAttributedString(string: "\(self.goal.slug)\n", attributes: [NSAttributedString.Key.font: UIFont.beeminder.defaultBoldFont])) - attrString.append(NSMutableAttributedString(string: "This goal obtains its data from Apple Health (\(self.goal.humanizedAutodata!)). You can disconnect the goal with the button below.", + attrString.append(NSMutableAttributedString(string: "This goal obtains its data from Apple Health (\(self.goal.humanizedAutodata ?? self.goal.healthKitMetric ?? "unknown metric")). You can disconnect the goal with the button below.", attributes: [NSAttributedString.Key.font: UIFont.beeminder.defaultFontLight.withSize(Constants.defaultFontSize)])) return attrString }()