Skip to content

Commit 7c8ca7c

Browse files
committed
show when healthkit linked goal was last synced with healthkit
1 parent ff91af8 commit 7c8ca7c

6 files changed

Lines changed: 101 additions & 44 deletions

File tree

BeeKit/Managers/HealthStoreManager.swift

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,22 @@ import OSLog
1919
/// This does mean users who have very little buffer, and are not regularly unlocking their phone, may erroneously derail. There is nothing we
2020
/// can do about this.
2121
static let daysToUpdateOnChangeNotification = 7
22-
2322
private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "HealthStoreManager")
24-
2523
private let goalManager: GoalManager
26-
2724
// TODO: Public for now to use from config
2825
public let healthStore = HKHealthStore()
29-
3026
/// The Connection objects responsible for updating goals based on their healthkit metrics
3127
/// Dictionary key is the goal id, as this is stable across goal renames
3228
private nonisolated(unsafe) var monitors: [String: HealthKitMetricMonitor] = [:]
33-
3429
/// Protect concurrent modifications to the connections dictionary
3530
private nonisolated let monitorsSemaphore = DispatchSemaphore(value: 1)
36-
3731
init(goalManager: GoalManager, container: NSPersistentContainer) {
3832
self.goalManager = goalManager
3933
self.modelContainer = container
4034
let context = container.newBackgroundContext()
4135
context.name = "HealthStoreManager"
4236
self.modelExecutor = .init(context: context)
4337
}
44-
4538
/// Request acess to HealthKit data for the supplied metric
4639
///
4740
/// This function will throw an exception on a major failure. However, it will return silently if the user chooses
@@ -51,15 +44,13 @@ import OSLog
5144
logger.notice("requestAuthorization for \(metric.databaseString, privacy: .public)")
5245
try await self.healthStore.requestAuthorization(toShare: Set(), read: [metric.sampleType()])
5346
}
54-
5547
/// Start listening for background updates to the supplied goal if we are not already doing so
5648
public func ensureUpdatesRegularly(goalID: NSManagedObjectID) async throws {
5749
let goal = try modelContext.existingObject(with: goalID) as! Goal
5850
modelContext.refresh(goal, mergeChanges: false)
5951
guard let metricName = goal.healthKitMetric else { return }
6052
try await self.ensureUpdatesRegularly(metricNames: [metricName], removeMissing: false)
6153
}
62-
6354
/// Ensure we have background update listeners for all known goals such that they
6455
/// will be updated any time the health data changes.
6556
public func ensureGoalsUpdateRegularly() async throws {
@@ -68,21 +59,17 @@ import OSLog
6859
let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" }
6960
return try await ensureUpdatesRegularly(metricNames: metrics, removeMissing: true)
7061
}
71-
7262
/// Install observers for any goals we currently have permission to read
7363
///
7464
/// This function will never show a permissions dialog - instead it will not update for
7565
/// metrics where we do not have permission.
7666
public nonisolated func silentlyInstallObservers(context: NSManagedObjectContext) {
7767
logger.notice("Silently installing observer queries")
78-
7968
guard let goals = goalManager.staleGoals(context: context) else { return }
8069
let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" }
8170
let monitors = updateKnownMonitors(metricNames: metrics, removeMissing: true)
82-
8371
for monitor in monitors { monitor.registerObserverQuery() }
8472
}
85-
8673
/// Immediately update the supplied goal based on HealthKit's data record
8774
///
8875
/// Any existing beeminder records for the date range provided will be updated or deleted.
@@ -95,16 +82,13 @@ import OSLog
9582
try await updateWithRecentData(goal: goal, days: days)
9683
try await goalManager.refreshGoal(goalID)
9784
}
98-
9985
/// Immediately update all known goals based on HealthKit's data record
10086
public func updateAllGoalsWithRecentData(days: Int) async throws {
10187
logger.notice("Updating all goals with recent day for last \(days, privacy: .public) days")
102-
10388
// We must create this context in a backgrounfd thread as it will be used in background threads
10489
modelContext.refreshAllObjects()
10590
guard let goals = goalManager.staleGoals(context: modelContext) else { return }
10691
let goalsWithHealthData = goals.filter { $0.healthKitMetric != nil && $0.healthKitMetric != "" }
107-
10892
try await withThrowingTaskGroup(of: Void.self) { group in
10993
for goal in goalsWithHealthData {
11094
let goalID = goal.objectID
@@ -118,28 +102,20 @@ import OSLog
118102
}
119103
try await goalManager.refreshGoals()
120104
}
121-
122105
private func ensureUpdatesRegularly(metricNames: any Sequence<String>, removeMissing: Bool) async throws {
123106
let monitors = updateKnownMonitors(metricNames: metricNames, removeMissing: removeMissing)
124-
125107
var permissions = Set<HKObjectType>()
126108
for monitor in monitors { permissions.insert(monitor.metric.permissionType()) }
127-
if permissions.count > 0 {
128-
try await self.healthStore.requestAuthorization(toShare: Set(), read: permissions)
129-
130-
}
131-
109+
if permissions.count > 0 { try await self.healthStore.requestAuthorization(toShare: Set(), read: permissions) }
132110
try await withThrowingTaskGroup(of: Void.self) { group in
133111
for monitor in monitors { group.addTask { try await monitor.setupHealthKit() } }
134112
try await group.waitForAll()
135113
}
136114
}
137-
138115
private nonisolated func updateKnownMonitors(metricNames: any Sequence<String>, removeMissing: Bool)
139116
-> [HealthKitMetricMonitor]
140117
{
141118
monitorsSemaphore.wait()
142-
143119
for metricName in metricNames {
144120
if monitors[metricName] == nil {
145121
guard let metric = HealthKitConfig.metrics.first(where: { $0.databaseString == metricName }) else {
@@ -155,7 +131,6 @@ import OSLog
155131
)
156132
}
157133
}
158-
159134
if removeMissing {
160135
for (metricName, monitor) in monitors {
161136
if !metricNames.contains(metricName) {
@@ -164,13 +139,10 @@ import OSLog
164139
}
165140
}
166141
}
167-
168142
let requestedMonitors = metricNames.compactMap { monitors[$0] }
169-
170143
monitorsSemaphore.signal()
171144
return requestedMonitors
172145
}
173-
174146
private func updateGoalsForMetricChange(metricName: String, metric: HealthKitMetric) async {
175147
do {
176148
modelContext.refreshAllObjects()
@@ -180,13 +152,11 @@ import OSLog
180152
logger.notice("Received an update for metric \(metricName, privacy: .public) but no goals using it")
181153
return
182154
}
183-
184155
for goal in goalsForMetric {
185156
try await self.updateWithRecentData(goal: goal, days: HealthStoreManager.daysToUpdateOnChangeNotification)
186157
}
187158
} catch { logger.error("Error updating goals for metric change: \(error, privacy: .public)") }
188159
}
189-
190160
private func updateWithRecentData(goal: Goal, days: Int) async throws {
191161
guard let metric = HealthKitConfig.metrics.first(where: { $0.databaseString == goal.healthKitMetric }) else {
192162
throw HealthKitError("No metric found for goal \(goal.slug) with metric \(goal.healthKitMetric ?? "nil")")
@@ -206,5 +176,9 @@ import OSLog
206176
goalID: goal.objectID,
207177
healthKitDataPoints: nonZeroDataPoints
208178
)
179+
180+
let goal = try self.modelContext.existingObject(with: goal.objectID) as! Goal
181+
goal.lastSyncedWithHealthKitLocal = Date()
182+
try self.modelContext.save()
209183
}
210184
}

BeeKit/Managers/RequestManager.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@ public class RequestManager {
7070
logger.error("Error issuing request \(url): \(error, privacy: .public)")
7171

7272
// Log out the user on an unauthorized response
73-
if case .responseValidationFailed(let reason) = error {
74-
if case .unacceptableStatusCode(let code) = reason {
75-
if code == 401 { try? await ServiceLocator.currentUserManager.signOut() }
76-
}
77-
}
73+
// if case .responseValidationFailed(let reason) = error {
74+
// if case .unacceptableStatusCode(let code) = reason {
75+
// if code == 401 { try? await ServiceLocator.currentUserManager.signOut() }
76+
// }
77+
// }
7878

7979
// If we receive an error message from the server use it as our user-visible error
8080
if let data = response.data, let errorMessage = try JSON(data: data)["error_message"].string {

BeeKit/Model/BeeminderModel.xcdatamodeld/.xccurrentversion

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
<plist version="1.0">
44
<dict>
55
<key>_XCCurrentVersionName</key>
6-
<string>BeeminderModel3.xcdatamodel</string>
6+
<string>BeeminderModel4.xcdatamodel</string>
77
</dict>
88
</plist>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2+
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25C56" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
3+
<entity name="DataPoint" representedClassName="DataPoint" syncable="YES">
4+
<attribute name="comment" optional="YES" attributeType="String"/>
5+
<attribute name="daystampRaw" attributeType="String"/>
6+
<attribute name="id" attributeType="String"/>
7+
<attribute name="isDummy" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8+
<attribute name="isInitial" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9+
<attribute name="lastUpdatedLocal" optional="YES" attributeType="Date" defaultDateTimeInterval="-978307200" usesScalarValueType="NO" elementID="lastUpdatedLocal">
10+
<userInfo>
11+
<entry key="renamingIdentifier" value="lastModifiedLocal"/>
12+
</userInfo>
13+
</attribute>
14+
<attribute name="requestid" optional="YES" attributeType="String"/>
15+
<attribute name="updatedAt" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
16+
<attribute name="value" attributeType="Decimal" defaultValueString="0.0"/>
17+
<relationship name="goal" maxCount="1" deletionRule="Nullify" destinationEntity="Goal" inverseName="data" inverseEntity="Goal"/>
18+
</entity>
19+
<entity name="Goal" representedClassName="Goal" syncable="YES" coreSpotlightDisplayNameExpression="slug">
20+
<attribute name="alertStart" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
21+
<attribute name="autodata" optional="YES" attributeType="String"/>
22+
<attribute name="autodataConfig" optional="YES" attributeType="Transformable" customClassName="NSDictionary"/>
23+
<attribute name="colorkey" attributeType="String" defaultValueString="gray"/>
24+
<attribute name="deadline" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
25+
<attribute name="dueBy" optional="YES" attributeType="Transformable" valueTransformerName="DueByTableValueTransformer" customClassName="NSDictionary"/>
26+
<attribute name="graphUrl" attributeType="String"/>
27+
<attribute name="healthKitMetric" optional="YES" attributeType="String"/>
28+
<attribute name="hhmmFormat" attributeType="Boolean" usesScalarValueType="YES"/>
29+
<attribute name="id" attributeType="String"/>
30+
<attribute name="initDay" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
31+
<attribute name="lastSyncedWithHealthKitLocal" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32+
<attribute name="lastTouch" attributeType="String"/>
33+
<attribute name="lastUpdatedLocal" optional="YES" attributeType="Date" defaultDateTimeInterval="-978307200" usesScalarValueType="NO" elementID="lastUpdatedLocal">
34+
<userInfo>
35+
<entry key="renamingIdentifier" value="lastModifiedLocal"/>
36+
</userInfo>
37+
</attribute>
38+
<attribute name="leadTime" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
39+
<attribute name="limSum" attributeType="String"/>
40+
<attribute name="pledge" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
41+
<attribute name="queued" attributeType="Boolean" usesScalarValueType="YES"/>
42+
<attribute name="safeBuf" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
43+
<attribute name="safeSum" attributeType="String"/>
44+
<attribute name="slug" attributeType="String" spotlightIndexingEnabled="YES"/>
45+
<attribute name="thumbUrl" attributeType="String"/>
46+
<attribute name="title" attributeType="String" spotlightIndexingEnabled="YES"/>
47+
<attribute name="todayta" attributeType="Boolean" usesScalarValueType="YES"/>
48+
<attribute name="urgencyKey" attributeType="String"/>
49+
<attribute name="useDefaults" attributeType="Boolean" usesScalarValueType="YES"/>
50+
<attribute name="won" attributeType="Boolean" usesScalarValueType="YES"/>
51+
<attribute name="yAxis" attributeType="String"/>
52+
<relationship name="data" toMany="YES" deletionRule="Cascade" destinationEntity="DataPoint" inverseName="goal" inverseEntity="DataPoint"/>
53+
<relationship name="owner" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="User" inverseName="goals" inverseEntity="User"/>
54+
<relationship name="recentData" toMany="YES" deletionRule="Nullify" destinationEntity="DataPoint"/>
55+
</entity>
56+
<entity name="User" representedClassName=".User" syncable="YES">
57+
<attribute name="deadbeat" attributeType="Boolean" usesScalarValueType="NO"/>
58+
<attribute name="defaultAlertStart" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
59+
<attribute name="defaultDeadline" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
60+
<attribute name="defaultLeadTime" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
61+
<attribute name="lastFetchedModelVersionLocal" optional="YES" attributeType="String"/>
62+
<attribute name="lastUpdatedLocal" optional="YES" attributeType="Date" defaultDateTimeInterval="-978307200" usesScalarValueType="NO" elementID="lastUpdatedLocal">
63+
<userInfo>
64+
<entry key="renamingIdentifier" value="lastModifiedLocal"/>
65+
</userInfo>
66+
</attribute>
67+
<attribute name="timezone" attributeType="String"/>
68+
<attribute name="updatedAt" attributeType="Date" defaultDateTimeInterval="-978278400" usesScalarValueType="YES"/>
69+
<attribute name="username" attributeType="String"/>
70+
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="owner" inverseEntity="Goal"/>
71+
</entity>
72+
</model>

BeeKit/Model/Goal.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import SwiftyJSON
6363

6464
/// The last time this record in the CoreData store was updated
6565
@NSManaged public var lastUpdatedLocal: Date
66+
@NSManaged public var lastSyncedWithHealthKitLocal: Date?
6667

6768
public init(
6869
context: NSManagedObjectContext,
@@ -119,6 +120,7 @@ import SwiftyJSON
119120
self.yAxis = yAxis
120121

121122
lastUpdatedLocal = Date()
123+
lastSyncedWithHealthKitLocal = nil
122124
}
123125

124126
public init(context: NSManagedObjectContext, owner: User, json: JSON) {
@@ -127,6 +129,7 @@ import SwiftyJSON
127129
self.owner = owner
128130
self.id = json["id"].string!
129131

132+
lastSyncedWithHealthKitLocal = nil
130133
self.updateToMatch(json: json)
131134
}
132135

BeeSwift/GoalView/GoalViewController.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable
5353

5454
fileprivate var scrollView = UIScrollView()
5555
fileprivate var submitButton = BSButton()
56+
private let pullToRefreshView = PullToRefreshView()
5657
fileprivate let headerWidth = Double(1.0 / 3.0)
5758

5859
// date corresponding to the datapoint to be created
@@ -308,14 +309,8 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable
308309
}
309310

310311
if self.goal.isDataProvidedAutomatically {
311-
let pullToRefreshView = PullToRefreshView()
312312
scrollView.addSubview(pullToRefreshView)
313-
314-
if self.goal.isLinkedToHealthKit {
315-
pullToRefreshView.message = "Pull down to synchronize with Apple Health"
316-
} else {
317-
pullToRefreshView.message = "Pull down to update"
318-
}
313+
refreshPullDown()
319314

320315
pullToRefreshView.snp.makeConstraints { (make) in
321316
make.top.equalTo(self.datapointTableController.view.snp.bottom).offset(elementSpacing)
@@ -396,6 +391,19 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable
396391
self.countdownLabel.textColor = self.goal.countdownColor
397392
self.countdownLabel.text = self.goal.capitalSafesum()
398393
}
394+
private func refreshPullDown() {
395+
let lastSynced: String = {
396+
guard let lastSyncedWithHealthKit = goal.lastSyncedWithHealthKitLocal else { return "not yet" }
397+
let isSameDay = Calendar.autoupdatingCurrent.isDate(lastSyncedWithHealthKit, inSameDayAs: .now)
398+
let dateStyle: Date.FormatStyle.DateStyle = isSameDay ? .omitted : .numeric
399+
return lastSyncedWithHealthKit.formatted(date: dateStyle, time: .shortened)
400+
}()
401+
if self.goal.isLinkedToHealthKit {
402+
pullToRefreshView.message = "Pull down to synchronize with Apple Health" + "\n" + "Last synced: \(lastSynced)"
403+
} else {
404+
pullToRefreshView.message = "Pull down to update"
405+
}
406+
}
399407

400408
@objc func goalImageTapped() {
401409
self.goalImageScrollView.setZoomScale(self.goalImageScrollView.zoomScale == 1.0 ? 2.0 : 1.0, animated: true)

0 commit comments

Comments
 (0)