From cec17696241fd00dbfafdb67b0826deed1ba056c Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Thu, 12 Dec 2024 00:36:37 +0100 Subject: [PATCH] show when healthkit linked goal was last synced with healthkit --- BeeKit/Managers/HealthStoreManager.swift | 36 ++-------- .../.xccurrentversion | 2 +- .../BeeminderModel4.xcdatamodel/contents | 72 +++++++++++++++++++ BeeKit/Model/Goal.swift | 3 + BeeSwift/GoalView/GoalViewController.swift | 22 ++++-- 5 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel4.xcdatamodel/contents diff --git a/BeeKit/Managers/HealthStoreManager.swift b/BeeKit/Managers/HealthStoreManager.swift index 7dab19f62..e477550f6 100644 --- a/BeeKit/Managers/HealthStoreManager.swift +++ b/BeeKit/Managers/HealthStoreManager.swift @@ -19,21 +19,15 @@ import OSLog /// This does mean users who have very little buffer, and are not regularly unlocking their phone, may erroneously derail. There is nothing we /// can do about this. static let daysToUpdateOnChangeNotification = 7 - private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "HealthStoreManager") - private let goalManager: GoalManager - // TODO: Public for now to use from config public let healthStore = HKHealthStore() - /// The Connection objects responsible for updating goals based on their healthkit metrics /// Dictionary key is the goal id, as this is stable across goal renames private nonisolated(unsafe) var monitors: [String: HealthKitMetricMonitor] = [:] - /// Protect concurrent modifications to the connections dictionary private nonisolated let monitorsSemaphore = DispatchSemaphore(value: 1) - init(goalManager: GoalManager, container: NSPersistentContainer) { self.goalManager = goalManager self.modelContainer = container @@ -41,7 +35,6 @@ import OSLog context.name = "HealthStoreManager" self.modelExecutor = .init(context: context) } - /// Request acess to HealthKit data for the supplied metric /// /// This function will throw an exception on a major failure. However, it will return silently if the user chooses @@ -51,7 +44,6 @@ import OSLog logger.notice("requestAuthorization for \(metric.databaseString, privacy: .public)") try await self.healthStore.requestAuthorization(toShare: Set(), read: [metric.sampleType()]) } - /// Start listening for background updates to the supplied goal if we are not already doing so public func ensureUpdatesRegularly(goalID: NSManagedObjectID) async throws { let goal = try modelContext.existingObject(with: goalID) as! Goal @@ -59,7 +51,6 @@ import OSLog guard let metricName = goal.healthKitMetric else { return } try await self.ensureUpdatesRegularly(metricNames: [metricName], removeMissing: false) } - /// Ensure we have background update listeners for all known goals such that they /// will be updated any time the health data changes. public func ensureGoalsUpdateRegularly() async throws { @@ -68,21 +59,17 @@ import OSLog let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" } return try await ensureUpdatesRegularly(metricNames: metrics, removeMissing: true) } - /// Install observers for any goals we currently have permission to read /// /// This function will never show a permissions dialog - instead it will not update for /// metrics where we do not have permission. public nonisolated func silentlyInstallObservers(context: NSManagedObjectContext) { logger.notice("Silently installing observer queries") - guard let goals = goalManager.staleGoals(context: context) else { return } let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" } let monitors = updateKnownMonitors(metricNames: metrics, removeMissing: true) - for monitor in monitors { monitor.registerObserverQuery() } } - /// Immediately update the supplied goal based on HealthKit's data record /// /// Any existing beeminder records for the date range provided will be updated or deleted. @@ -95,16 +82,13 @@ import OSLog try await updateWithRecentData(goal: goal, days: days) try await goalManager.refreshGoal(goalID) } - /// Immediately update all known goals based on HealthKit's data record public func updateAllGoalsWithRecentData(days: Int) async throws { logger.notice("Updating all goals with recent day for last \(days, privacy: .public) days") - // We must create this context in a backgrounfd thread as it will be used in background threads modelContext.refreshAllObjects() guard let goals = goalManager.staleGoals(context: modelContext) else { return } let goalsWithHealthData = goals.filter { $0.healthKitMetric != nil && $0.healthKitMetric != "" } - try await withThrowingTaskGroup(of: Void.self) { group in for goal in goalsWithHealthData { let goalID = goal.objectID @@ -118,28 +102,20 @@ import OSLog } try await goalManager.refreshGoals() } - private func ensureUpdatesRegularly(metricNames: any Sequence, removeMissing: Bool) async throws { let monitors = updateKnownMonitors(metricNames: metricNames, removeMissing: removeMissing) - var permissions = Set() for monitor in monitors { permissions.insert(monitor.metric.permissionType()) } - if permissions.count > 0 { - try await self.healthStore.requestAuthorization(toShare: Set(), read: permissions) - - } - + if permissions.count > 0 { try await self.healthStore.requestAuthorization(toShare: Set(), read: permissions) } try await withThrowingTaskGroup(of: Void.self) { group in for monitor in monitors { group.addTask { try await monitor.setupHealthKit() } } try await group.waitForAll() } } - private nonisolated func updateKnownMonitors(metricNames: any Sequence, removeMissing: Bool) -> [HealthKitMetricMonitor] { monitorsSemaphore.wait() - for metricName in metricNames { if monitors[metricName] == nil { guard let metric = HealthKitConfig.metrics.first(where: { $0.databaseString == metricName }) else { @@ -155,7 +131,6 @@ import OSLog ) } } - if removeMissing { for (metricName, monitor) in monitors { if !metricNames.contains(metricName) { @@ -164,13 +139,10 @@ import OSLog } } } - let requestedMonitors = metricNames.compactMap { monitors[$0] } - monitorsSemaphore.signal() return requestedMonitors } - private func updateGoalsForMetricChange(metricName: String, metric: HealthKitMetric) async { do { modelContext.refreshAllObjects() @@ -180,13 +152,11 @@ import OSLog logger.notice("Received an update for metric \(metricName, privacy: .public) but no goals using it") return } - for goal in goalsForMetric { try await self.updateWithRecentData(goal: goal, days: HealthStoreManager.daysToUpdateOnChangeNotification) } } catch { logger.error("Error updating goals for metric change: \(error, privacy: .public)") } } - private func updateWithRecentData(goal: Goal, days: Int) async throws { guard let metric = HealthKitConfig.metrics.first(where: { $0.databaseString == goal.healthKitMetric }) else { throw HealthKitError("No metric found for goal \(goal.slug) with metric \(goal.healthKitMetric ?? "nil")") @@ -206,5 +176,9 @@ import OSLog goalID: goal.objectID, healthKitDataPoints: nonZeroDataPoints ) + + let goal = try self.modelContext.existingObject(with: goal.objectID) as! Goal + goal.lastSyncedWithHealthKitLocal = Date() + try self.modelContext.save() } } diff --git a/BeeKit/Model/BeeminderModel.xcdatamodeld/.xccurrentversion b/BeeKit/Model/BeeminderModel.xcdatamodeld/.xccurrentversion index 818821388..d8882f156 100644 --- a/BeeKit/Model/BeeminderModel.xcdatamodeld/.xccurrentversion +++ b/BeeKit/Model/BeeminderModel.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - BeeminderModel3.xcdatamodel + BeeminderModel4.xcdatamodel diff --git a/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel4.xcdatamodel/contents b/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel4.xcdatamodel/contents new file mode 100644 index 000000000..2b8b8d3f9 --- /dev/null +++ b/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel4.xcdatamodel/contents @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BeeKit/Model/Goal.swift b/BeeKit/Model/Goal.swift index b2a4086e7..868e24a12 100644 --- a/BeeKit/Model/Goal.swift +++ b/BeeKit/Model/Goal.swift @@ -63,6 +63,7 @@ import SwiftyJSON /// The last time this record in the CoreData store was updated @NSManaged public var lastUpdatedLocal: Date + @NSManaged public var lastSyncedWithHealthKitLocal: Date? public init( context: NSManagedObjectContext, @@ -119,6 +120,7 @@ import SwiftyJSON self.yAxis = yAxis lastUpdatedLocal = Date() + lastSyncedWithHealthKitLocal = nil } public init(context: NSManagedObjectContext, owner: User, json: JSON) { @@ -127,6 +129,7 @@ import SwiftyJSON self.owner = owner self.id = json["id"].string! + lastSyncedWithHealthKitLocal = nil self.updateToMatch(json: json) } diff --git a/BeeSwift/GoalView/GoalViewController.swift b/BeeSwift/GoalView/GoalViewController.swift index 67083c4e6..04421f7bb 100644 --- a/BeeSwift/GoalView/GoalViewController.swift +++ b/BeeSwift/GoalView/GoalViewController.swift @@ -53,6 +53,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable fileprivate var scrollView = UIScrollView() fileprivate var submitButton = BSButton() + private let pullToRefreshView = PullToRefreshView() fileprivate let headerWidth = Double(1.0 / 3.0) // date corresponding to the datapoint to be created @@ -308,14 +309,8 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable } if self.goal.isDataProvidedAutomatically { - let pullToRefreshView = PullToRefreshView() scrollView.addSubview(pullToRefreshView) - - if self.goal.isLinkedToHealthKit { - pullToRefreshView.message = "Pull down to synchronize with Apple Health" - } else { - pullToRefreshView.message = "Pull down to update" - } + refreshPullDown() pullToRefreshView.snp.makeConstraints { (make) in make.top.equalTo(self.datapointTableController.view.snp.bottom).offset(elementSpacing) @@ -396,6 +391,19 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable self.countdownLabel.textColor = self.goal.countdownColor self.countdownLabel.text = self.goal.capitalSafesum() } + private func refreshPullDown() { + let lastSynced: String = { + guard let lastSyncedWithHealthKit = goal.lastSyncedWithHealthKitLocal else { return "not yet" } + let isSameDay = Calendar.autoupdatingCurrent.isDate(lastSyncedWithHealthKit, inSameDayAs: .now) + let dateStyle: Date.FormatStyle.DateStyle = isSameDay ? .omitted : .numeric + return lastSyncedWithHealthKit.formatted(date: dateStyle, time: .shortened) + }() + if self.goal.isLinkedToHealthKit { + pullToRefreshView.message = "Pull down to synchronize with Apple Health" + "\n" + "Last synced: \(lastSynced)" + } else { + pullToRefreshView.message = "Pull down to update" + } + } @objc func goalImageTapped() { self.goalImageScrollView.setZoomScale(self.goalImageScrollView.zoomScale == 1.0 ? 2.0 : 1.0, animated: true)