From 0448f19a74a18cc54784499b62d3efda73c98138 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 26 Jan 2020 23:28:08 -0600 Subject: [PATCH] Denote stale glucose as three dashes --- Common/Models/WatchContext.swift | 12 +++ .../StatusViewController.swift | 3 +- Loop.xcodeproj/project.pbxproj | 22 +++-- Loop/Managers/LoopDataManager.swift | 18 ++-- Loop/Managers/WatchDataManager.swift | 12 +++ .../StatusTableViewController.swift | 1 + LoopCore/LoopCompletionFreshness.swift | 57 +++++++++++ LoopCore/LoopSettings.swift | 9 +- LoopUI/Views/GlucoseHUDView.swift | 10 +- LoopUI/Views/LoopCompletionHUDView.swift | 23 +---- .../ComplicationController.swift | 76 ++++++++++----- .../Controllers/HUDInterfaceController.swift | 8 +- .../Extensions/CLKComplicationTemplate.swift | 94 +++++++++++++++---- WatchApp Extension/Extensions/UIColor.swift | 6 +- .../Managers/LoopDataManager.swift | 5 +- 15 files changed, 269 insertions(+), 87 deletions(-) create mode 100644 LoopCore/LoopCompletionFreshness.swift diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 67f6c603d4..19625b6b38 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -21,6 +21,7 @@ final class WatchContext: RawRepresentable { var glucose: HKQuantity? var glucoseTrendRawValue: Int? var glucoseDate: Date? + var glucoseSyncIdentifier: String? var predictedGlucose: WatchPredictedGlucose? var eventualGlucose: HKQuantity? { @@ -58,6 +59,7 @@ final class WatchContext: RawRepresentable { glucoseTrendRawValue = rawValue["gt"] as? Int glucoseDate = rawValue["gd"] as? Date + glucoseSyncIdentifier = rawValue["gs"] as? String iob = rawValue["iob"] as? Double reservoir = rawValue["r"] as? Double reservoirPercentage = rawValue["rp"] as? Double @@ -95,6 +97,7 @@ final class WatchContext: RawRepresentable { raw["gt"] = glucoseTrendRawValue raw["gd"] = glucoseDate + raw["gs"] = glucoseSyncIdentifier raw["iob"] = iob raw["ld"] = loopLastRunDate raw["r"] = reservoir @@ -117,3 +120,12 @@ extension WatchContext { } } } + +extension WatchContext { + var newGlucoseSample: NewGlucoseSample? { + if let quantity = glucose, let date = glucoseDate, let syncIdentifier = glucoseSyncIdentifier { + return NewGlucoseSample(date: date, quantity: quantity, isDisplayOnly: false, syncIdentifier: syncIdentifier, syncVersion: 0) + } + return nil + } +} diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift index f009729b5d..44fc0793b0 100644 --- a/Loop Status Extension/StatusViewController.swift +++ b/Loop Status Extension/StatusViewController.swift @@ -256,11 +256,12 @@ class StatusViewController: UIViewController, NCWidgetProviding { return } - if let lastGlucose = glucose.last { + if let lastGlucose = glucose.last, let recencyInterval = defaults.loopSettings?.inputDataRecencyInterval { self.hudView.glucoseHUD.setGlucoseQuantity( lastGlucose.quantity.doubleValue(for: unit), at: lastGlucose.startDate, unit: unit, + staleGlucoseAge: recencyInterval, sensor: context.sensor ) } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6524cbd5eb..3a6e213bb0 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -375,6 +375,8 @@ C17824A61E1AF91F00D9D25C /* BolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* BolusRecommendation.swift */; }; C1814B86225E507C008D2D8E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1814B85225E507C008D2D8E /* Sequence.swift */; }; C18C8C511D5A351900E043FB /* NightscoutDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */; }; + C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; + C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; C1A3EED2235233E1007672E3 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C1A3EED1235233E1007672E3 /* DerivedAssets.xcassets */; }; C1A3EED423523551007672E3 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C1A3EED323523551007672E3 /* DerivedAssets.xcassets */; }; C1A3EED523535FFF007672E3 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 894F71E11FFEC4D8007D365C /* DefaultAssets.xcassets */; }; @@ -1087,6 +1089,7 @@ C18A491422FCC22900FDA733 /* build-derived-watch-assets.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "build-derived-watch-assets.sh"; sourceTree = ""; }; C18A491522FCC22900FDA733 /* copy-plugins.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "copy-plugins.sh"; sourceTree = ""; }; C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutDataManager.swift; sourceTree = ""; }; + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshness.swift; sourceTree = ""; }; C1A3EED1235233E1007672E3 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; C1A3EED323523551007672E3 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; C1C6591B1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_dropping_then_rising.json; sourceTree = ""; }; @@ -1517,6 +1520,7 @@ 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, 430B298C2041F56500BA9F93 /* LoopSettings.swift */, + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, 43D848AF1E7DCBE100DADCBC /* Result.swift */, @@ -2790,6 +2794,7 @@ files = ( 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, + C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, 431EA87221EB29150076EC1A /* InsulinModelSettings.swift in Sources */, @@ -2842,6 +2847,7 @@ files = ( 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, + C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, 431EA87321EB29160076EC1A /* InsulinModelSettings.swift in Sources */, @@ -3531,7 +3537,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; ENABLE_BITCODE = YES; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -3550,7 +3556,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; ENABLE_BITCODE = YES; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -3568,7 +3574,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/watchOS"; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -3591,7 +3597,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/watchOS"; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -3612,7 +3618,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/watchOS"; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -3633,7 +3639,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/watchOS"; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -3913,7 +3919,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -3935,7 +3941,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = UY678SP37Q; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 987e0cf25d..e02ce7174d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -726,7 +726,7 @@ extension LoopDataManager { // Fetch glucose effects as far back as we want to make retroactive analysis var latestGlucoseDate: Date? updateGroup.enter() - glucoseStore.getCachedGlucoseSamples(start: Date(timeIntervalSinceNow: -settings.recencyInterval)) { (values) in + glucoseStore.getCachedGlucoseSamples(start: Date(timeIntervalSinceNow: -settings.inputDataRecencyInterval)) { (values) in latestGlucoseDate = values.last?.startDate updateGroup.leave() } @@ -917,11 +917,11 @@ extension LoopDataManager { let lastGlucoseDate = glucose.startDate let now = Date() - guard now.timeIntervalSince(lastGlucoseDate) <= settings.recencyInterval else { + guard now.timeIntervalSince(lastGlucoseDate) <= settings.inputDataRecencyInterval else { throw LoopError.glucoseTooOld(date: glucose.startDate) } - guard now.timeIntervalSince(pumpStatusDate) <= settings.recencyInterval else { + guard now.timeIntervalSince(pumpStatusDate) <= settings.inputDataRecencyInterval else { throw LoopError.pumpDataTooOld(date: pumpStatusDate) } @@ -1015,11 +1015,11 @@ extension LoopDataManager { let lastGlucoseDate = glucose.startDate let now = Date() - guard now.timeIntervalSince(lastGlucoseDate) <= settings.recencyInterval else { + guard now.timeIntervalSince(lastGlucoseDate) <= settings.inputDataRecencyInterval else { throw LoopError.glucoseTooOld(date: glucose.startDate) } - guard now.timeIntervalSince(pumpStatusDate) <= settings.recencyInterval else { + guard now.timeIntervalSince(pumpStatusDate) <= settings.inputDataRecencyInterval else { throw LoopError.pumpDataTooOld(date: pumpStatusDate) } @@ -1112,7 +1112,7 @@ extension LoopDataManager { retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect( startingAt: glucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: settings.recencyInterval, + recencyInterval: settings.inputDataRecencyInterval, insulinSensitivitySchedule: insulinSensitivitySchedule, basalRateSchedule: basalRateSchedule, glucoseCorrectionRangeSchedule: settings.glucoseTargetRangeSchedule, @@ -1126,7 +1126,7 @@ extension LoopDataManager { return retrospectiveCorrection.computeEffect( startingAt: glucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: settings.recencyInterval, + recencyInterval: settings.inputDataRecencyInterval, insulinSensitivitySchedule: insulinSensitivitySchedule, basalRateSchedule: basalRateSchedule, glucoseCorrectionRangeSchedule: settings.glucoseTargetRangeSchedule, @@ -1155,12 +1155,12 @@ extension LoopDataManager { let startDate = Date() - guard startDate.timeIntervalSince(glucose.startDate) <= settings.recencyInterval else { + guard startDate.timeIntervalSince(glucose.startDate) <= settings.inputDataRecencyInterval else { self.predictedGlucose = nil throw LoopError.glucoseTooOld(date: glucose.startDate) } - guard startDate.timeIntervalSince(pumpStatusDate) <= settings.recencyInterval else { + guard startDate.timeIntervalSince(pumpStatusDate) <= settings.inputDataRecencyInterval else { self.predictedGlucose = nil throw LoopError.pumpDataTooOld(date: pumpStatusDate) } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 6c8d304d5e..ffeee3a2ba 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -211,6 +211,18 @@ final class WatchDataManager: NSObject { if let trend = self.deviceManager.cgmManager?.sensorState?.trendType { context.glucoseTrendRawValue = trend.rawValue } + + if let glucose = glucose { + updateGroup.enter() + manager.glucoseStore.getCachedGlucoseSamples(start: glucose.startDate) { (samples) in + if let sample = samples.last { + context.glucose = sample.quantity + context.glucoseDate = sample.startDate + context.glucoseSyncIdentifier = sample.syncIdentifier + } + updateGroup.leave() + } + } updateGroup.enter() manager.doseStore.insulinOnBoard(at: Date()) { (result) in diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 0eb5becb43..7203e5efa1 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -487,6 +487,7 @@ final class StatusTableViewController: ChartsTableViewController { hudView.glucoseHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), at: glucose.startDate, unit: unit, + staleGlucoseAge: self.deviceManager.loopManager.settings.inputDataRecencyInterval, sensor: self.deviceManager.sensorState ) } diff --git a/LoopCore/LoopCompletionFreshness.swift b/LoopCore/LoopCompletionFreshness.swift new file mode 100644 index 0000000000..8da8d0e2b6 --- /dev/null +++ b/LoopCore/LoopCompletionFreshness.swift @@ -0,0 +1,57 @@ +// +// LoopCompletionFreshness.swift +// Loop +// +// Created by Pete Schwamb on 1/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum LoopCompletionFreshness { + case fresh + case aging + case stale + case unknown + + public var maxAge: TimeInterval? { + switch self { + case .fresh: + return TimeInterval(minutes: 6) + case .aging: + return TimeInterval(minutes: 16) + case .stale: + return TimeInterval(hours: 12) + case .unknown: + return nil + } + } + + public init(age: TimeInterval?) { + guard let age = age else { + self = .unknown + return + } + + switch age { + case let t where t <= LoopCompletionFreshness.fresh.maxAge!: + self = .fresh + case let t where t <= LoopCompletionFreshness.aging.maxAge!: + self = .aging + case let t where t <= LoopCompletionFreshness.stale.maxAge!: + self = .stale + default: + self = .unknown + } + } + + public init(lastCompletion: Date?, at date: Date = Date()) { + guard let lastCompletion = lastCompletion else { + self = .unknown + return + } + + self = LoopCompletionFreshness(age: date.timeIntervalSince(lastCompletion)) + } + +} diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index b524e3495c..2b23925b33 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -36,8 +36,13 @@ public struct LoopSettings: Equatable { /// The interval over which to aggregate changes in glucose for retrospective correction public let retrospectiveCorrectionGroupingInterval = TimeInterval(minutes: 30) - /// The amount of time since a given date that data should be considered valid - public let recencyInterval = TimeInterval(minutes: 15) + /// The amount of time since a given date that input data should be considered valid + public let inputDataRecencyInterval = TimeInterval(minutes: 15) + + /// Loop completion aging category limits + public let completionFreshLimit = TimeInterval(minutes: 6) + public let completionAgingLimit = TimeInterval(minutes: 16) + public let completionStaleLimit = TimeInterval(hours: 12) public let batteryReplacementDetectionThreshold = 0.5 diff --git a/LoopUI/Views/GlucoseHUDView.swift b/LoopUI/Views/GlucoseHUDView.swift index c5380db1c9..fae00b8bb8 100644 --- a/LoopUI/Views/GlucoseHUDView.swift +++ b/LoopUI/Views/GlucoseHUDView.swift @@ -91,27 +91,27 @@ public final class GlucoseHUDView: BaseHUDView { } } - public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, sensor: SensorDisplayable?) { + public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, staleGlucoseAge: TimeInterval, sensor: SensorDisplayable?) { var accessibilityStrings = [String]() let time = timeFormatter.string(from: glucoseStartDate) caption?.text = time - let sensorDataCurrent = glucoseStartDate.timeIntervalSinceNow > TimeInterval(minutes: -15) + let glucoseValueCurrent = glucoseStartDate.timeIntervalSinceNow > -staleGlucoseAge let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) if let valueString = numberFormatter.string(from: glucoseQuantity) { - if sensorDataCurrent { + if glucoseValueCurrent { glucoseLabel.text = valueString } else { - glucoseLabel.text = "-" + glucoseLabel.text = "---" } accessibilityStrings.append(String(format: LocalizedString("%1$@ at %2$@", comment: "Accessbility format value describing glucose: (1: glucose number)(2: glucose time)"), valueString, time)) } var unitStrings = [unit.localizedShortUnitString] - if let trend = sensor?.trendType, sensorDataCurrent { + if let trend = sensor?.trendType, glucoseValueCurrent { unitStrings.append(trend.symbol) accessibilityStrings.append(trend.localizedDescription) } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 1f62d14dde..705daec9d7 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -8,6 +8,7 @@ import UIKit import LoopKitUI +import LoopCore public final class LoopCompletionHUDView: BaseHUDView { @@ -17,14 +18,7 @@ public final class LoopCompletionHUDView: BaseHUDView { return 1 } - enum Freshness { - case fresh - case aging - case stale - case unknown - } - - private(set) var freshness = Freshness.unknown { + private(set) var freshness = LoopCompletionFreshness.unknown { didSet { updateTintColor() } @@ -128,17 +122,8 @@ public final class LoopCompletionHUDView: BaseHUDView { @objc private func updateDisplay(_: Timer?) { if let date = lastLoopCompleted { let ago = abs(min(0, date.timeIntervalSinceNow)) - - switch ago { - case let t where t <= .minutes(6): - freshness = .fresh - case let t where t <= .minutes(16): - freshness = .aging - case let t where t <= .hours(12): - freshness = .stale - default: - freshness = .unknown - } + + freshness = LoopCompletionFreshness(age: ago) if let timeString = formatter.string(from: ago) { switch traitCollection.preferredContentSizeCategory { diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index cab92ddae8..7e1086cab3 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -8,10 +8,13 @@ import ClockKit import WatchKit - +import LoopCore +import os.log final class ComplicationController: NSObject, CLKComplicationDataSource { + private let log = OSLog(category: "ComplicationController") + // MARK: - Timeline Configuration func getSupportedTimeTravelDirections(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void) { @@ -76,11 +79,18 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { updateChartManagerIfNeeded(completion: { let entry: CLKComplicationTimelineEntry? - - if let context = ExtensionDelegate.shared().loopManager.activeContext, - let glucoseDate = context.glucoseDate, - glucoseDate.timeIntervalSinceNow.minutes >= -15, - let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, chartGenerator: self.makeChart) + + let settings = ExtensionDelegate.shared().loopManager.settings + let timelineDate = Date() + + self.log.default("Updating current complication timeline entry") + + if let context = ExtensionDelegate.shared().loopManager.activeContext, + let template = CLKComplicationTemplate.templateForFamily(complication.family, + from: context, + at: timelineDate, + recencyInterval: settings.inputDataRecencyInterval, + chartGenerator: self.makeChart) { switch complication.family { case .graphicRectangular: @@ -88,7 +98,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { default: template.tintColor = .tintColor } - entry = CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template) + entry = CLKComplicationTimelineEntry(date: timelineDate, complicationTemplate: template) } else { entry = nil } @@ -97,26 +107,48 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { }) } - func getTimelineEntries(for complication: CLKComplication, before date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { - // Call the handler with the timeline entries prior to the given date - handler(nil) - } - func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { updateChartManagerIfNeeded { let entries: [CLKComplicationTimelineEntry]? - - if let context = ExtensionDelegate.shared().loopManager.activeContext, - let glucoseDate = context.glucoseDate, - glucoseDate.timeIntervalSince(date) > 0, - let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, chartGenerator: self.makeChart) + + let settings = ExtensionDelegate.shared().loopManager.settings + + guard let context = ExtensionDelegate.shared().loopManager.activeContext, + let glucoseDate = context.glucoseDate else { - template.tintColor = UIColor.tintColor - entries = [CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template)] - } else { - entries = nil + handler(nil) + return } - + + var futureChangeDates: [Date] = [ + // Stale glucose date: just a second after glucose expires + glucoseDate + settings.inputDataRecencyInterval + 1, + ] + + if let loopLastRunDate = context.loopLastRunDate { + let freshnessCategories = [ + LoopCompletionFreshness.fresh, + LoopCompletionFreshness.aging, + LoopCompletionFreshness.stale + ].compactMap( { $0.maxAge }) + futureChangeDates.append(contentsOf: freshnessCategories.map { loopLastRunDate + $0 + 1}) + } + + entries = futureChangeDates.filter { $0 > date }.compactMap({ (futureChangeDate) -> CLKComplicationTimelineEntry? in + if let template = CLKComplicationTemplate.templateForFamily(complication.family, + from: context, + at: futureChangeDate, + recencyInterval: settings.inputDataRecencyInterval, + chartGenerator: self.makeChart) + { + template.tintColor = UIColor.tintColor + self.log.default("Adding complication timeline entry for date %{public}@", String(describing: futureChangeDate)) + return CLKComplicationTimelineEntry(date: futureChangeDate, complicationTemplate: template) + } else { + return nil + } + }) + handler(entries) } } diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index 3b3eddcf63..07ace72b3b 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -29,6 +29,8 @@ class HUDInterfaceController: WKInterfaceController { } } } + + loopManager.requestGlucoseBackfillIfNecessary() } override func didDeactivate() { @@ -48,15 +50,15 @@ class HUDInterfaceController: WKInterfaceController { return } - glucoseLabel.setHidden(true) + glucoseLabel.setText("---") + glucoseLabel.setHidden(false) eventualGlucoseLabel.setHidden(true) - if let glucose = activeContext.glucose, let unit = activeContext.preferredGlucoseUnit { + if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.preferredGlucoseUnit, glucoseDate.timeIntervalSinceNow > -loopManager.settings.inputDataRecencyInterval { let formatter = NumberFormatter.glucoseFormatter(for: unit) if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { let trend = activeContext.glucoseTrend?.symbol ?? "" glucoseLabel.setText(glucoseValue + trend) - glucoseLabel.setHidden(false) } if let eventualGlucose = activeContext.eventualGlucose { diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index 57c293029a..547f8f2a5e 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -10,37 +10,83 @@ import ClockKit import HealthKit import LoopKit import Foundation - +import LoopCore extension CLKComplicationTemplate { - static func templateForFamily(_ family: CLKComplicationFamily, from context: WatchContext, chartGenerator makeChart: () -> UIImage?) -> CLKComplicationTemplate? { + static func templateForFamily( + _ family: CLKComplicationFamily, + from context: WatchContext, + at date: Date, + recencyInterval: TimeInterval, + chartGenerator makeChart: () -> UIImage? + ) -> CLKComplicationTemplate? { guard let glucose = context.glucose, let unit = context.preferredGlucoseUnit else { return nil } - - return templateForFamily(family, glucose: glucose, unit: unit, date: context.glucoseDate, trend: context.glucoseTrend, eventualGlucose: context.eventualGlucose, chartGenerator: makeChart) + + return templateForFamily(family, + glucose: glucose, + unit: unit, + glucoseDate: context.glucoseDate, + trend: context.glucoseTrend, + eventualGlucose: context.eventualGlucose, + at: date, + loopLastRunDate: context.loopLastRunDate, + recencyInterval: recencyInterval, + chartGenerator: makeChart) } static func templateForFamily( _ family: CLKComplicationFamily, glucose: HKQuantity, unit: HKUnit, - date: Date?, + glucoseDate: Date?, trend: GlucoseTrend?, eventualGlucose: HKQuantity?, + at date: Date, + loopLastRunDate: Date?, + recencyInterval: TimeInterval, chartGenerator makeChart: () -> UIImage? ) -> CLKComplicationTemplate? { let formatter = NumberFormatter.glucoseFormatter(for: unit) - - guard let glucoseString = formatter.string(from: glucose.doubleValue(for: unit)), - let date = date else - { + + guard let glucoseDate = glucoseDate else { return nil } + + let glucoseString: String + let trendString: String + + let isGlucoseStale = date.timeIntervalSince(glucoseDate) > recencyInterval + + if isGlucoseStale { + glucoseString = "---" + trendString = "" + } else { + guard let formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) else { + return nil + } + glucoseString = formattedGlucose + trendString = trend?.symbol ?? " " + } + + let loopCompletionFreshness = LoopCompletionFreshness(lastCompletion: loopLastRunDate, at: date) + + let tintColor: UIColor + + switch loopCompletionFreshness { + case .fresh: + tintColor = .tintColor + case .aging: + tintColor = .agingColor + case .stale: + tintColor = .staleColor + case .unknown: + tintColor = .disabledButtonColor + } - let trendString = trend?.symbol ?? " " let glucoseAndTrend = "\(glucoseString)\(trendString)" var accessibilityStrings = [glucoseString] @@ -49,7 +95,15 @@ extension CLKComplicationTemplate { } let glucoseAndTrendText = CLKSimpleTextProvider(text: glucoseAndTrend, shortText: glucoseString, accessibilityLabel: accessibilityStrings.joined(separator: ", ")) - let timeText = CLKRelativeDateTextProvider(date: date, style: .natural, units: .minute) + + let timeText: CLKTextProvider + + if let loopLastRunDate = loopLastRunDate { + timeText = CLKRelativeDateTextProvider(date: loopLastRunDate, style: .natural, units: [.minute, .hour, .day]) + } else { + timeText = CLKTextProvider() + } + timeText.tintColor = tintColor let timeFormatter = DateFormatter() timeFormatter.dateStyle = .none @@ -93,14 +147,13 @@ extension CLKComplicationTemplate { template.textProvider = CLKSimpleTextProvider(text: String(format: format, arguments: [ glucoseAndTrend, eventualGlucoseText, - timeFormatter.string(from: date) + timeFormatter.string(from: glucoseDate) ] )) return template case .graphicCorner: if #available(watchOSApplicationExtension 5.0, *) { let template = CLKComplicationTemplateGraphicCornerStackText() - timeText.tintColor = .tintColor template.innerTextProvider = timeText template.outerTextProvider = glucoseAndTrendText return template @@ -112,7 +165,7 @@ extension CLKComplicationTemplate { let template = CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText() template.centerTextProvider = CLKSimpleTextProvider(text: glucoseString) template.bottomTextProvider = CLKSimpleTextProvider(text: trendString) - template.gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .tintColor, fillFraction: 1) + template.gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: tintColor, fillFraction: 1) return template } else { return nil @@ -121,7 +174,17 @@ extension CLKComplicationTemplate { if #available(watchOSApplicationExtension 5.0, *) { let template = CLKComplicationTemplateGraphicBezelCircularText() guard - let circularTemplate = templateForFamily(.graphicCircular, glucose: glucose, unit: unit, date: date, trend: trend, eventualGlucose: eventualGlucose, chartGenerator: makeChart) as? CLKComplicationTemplateGraphicCircular + let circularTemplate = templateForFamily(.graphicCircular, + glucose: glucose, + unit: unit, + glucoseDate: glucoseDate, + trend: trend, + eventualGlucose: eventualGlucose, + at: date, + loopLastRunDate: loopLastRunDate, + recencyInterval: recencyInterval, + chartGenerator: makeChart + ) as? CLKComplicationTemplateGraphicCircular else { fatalError("\(#function) invoked with .graphicCircular must return a subclass of CLKComplicationTemplateGraphicCircular") } @@ -135,7 +198,6 @@ extension CLKComplicationTemplate { if #available(watchOSApplicationExtension 5.0, *) { let template = CLKComplicationTemplateGraphicRectangularLargeImage() template.imageProvider = CLKFullColorImageProvider(fullColorImage: makeChart() ?? UIImage()) - timeText.tintColor = .tintColor template.textProvider = CLKTextProvider(byJoining: [glucoseAndTrendText, timeText], separator: " ") return template } else { diff --git a/WatchApp Extension/Extensions/UIColor.swift b/WatchApp Extension/Extensions/UIColor.swift index b7088fe575..bac52cb432 100644 --- a/WatchApp Extension/Extensions/UIColor.swift +++ b/WatchApp Extension/Extensions/UIColor.swift @@ -40,7 +40,11 @@ extension UIColor { static let chartNowLine = HIGWhiteColor().withAlphaComponent(0.2) static let chartPlatter = HIGWhiteColorDark() - + + static let agingColor = HIGYellowColor() + + static let staleColor = HIGRedColor() + // MARK: - HIG colors // See: https://developer.apple.com/watch/human-interface-guidelines/visual-design/#color diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 7985b76db6..dc97fd8e24 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -77,6 +77,9 @@ extension LoopDataManager { dispatchPrecondition(condition: .onQueue(.main)) if activeContext == nil || context.shouldReplace(activeContext!) { + if let newGlucoseSample = context.newGlucoseSample { + self.glucoseStore.addGlucose(newGlucoseSample) { (_) in } + } activeContext = context } } @@ -108,7 +111,7 @@ extension LoopDataManager { NotificationCenter.default.post(name: LoopDataManager.didUpdateContextNotification, object: self) } } - + @discardableResult func requestGlucoseBackfillIfNecessary() -> Bool { dispatchPrecondition(condition: .onQueue(.main))