From a805df23a65d382d2be55066f204dc4f7013d0bf Mon Sep 17 00:00:00 2001 From: Bharat Mediratta Date: Fri, 29 Jun 2018 15:56:29 -0700 Subject: [PATCH 1/2] Put a glucose chart on the watch. This incorporates a ton of fine work by Eric Jensen, but I've squashed it down into a single commit for ease of management. The watch now has two discrete pages, one which is the standard actions based interface, the second is an information interface which shows the IOB, COB, basal rate and a glucose chart. Glucose data is read from HealthKit on the watch. Sometimes this lags behind so we also make requests to backfill glucose data as necessary. We're also minimizing the amount of data sent over BLE as much as possible since BLE is slow and less reliable. Fix filename mismatches in comments Prototype of a SpriteKit-based glucose chart Tweak the scene size for 42mm watches Add the rest of the chart components Set high watermark higher for mg/dl Clean up pause/unpause semantics and set the max hours to 4 The crown now adjusts the height of the graph Fix override range rendering to match the Loop app ui Small optimizations Clean up the rendering code to pause between data changes Remove debug code Highlight the upper BG when it changes Make updateGlucoseChart async to avoid hitting CPU limits More stability fixes & remove graph corner radius Force updateNode() to run on the main queue to avoid contention Improve watch size detection code Switch to the more efficient SpriteNode Only cache 4 hours of glucose history Remove pause/unpause code- it doesn't save significant CPU Try unpausing the WKInterfaceSKScene to get the animation to stop freezing Revert "Remove pause/unpause code- it doesn't save significant CPU" This reverts commit deeddb21e066f760a9dac23ac6c0b3807b097676. Move the IOB/COB below the graph Whitespace and comment cleanup Animate moving points on the chart Only animate the predicted path when adjusting the y-axis Clean up sprite animation code Adjust scene size on 38mm watch Only show overrides that haven't yet expired Fix node expiration code Break the more complex updateSprite into two more manageable pieces: getSprite() and SKSpriteKitNode.move() Clean up variable names and comments around how we expire inactive nodes for readability Add some comments to aid readability Change visible hours with force touch menu Revert changes in tools versions in storyboard Add icons for duration menu Rearrange menu actions Fix asset locations for graph menu icons Make mmol/L graph limits be integers Force minimum height for dated range rects Make target ranges more visible Only show future part of override ranges Revert "Only show future part of override ranges" This reverts commit 0b25d2e2ec75c498a461f606b57a871d4e054bdd. Expire overrides once they move into past Set min range height via variable Rearrange image assets for graph menu Clarify minimum height on dated range Don't send expired overrides in watch context Rearrange testing of override end date Use Int16 instead of UInt16 to handle negative numbers in predicted glucose Change the appearance of the chart to be platter style to better fit with Watch aesthetics Remove duplicate files --- .../GlucoseBackfillRequestUserInfo.swift | 40 +++ Common/Models/WatchContext.swift | 33 +- Common/Models/WatchDatedRange.swift | 54 ++++ Common/Models/WatchHistoricalGlucose.swift | 51 +++ Common/Models/WatchPredictedGlucose.swift | 52 +++ .../Base.lproj/MainInterface.storyboard | 4 +- Loop.xcodeproj/project.pbxproj | 83 ++++- Loop/Managers/WatchDataManager.swift | 83 +++-- .../ComplicationController.swift | 8 +- ...roller.swift => ActionHUDController.swift} | 100 +----- .../Controllers/ChartHUDController.swift | 160 ++++++++++ .../Controllers/ContextUpdatable.swift | 14 - .../Controllers/HUDInterfaceController.swift | 81 +++++ WatchApp Extension/ExtensionDelegate.swift | 23 +- WatchApp Extension/Extensions/Date.swift | 20 ++ .../Extensions/GlucoseStore.swift | 25 ++ .../Extensions/NSUserDefaults.swift | 10 + WatchApp Extension/Extensions/WCSession.swift | 16 + WatchApp Extension/Info.plist | 2 + .../Managers/LoopDataManager.swift | 31 ++ .../Scenes/GlucoseChartScene.swift | 298 ++++++++++++++++++ .../Graph menu icons/1-hour-graph-38mm.png | Bin 0 -> 2658 bytes .../Graph menu icons/1-hour-graph-42mm.png | Bin 0 -> 2687 bytes .../1-hour-graph-38mm.png | Bin 0 -> 1052 bytes .../1-hour-graph-42mm.png | Bin 0 -> 1570 bytes .../1-hour-graph.imageset/Contents.json | 20 ++ .../Graph menu icons/2-hour-graph-38mm.png | Bin 0 -> 2969 bytes .../Graph menu icons/2-hour-graph-42mm.png | Bin 0 -> 3097 bytes .../2-hour-graph-38mm.png | Bin 0 -> 1430 bytes .../2-hour-graph-42mm.png | Bin 0 -> 1941 bytes .../2-hour-graph.imageset/Contents.json | 20 ++ .../Graph menu icons/3-hour-graph-38mm.png | Bin 0 -> 3043 bytes .../Graph menu icons/3-hour-graph-42mm.png | Bin 0 -> 3157 bytes .../3-hour-graph-38mm.png | Bin 0 -> 1655 bytes .../3-hour-graph-42mm.png | Bin 0 -> 2270 bytes .../3-hour-graph.imageset/Contents.json | 20 ++ .../Graph menu icons/Contents.json | 6 + WatchApp/Base.lproj/Interface.storyboard | 114 ++++++- 38 files changed, 1205 insertions(+), 163 deletions(-) create mode 100644 Common/Models/GlucoseBackfillRequestUserInfo.swift create mode 100644 Common/Models/WatchDatedRange.swift create mode 100644 Common/Models/WatchHistoricalGlucose.swift create mode 100644 Common/Models/WatchPredictedGlucose.swift rename WatchApp Extension/Controllers/{StatusInterfaceController.swift => ActionHUDController.swift} (63%) create mode 100644 WatchApp Extension/Controllers/ChartHUDController.swift delete mode 100644 WatchApp Extension/Controllers/ContextUpdatable.swift create mode 100644 WatchApp Extension/Controllers/HUDInterfaceController.swift create mode 100644 WatchApp Extension/Extensions/Date.swift create mode 100644 WatchApp Extension/Extensions/GlucoseStore.swift create mode 100644 WatchApp Extension/Managers/LoopDataManager.swift create mode 100644 WatchApp Extension/Scenes/GlucoseChartScene.swift create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-38mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-42mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-38mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-42mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/Contents.json create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-38mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-42mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-38mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-42mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/Contents.json create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-38mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-42mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-38mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-42mm.png create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/Contents.json create mode 100644 WatchApp/Assets.xcassets/Graph menu icons/Contents.json diff --git a/Common/Models/GlucoseBackfillRequestUserInfo.swift b/Common/Models/GlucoseBackfillRequestUserInfo.swift new file mode 100644 index 0000000000..899a93434b --- /dev/null +++ b/Common/Models/GlucoseBackfillRequestUserInfo.swift @@ -0,0 +1,40 @@ +// +// GlucoseBackfillRequestUserInfo.swift +// Loop +// +// Created by Bharat Mediratta on 6/21/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct GlucoseBackfillRequestUserInfo { + let version = 1 + let startDate: Date +} + +extension GlucoseBackfillRequestUserInfo: RawRepresentable { + typealias RawValue = [String: Any] + + static let name = "GlucoseBackfillRequestUserInfo" + + init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == GlucoseBackfillRequestUserInfo.name, + let startDate = rawValue["sd"] as? Date + else { + return nil + } + + self.startDate = startDate + } + + var rawValue: RawValue { + return [ + "v": version, + "name": GlucoseBackfillRequestUserInfo.name, + "sd": startDate + ] + } +} diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 8181fd3344..f6318e6e4e 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -10,6 +10,7 @@ import Foundation import HealthKit import LoopKit + final class WatchContext: NSObject, RawRepresentable { typealias RawValue = [String: Any] @@ -23,6 +24,8 @@ final class WatchContext: NSObject, RawRepresentable { var eventualGlucose: HKQuantity? var glucoseDate: Date? + var targetRanges: [WatchDatedRange]? + var temporaryOverride: WatchDatedRange? var glucoseRangeScheduleOverride: GlucoseRangeScheduleOverrideUserInfo? var configuredOverrideContexts: [GlucoseRangeScheduleOverrideUserInfo.Context] = [] @@ -42,6 +45,7 @@ final class WatchContext: NSObject, RawRepresentable { var reservoir: Double? var reservoirPercentage: Double? var batteryPercentage: Double? + var predictedGlucose: WatchPredictedGlucose? var cgmManagerState: CGMManager.RawStateValue? @@ -57,16 +61,15 @@ final class WatchContext: NSObject, RawRepresentable { } if let unitString = rawValue["gu"] as? String { - let unit = HKUnit(from: unitString) - preferredGlucoseUnit = unit + preferredGlucoseUnit = HKUnit(from: unitString) } - + let unit = preferredGlucoseUnit ?? .milligramsPerDeciliter if let glucoseValue = rawValue["gv"] as? Double { - glucose = HKQuantity(unit: preferredGlucoseUnit ?? .milligramsPerDeciliter, doubleValue: glucoseValue) + glucose = HKQuantity(unit: unit, doubleValue: glucoseValue) } if let glucoseValue = rawValue["egv"] as? Double { - eventualGlucose = HKQuantity(unit: preferredGlucoseUnit ?? .milligramsPerDeciliter, doubleValue: glucoseValue) + eventualGlucose = HKQuantity(unit: unit, doubleValue: glucoseValue) } glucoseTrendRawValue = rawValue["gt"] as? Int @@ -95,6 +98,21 @@ final class WatchContext: NSObject, RawRepresentable { maxBolus = rawValue["mb"] as? Double cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue + if let rawValue = rawValue["pg"] as? WatchPredictedGlucose.RawValue { + predictedGlucose = WatchPredictedGlucose(rawValue: rawValue) + } + + if let rawValue = rawValue["tr"] as? [WatchDatedRange.RawValue] { + targetRanges = rawValue.compactMap({return WatchDatedRange(rawValue: $0)}) + } + + if let rawValue = rawValue["to"] as? WatchDatedRange.RawValue { + temporaryOverride = WatchDatedRange(rawValue: rawValue) + } + + if let cgmRawValue = rawValue["cgm"] as? CGM.RawValue { + cgm = CGM(rawValue: cgmRawValue) + } } var rawValue: RawValue { @@ -126,6 +144,11 @@ final class WatchContext: NSObject, RawRepresentable { raw["rbo"] = recommendedBolusDose raw["rp"] = reservoirPercentage + raw["pg"] = predictedGlucose?.rawValue + + raw["tr"] = targetRanges?.map { $0.rawValue } + raw["to"] = temporaryOverride?.rawValue + return raw } } diff --git a/Common/Models/WatchDatedRange.swift b/Common/Models/WatchDatedRange.swift new file mode 100644 index 0000000000..855f22bd6e --- /dev/null +++ b/Common/Models/WatchDatedRange.swift @@ -0,0 +1,54 @@ +// +// WatchDatedRange.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/26/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + + +struct WatchDatedRange { + public let startDate: Date + public let endDate: Date + public let minValue: Double + public let maxValue: Double + + public init(startDate: Date, endDate: Date, minValue: Double, maxValue: Double) { + self.startDate = startDate + self.endDate = endDate + self.minValue = minValue + self.maxValue = maxValue + } +} + + +extension WatchDatedRange: RawRepresentable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "sd": startDate, + "ed": endDate, + "mi": minValue, + "ma": maxValue + ] + } + + init?(rawValue: RawValue) { + guard + let startDate = rawValue["sd"] as? Date, + let endDate = rawValue["ed"] as? Date, + let minValue = rawValue["mi"] as? Double, + let maxValue = rawValue["ma"] as? Double + else { + return nil + } + + self.startDate = startDate + self.endDate = endDate + self.minValue = minValue + self.maxValue = maxValue + } +} diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift new file mode 100644 index 0000000000..f4214625e6 --- /dev/null +++ b/Common/Models/WatchHistoricalGlucose.swift @@ -0,0 +1,51 @@ +// +// WatchHistoricalGlucose.swift +// Loop +// +// Created by Bharat Mediratta on 6/22/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + + +struct WatchHistoricalGlucose { + let samples: [NewGlucoseSample] + + init(with samples: [StoredGlucoseSample]) { + self.samples = samples.map { + NewGlucoseSample(date: $0.startDate, quantity: $0.quantity, isDisplayOnly: false, syncIdentifier: $0.syncIdentifier) + } + } +} + + +extension WatchHistoricalGlucose: RawRepresentable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "d": samples.map { $0.date }, + "v": samples.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter)) }, + "id": samples.map { $0.syncIdentifier } + ] + } + + init?(rawValue: RawValue) { + guard + let dates = rawValue["d"] as? [Date], + let values = rawValue["v"] as? [Int16], + let syncIdentifiers = rawValue["id"] as? [String], + dates.count == values.count, + dates.count == syncIdentifiers.count + else { + return nil + } + + self.samples = (0.. 1 else { + return nil + } + self.values = values + } +} + + +extension WatchPredictedGlucose: RawRepresentable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + + return [ + "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter)) }, + "d": values[0].startDate, + "i": values[1].startDate.timeIntervalSince(values[0].startDate) + ] + } + + init?(rawValue: RawValue) { + guard + let values = rawValue["v"] as? [Int16], + let firstDate = rawValue["d"] as? Date, + let interval = rawValue["i"] as? TimeInterval + else { + return nil + } + + self.values = values.enumerated().map { tuple in + PredictedGlucoseValue(startDate: firstDate + Double(tuple.0) * interval, + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(tuple.1))) + } + } +} diff --git a/Loop Status Extension/Base.lproj/MainInterface.storyboard b/Loop Status Extension/Base.lproj/MainInterface.storyboard index 154bdf9b27..5b5eb74e56 100644 --- a/Loop Status Extension/Base.lproj/MainInterface.storyboard +++ b/Loop Status Extension/Base.lproj/MainInterface.storyboard @@ -1,11 +1,11 @@ - + - + diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 00892ae375..6d565c924a 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -41,7 +41,7 @@ 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */; }; 431E73481FF95A900069B5F7 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; 4326BA641F3A44D9007CCAD4 /* ChartLineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */; }; - 4328E01A1CFBE1DA00E199AA /* StatusInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */; }; + 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */; }; 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */; }; 4328E01E1CFBE25F00E199AA /* AddCarbsInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */; }; 4328E0261CFBE2C500E199AA /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */; }; @@ -105,7 +105,6 @@ 437D9BA31D7BC977007245E8 /* PredictionTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D9BA21D7BC977007245E8 /* PredictionTableViewController.swift */; }; 438172D91F4E9E37003C3328 /* NewPumpEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438172D81F4E9E37003C3328 /* NewPumpEvent.swift */; }; 43846AD51D8FA67800799272 /* Base.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 43846AD41D8FA67800799272 /* Base.lproj */; }; - 43846ADB1D91057000799272 /* ContextUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43846ADA1D91057000799272 /* ContextUpdatable.swift */; }; 438849EA1D297CB6003B3F23 /* NightscoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438849E91D297CB6003B3F23 /* NightscoutService.swift */; }; 438849EC1D29EC34003B3F23 /* AmplitudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438849EB1D29EC34003B3F23 /* AmplitudeService.swift */; }; 438849EE1D2A1EBB003B3F23 /* MLabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438849ED1D2A1EBB003B3F23 /* MLabService.swift */; }; @@ -199,6 +198,10 @@ 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */; }; 4F08DE9B1E7BC4ED006741EA /* SwiftCharts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4346D1EF1C781BEA00ABAFE3 /* SwiftCharts.framework */; }; 4F08DE9D1E81D0E9006741EA /* StatusChartsManager+LoopKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430C1ABC1E5568A80067F1AE /* StatusChartsManager+LoopKit.swift */; }; + 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; + 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; 4F20AE621E6B879C00D07A06 /* ReservoirVolumeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEC71CD84CBB003C8C80 /* ReservoirVolumeHUDView.swift */; }; 4F20AE631E6B87B100D07A06 /* ChartContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4313EDDF1D8A6BF90060FA79 /* ChartContainerView.swift */; }; 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; @@ -219,6 +222,7 @@ 4F70C2101DE8FAC5006380B7 /* StatusExtensionDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */; }; 4F70C2121DE900EA006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; + 4F73F5FC20E2E7FA00E8D82C /* GlucoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73F5FB20E2E7FA00E8D82C /* GlucoseStore.swift */; }; 4F7528941DFE1E9500C322D6 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; 4F7528951DFE1E9B00C322D6 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; 4F75289A1DFE1F6000C322D6 /* BasalRateHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */; }; @@ -231,6 +235,13 @@ 4F7528A21DFE200B00C322D6 /* LevelMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FBEDD71D73843700B21F22 /* LevelMaskView.swift */; }; 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; + 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */; }; + 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC420E2AB9600AEA65E /* Date.swift */; }; + 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + 4F7E8AC920E2AC3700AEA65E /* WatchDatedRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC820E2AC3700AEA65E /* WatchDatedRange.swift */; }; + 4F7E8ACA20E2ACAE00AEA65E /* WatchDatedRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC820E2AC3700AEA65E /* WatchDatedRange.swift */; }; + 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */; }; 4FAC02541E22F6B20087A773 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4FB76FB01E8C3E8000B39636 /* SwiftCharts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4346D1EF1C781BEA00ABAFE3 /* SwiftCharts.framework */; }; 4FB76FB31E8C3EE400B39636 /* ChartAxisValueDoubleLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DE7C1E7BB6E5006741EA /* ChartAxisValueDoubleLog.swift */; }; @@ -246,13 +257,15 @@ 4FB76FCE1E8C835D00B39636 /* ChartColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB76FCD1E8C835D00B39636 /* ChartColorPalette.swift */; }; 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; 4FC8C8021DEB943800A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; + 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */; }; + 4FF0F75E20E1E5D100FC6291 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; + 4FF0F75F20E1E5EF00FC6291 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; 4FF4D0F91E17268800846527 /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */; }; 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; 7D2366E621250E0A0028B67D /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D2366E421250E0A0028B67D /* InfoPlist.strings */; }; - 7D2366E821250E7B0028B67D /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D2366E421250E0A0028B67D /* InfoPlist.strings */; }; 7D7076351FE06EDE004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076371FE06EDE004AC8EA /* Localizable.strings */; }; 7D70763A1FE06EDF004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D70763C1FE06EDF004AC8EA /* InfoPlist.strings */; }; 7D70763F1FE06EDF004AC8EA /* ckcomplication.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076411FE06EDF004AC8EA /* ckcomplication.strings */; }; @@ -436,7 +449,7 @@ 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; 431E73471FF95A900069B5F7 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartLineModel.swift; sourceTree = ""; }; - 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusInterfaceController.swift; sourceTree = ""; }; + 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionHUDController.swift; sourceTree = ""; }; 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusInterfaceController.swift; sourceTree = ""; }; 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCarbsInterfaceController.swift; sourceTree = ""; }; 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifiableClass.swift; sourceTree = ""; }; @@ -503,7 +516,6 @@ 437D9BA21D7BC977007245E8 /* PredictionTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PredictionTableViewController.swift; sourceTree = ""; }; 438172D81F4E9E37003C3328 /* NewPumpEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPumpEvent.swift; sourceTree = ""; }; 43846AD41D8FA67800799272 /* Base.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base.lproj; sourceTree = ""; }; - 43846ADA1D91057000799272 /* ContextUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextUpdatable.swift; sourceTree = ""; }; 438849E91D297CB6003B3F23 /* NightscoutService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutService.swift; sourceTree = ""; }; 438849EB1D29EC34003B3F23 /* AmplitudeService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmplitudeService.swift; sourceTree = ""; }; 438849ED1D2A1EBB003B3F23 /* MLabService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MLabService.swift; sourceTree = ""; }; @@ -601,6 +613,8 @@ 4F08DE831E7BB70B006741EA /* ChartPointsScatterDownTrianglesLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartPointsScatterDownTrianglesLayer.swift; sourceTree = ""; }; 4F08DE841E7BB70B006741EA /* ChartPointsTouchHighlightLayerViewCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartPointsTouchHighlightLayerViewCache.swift; sourceTree = ""; }; 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CollectionType+Loop.swift"; sourceTree = ""; }; + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBackfillRequestUserInfo.swift; sourceTree = ""; }; + 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHistoricalGlucose.swift; sourceTree = ""; }; 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WatchContext+WatchApp.swift"; sourceTree = ""; }; 4F2C15921E09BF2C00E160D4 /* HUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HUDView.swift; sourceTree = ""; }; 4F2C15941E09BF3C00E160D4 /* HUDView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = HUDView.xib; sourceTree = ""; }; @@ -616,13 +630,21 @@ 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Status Extension.entitlements"; sourceTree = ""; }; 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusExtensionDataManager.swift; sourceTree = ""; }; 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusExtensionContext.swift; sourceTree = ""; }; + 4F73F5FB20E2E7FA00E8D82C /* GlucoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStore.swift; sourceTree = ""; }; 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F75288D1DFE1DC600C322D6 /* LoopUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoopUI.h; sourceTree = ""; }; 4F75288E1DFE1DC600C322D6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartScene.swift; sourceTree = ""; }; + 4F7E8AC420E2AB9600AEA65E /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPredictedGlucose.swift; sourceTree = ""; }; + 4F7E8AC820E2AC3700AEA65E /* WatchDatedRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchDatedRange.swift; sourceTree = ""; }; + 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDInterfaceController.swift; sourceTree = ""; }; 4FB76FC51E8C57B100B39636 /* StatusChartsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; 4FB76FCD1E8C835D00B39636 /* ChartColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartColorPalette.swift; sourceTree = ""; }; 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+StatusExtension.swift"; sourceTree = ""; }; + 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManager.swift; sourceTree = ""; }; 4FF4D0FF1E18374700846527 /* WatchContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchContext.swift; sourceTree = ""; }; + 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; 7D199D92212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/LaunchScreen.strings; sourceTree = ""; }; 7D199D93212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Main.strings; sourceTree = ""; }; 7D199D94212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/MainInterface.strings; sourceTree = ""; }; @@ -890,9 +912,10 @@ children = ( 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */, 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */, - 43846ADA1D91057000799272 /* ContextUpdatable.swift */, 43A943891B926B7B0051FA24 /* NotificationController.swift */, - 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */, + 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */, + 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */, + 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */, ); path = Controllers; sourceTree = ""; @@ -902,6 +925,8 @@ children = ( 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, + 4F7E8AC420E2AB9600AEA65E /* Date.swift */, + 4F73F5FB20E2E7FA00E8D82C /* GlucoseStore.swift */, 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */, 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */, 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */, @@ -1031,6 +1056,8 @@ 43A9438F1B926B7B0051FA24 /* Assets.xcassets */, 4328E0121CFBE1B700E199AA /* Controllers */, 4328E01F1CFBE2B100E199AA /* Extensions */, + 4FE3475F20D5D7FA00A86D03 /* Managers */, + 4F75F0052100146B00B5570E /* Scenes */, 43A943831B926B7B0051FA24 /* Supporting Files */, ); path = "WatchApp Extension"; @@ -1240,6 +1267,14 @@ path = Models; sourceTree = ""; }; + 4F75F0052100146B00B5570E /* Scenes */ = { + isa = PBXGroup; + children = ( + 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */, + ); + path = Scenes; + sourceTree = ""; + }; 4FB76FC31E8C575900B39636 /* Managers */ = { isa = PBXGroup; children = ( @@ -1258,6 +1293,14 @@ path = Extensions; sourceTree = ""; }; + 4FE3475F20D5D7FA00A86D03 /* Managers */ = { + isa = PBXGroup; + children = ( + 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; 4FF4D0FA1E1834BD00846527 /* Common */ = { isa = PBXGroup; children = ( @@ -1274,12 +1317,16 @@ 43673E2E1F37BDA10058AC7C /* Insulin */, 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */, 43DE92581C5479E4001FFDE1 /* CarbEntryUserInfo.swift */, + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, 894B91CC1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift */, 430B298D2041F56500BA9F93 /* GlucoseThreshold.swift */, 430B298C2041F56500BA9F93 /* LoopSettings.swift */, 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */, 4FF4D0FF1E18374700846527 /* WatchContext.swift */, + 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */, + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, + 4F7E8AC820E2AC3700AEA65E /* WatchDatedRange.swift */, ); path = Models; sourceTree = ""; @@ -1500,7 +1547,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0730; - LastUpgradeCheck = 0930; + LastUpgradeCheck = 0940; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { 432CF87720D8B8380066B889 = { @@ -1684,7 +1731,6 @@ buildActionMask = 2147483647; files = ( 7D2366E621250E0A0028B67D /* InfoPlist.strings in Resources */, - 7D2366E821250E7B0028B67D /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1815,6 +1861,7 @@ 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, 43BFF0CB1E466C0900FF19A9 /* StateColorPalette.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, + 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 43F5C2DB1B92A5E1003EB13D /* SettingsTableViewController.swift in Sources */, 434FF1EA1CF26C29000DB779 /* IdentifiableClass.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, @@ -1847,6 +1894,7 @@ 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */, + 4F7E8ACA20E2ACAE00AEA65E /* WatchDatedRange.swift in Sources */, 4FB76FBB1E8C42CF00B39636 /* UIColor.swift in Sources */, 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, @@ -1875,7 +1923,9 @@ 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */, 43C3B6EC20B650A80026CAFA /* SettingsImageTableViewCell.swift in Sources */, + 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */, 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, + 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 435CB6231F37967800C320C7 /* InsulinModelSettingsViewController.swift in Sources */, 431E73481FF95A900069B5F7 /* PersistenceController.swift in Sources */, 43F78D261C8FC000002152D1 /* DoseMath.swift in Sources */, @@ -1909,27 +1959,38 @@ 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */, 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */, + 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */, 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */, 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */, + 4FF0F75F20E1E5EF00FC6291 /* NSBundle.swift in Sources */, 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */, 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */, + 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */, + 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */, 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */, + 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */, 4344628E20A7ADD100C4BE6F /* UserDefaults+CGM.swift in Sources */, + 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */, + 4FFEDFBF20E5CF22000BFC58 /* ChartHUDController.swift in Sources */, 894B91CE1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift in Sources */, + 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */, 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */, 4328E01E1CFBE25F00E199AA /* AddCarbsInterfaceController.swift in Sources */, - 43846ADB1D91057000799272 /* ContextUpdatable.swift in Sources */, 4328E0261CFBE2C500E199AA /* IdentifiableClass.swift in Sources */, + 4F73F5FC20E2E7FA00E8D82C /* GlucoseStore.swift in Sources */, 432CF87520D8AC950066B889 /* NSUserDefaults.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */, 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */, + 4F7E8AC920E2AC3700AEA65E /* WatchDatedRange.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, 43DE925A1C5479E4001FFDE1 /* CarbEntryUserInfo.swift in Sources */, + 4FF0F75E20E1E5D100FC6291 /* PersistenceController.swift in Sources */, 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */, 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */, - 4328E01A1CFBE1DA00E199AA /* StatusInterfaceController.swift in Sources */, + 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */, + 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index f59f05fa28..72e407d69e 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -11,7 +11,6 @@ import UIKit import WatchConnectivity import LoopKit - final class WatchDataManager: NSObject, WCSessionDelegate { unowned let deviceManager: DeviceDataManager @@ -51,6 +50,8 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } switch updateContext { + case .glucose: + break case .tempBasal: break case .preferences: @@ -123,13 +124,18 @@ final class WatchDataManager: NSObject, WCSessionDelegate { let reservoir = loopManager.doseStore.lastReservoirValue loopManager.getLoopState { (manager, state) in - let eventualGlucose = state.predictedGlucose?.last - let context = WatchContext(glucose: glucose, eventualGlucose: eventualGlucose, glucoseUnit: manager.glucoseStore.preferredUnit) - context.reservoir = reservoir?.unitVolume + let updateGroup = DispatchGroup() + + let startDate = Date().addingTimeInterval(TimeInterval(minutes: -180)) + let endDate = Date().addingTimeInterval(TimeInterval(minutes: 180)) + let context = WatchContext(glucose: glucose, eventualGlucose: state.predictedGlucose?.last, glucoseUnit: manager.glucoseStore.preferredUnit) + context.reservoir = reservoir?.unitVolume context.loopLastRunDate = manager.lastLoopCompleted context.recommendedBolusDose = state.recommendedBolus?.recommendation.amount context.maxBolus = manager.settings.maximumBolus + context.COB = state.carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) + context.glucoseTrendRawValue = self.deviceDataManager.sensorInfo?.trendType?.rawValue context.cgmManagerState = self.deviceManager.cgmManager?.rawValue @@ -140,17 +146,61 @@ final class WatchDataManager: NSObject, WCSessionDelegate { startDate: override.start, endDate: override.end ) + + let endDate = override.end ?? .distantFuture + if endDate > Date() { + context.temporaryOverride = WatchDatedRange( + startDate: override.start, + endDate: endDate, + minValue: override.value.minValue, + maxValue: override.value.maxValue + ) + } } let configuredOverrideContexts = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.configuredOverrideContexts ?? [] let configuredUserInfoOverrideContexts = configuredOverrideContexts.map { $0.correspondingUserInfoContext } context.configuredOverrideContexts = configuredUserInfoOverrideContexts + + context.targetRanges = glucoseTargetRangeSchedule.between(start: startDate, end: endDate).map { + return WatchDatedRange( + startDate: $0.startDate, + endDate: $0.endDate, + minValue: $0.value.minValue, + maxValue: $0.value.maxValue + ) + } } if let trend = self.deviceManager.cgmManager?.sensorState?.trendType { context.glucoseTrendRawValue = trend.rawValue } + updateGroup.enter() + manager.doseStore.insulinOnBoard(at: Date()) { (result) in + switch result { + case .success(let iobValue): + context.IOB = iobValue.value + case .failure: + context.IOB = nil + } + updateGroup.leave() + } + + // Only set this value in the Watch context if there is a temp basal running that hasn't ended yet + let date = state.lastTempBasal?.startDate ?? Date() + if let scheduledBasal = manager.basalRateSchedule?.between(start: date, end: date).first, + let lastTempBasal = state.lastTempBasal, + lastTempBasal.endDate > Date() { + context.lastNetTempBasalDose = lastTempBasal.unitsPerHour - scheduledBasal.value + } + + // Drop the first element in predictedGlucose because it is the current glucose + if let predictedGlucose = state.predictedGlucose?.dropFirst(), predictedGlucose.count > 0 { + context.predictedGlucose = WatchPredictedGlucose(values: Array(predictedGlucose)) + } + + _ = updateGroup.wait(timeout: .distantFuture) completion(context) } } @@ -198,23 +248,20 @@ final class WatchDataManager: NSObject, WCSessionDelegate { replyHandler([:]) case GlucoseRangeScheduleOverrideUserInfo.name?: + // Successful changes will trigger a preferences change which will update the watch with the new overrides if let overrideUserInfo = GlucoseRangeScheduleOverrideUserInfo(rawValue: message) { - let overrideContext = overrideUserInfo.context.correspondingOverrideContext - - // update the recorded last active override context prior to enabling the actual override - // to prevent the Watch context being unnecessarily sent in response to the override being enabled - let previousActiveOverrideContext = lastActiveOverrideContext - lastActiveOverrideContext = overrideContext - let overrideSuccess = deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.setOverride(overrideContext, from: overrideUserInfo.startDate, until: overrideUserInfo.effectiveEndDate) - - if overrideSuccess == false { - lastActiveOverrideContext = previousActiveOverrideContext + _ = deviceDataManager.loopManager.settings.glucoseTargetRangeSchedule?.setOverride(overrideUserInfo.context.correspondingOverrideContext, from: overrideUserInfo.startDate, until: overrideUserInfo.effectiveEndDate) + } else { + deviceDataManager.loopManager.settings.glucoseTargetRangeSchedule?.clearOverride() + } + replyHandler([:]) + case GlucoseBackfillRequestUserInfo.name?: + if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message), + let manager = deviceDataManager.loopManager { + manager.glucoseStore.getCachedGlucoseSamples(start: userInfo.startDate) { (values) in + replyHandler(WatchHistoricalGlucose(with: values).rawValue) } - - replyHandler([:]) } else { - lastActiveOverrideContext = nil - deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.clearOverride() replyHandler([:]) } default: diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 4b149cabb1..163ffe5220 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -19,7 +19,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().lastContext?.glucoseDate { + if let date = ExtensionDelegate.shared().activeContext?.glucoseDate { handler(date) } else { handler(nil) @@ -27,7 +27,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().lastContext?.glucoseDate { + if let date = ExtensionDelegate.shared().activeContext?.glucoseDate { handler(date) } else { handler(nil) @@ -45,7 +45,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { let entry: CLKComplicationTimelineEntry? - if let context = ExtensionDelegate.shared().lastContext, + if let context = ExtensionDelegate.shared().activeContext, let glucoseDate = context.glucoseDate, glucoseDate.timeIntervalSinceNow.minutes >= -15, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context) @@ -68,7 +68,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // Call the handler with the timeline entries after to the given date let entries: [CLKComplicationTimelineEntry]? - if let context = ExtensionDelegate.shared().lastContext, + if let context = ExtensionDelegate.shared().activeContext, let glucoseDate = context.glucoseDate, glucoseDate.timeIntervalSince(date) > 0, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context) diff --git a/WatchApp Extension/Controllers/StatusInterfaceController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift similarity index 63% rename from WatchApp Extension/Controllers/StatusInterfaceController.swift rename to WatchApp Extension/Controllers/ActionHUDController.swift index 9e56ce5ded..097770cdc9 100644 --- a/WatchApp Extension/Controllers/StatusInterfaceController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -1,5 +1,5 @@ // -// StatusInterfaceController.swift +// ActionHUDController.swift // Loop // // Created by Nathan Racklyeft on 5/29/16. @@ -11,33 +11,19 @@ import WatchConnectivity import LoopKit -final class StatusInterfaceController: WKInterfaceController, ContextUpdatable { - - @IBOutlet weak var loopHUDImage: WKInterfaceImage! - @IBOutlet weak var loopTimer: WKInterfaceTimer! - @IBOutlet weak var glucoseLabel: WKInterfaceLabel! - @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! - @IBOutlet weak var statusLabel: WKInterfaceLabel! - +final class ActionHUDController: HUDInterfaceController { @IBOutlet var preMealButton: WKInterfaceButton! @IBOutlet var preMealButtonImage: WKInterfaceImage! @IBOutlet var preMealButtonBackground: WKInterfaceGroup! - @IBOutlet var workoutButton: WKInterfaceButton! @IBOutlet var workoutButtonImage: WKInterfaceImage! @IBOutlet var workoutButtonBackground: WKInterfaceGroup! - private lazy var preMealButtonGroup = ButtonGroup(button: preMealButton, image: preMealButtonImage, background: preMealButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor) - - private lazy var workoutButtonGroup = ButtonGroup(button: workoutButton, image: workoutButtonImage, background: workoutButtonBackground, onBackgroundColor: .workoutColor, offBackgroundColor: .darkWorkoutColor) - private var lastOverrideContext: GlucoseRangeScheduleOverrideUserInfo.Context? - private var lastContext: WatchContext? + private lazy var preMealButtonGroup = ButtonGroup(button: preMealButton, image: preMealButtonImage, background: preMealButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor) - override func didAppear() { - super.didAppear() - } + private lazy var workoutButtonGroup = ButtonGroup(button: workoutButton, image: workoutButtonImage, background: workoutButtonBackground, onBackgroundColor: .workoutColor, offBackgroundColor: .darkWorkoutColor) override func willActivate() { super.willActivate() @@ -51,66 +37,15 @@ final class StatusInterfaceController: WKInterfaceController, ContextUpdatable { updateUserActivity(userActivity.activityType, userInfo: userActivity.userInfo, webpageURL: nil) } } + override func update() { + super.update() - private func updateLoopHUD() { - guard let date = lastContext?.loopLastRunDate else { - loopHUDImage.setLoopImage(.unknown) + guard let activeContext = loopManager?.activeContext else { return } - let loopImage: LoopImage - - switch date.timeIntervalSinceNow { - case let t where t > .minutes(-6): - loopImage = .fresh - case let t where t > .minutes(-20): - loopImage = .aging - default: - loopImage = .stale - } - - loopHUDImage.setLoopImage(loopImage) - } - - func update(with context: WatchContext?) { - lastContext = context - - if let date = context?.loopLastRunDate { - loopTimer.setDate(date) - loopTimer.setHidden(false) - loopTimer.start() - - updateLoopHUD() - } else { - loopTimer.setHidden(true) - loopHUDImage.setLoopImage(.unknown) - } - - if let glucose = context?.glucose, let unit = context?.preferredGlucoseUnit { - let formatter = NumberFormatter.glucoseFormatter(for: unit) - - if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { - let trend = context?.glucoseTrend?.symbol ?? "" - glucoseLabel.setText(glucoseValue + trend) - glucoseLabel.setHidden(false) - } else { - glucoseLabel.setHidden(true) - } - - if let eventualGlucose = context?.eventualGlucose { - let glucoseValue = formatter.string(from: eventualGlucose.doubleValue(for: unit)) - eventualGlucoseLabel.setText(glucoseValue) - eventualGlucoseLabel.setHidden(false) - } else { - eventualGlucoseLabel.setHidden(true) - } - } else { - glucoseLabel.setHidden(true) - eventualGlucoseLabel.setHidden(true) - } - let overrideContext: GlucoseRangeScheduleOverrideUserInfo.Context? - if let glucoseRangeScheduleOverride = context?.glucoseRangeScheduleOverride, glucoseRangeScheduleOverride.dateInterval.contains(Date()) + if let glucoseRangeScheduleOverride = activeContext.glucoseRangeScheduleOverride, glucoseRangeScheduleOverride.dateInterval.contains(Date()) { overrideContext = glucoseRangeScheduleOverride.context } else { @@ -119,19 +54,14 @@ final class StatusInterfaceController: WKInterfaceController, ContextUpdatable { updateForOverrideContext(overrideContext) lastOverrideContext = overrideContext - if let configuredOverrideContexts = context?.configuredOverrideContexts { - for overrideContext in GlucoseRangeScheduleOverrideUserInfo.Context.allContexts { - let contextButtonGroup = buttonGroup(for: overrideContext) - if !configuredOverrideContexts.contains(overrideContext) { - contextButtonGroup.state = .disabled - } else if contextButtonGroup.state == .disabled { - contextButtonGroup.state = .off - } + for overrideContext in GlucoseRangeScheduleOverrideUserInfo.Context.allContexts { + let contextButtonGroup = buttonGroup(for: overrideContext) + if !activeContext.configuredOverrideContexts.contains(overrideContext) { + contextButtonGroup.state = .disabled + } else if contextButtonGroup.state == .disabled { + contextButtonGroup.state = .off } } - - // TODO: Other elements - statusLabel.setHidden(true) } private func updateForOverrideContext(_ context: GlucoseRangeScheduleOverrideUserInfo.Context?) { @@ -164,7 +94,7 @@ final class StatusInterfaceController: WKInterfaceController, ContextUpdatable { } @IBAction func setBolus() { - presentController(withName: BolusInterfaceController.className, context: lastContext?.bolusSuggestion) + presentController(withName: BolusInterfaceController.className, context: loopManager?.activeContext?.bolusSuggestion ?? 0) } @IBAction func togglePreMealMode() { diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift new file mode 100644 index 0000000000..2a896c429a --- /dev/null +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -0,0 +1,160 @@ +// +// ChartHUDController.swift +// Loop +// +// Created by Bharat Mediratta on 6/26/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import WatchKit +import WatchConnectivity +import CGMBLEKit +import LoopKit +import SpriteKit + +final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { + @IBOutlet weak var basalLabel: WKInterfaceLabel! + @IBOutlet weak var iobLabel: WKInterfaceLabel! + @IBOutlet weak var cobLabel: WKInterfaceLabel! + @IBOutlet weak var glucoseScene: WKInterfaceSKScene! + @IBAction func setChartWindow1Hour() { + scene.visibleHours = 1 + } + @IBAction func setChartWindow2Hours() { + scene.visibleHours = 2 + } + @IBAction func setChartWindow3Hours() { + scene.visibleHours = 3 + } + private let scene = GlucoseChartScene() + + override init() { + super.init() + + loopManager = ExtensionDelegate.shared().loopManager + NotificationCenter.default.addObserver(forName: .GlucoseSamplesDidChange, object: loopManager?.glucoseStore, queue: nil) { _ in + DispatchQueue.main.async { + self.updateGlucoseChart() + } + } + + glucoseScene.presentScene(scene) + } + + override func awake(withContext context: Any?) { + if UserDefaults.standard.startOnChartPage { + self.becomeCurrentPage() + + // For some reason, .didAppear() does not get called when we do this. It gets called *twice* the next + // time this view appears. Force it by hand now, until we figure out the root cause. + // + // TODO: possibly because I'm not calling super.awake()? investigate that. + DispatchQueue.main.async { + self.didAppear() + } + } + } + + override func didAppear() { + super.didAppear() + } + + override func willActivate() { + crownSequencer.delegate = self + crownSequencer.focus() + + super.willActivate() + + loopManager?.glucoseStore.maybeRequestGlucoseBackfill() + glucoseScene.isPaused = false + } + + override func update() { + super.update() + + guard let activeContext = loopManager?.activeContext else { + return + } + + let insulinFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + + numberFormatter.numberStyle = .decimal + numberFormatter.minimumFractionDigits = 1 + numberFormatter.maximumFractionDigits = 1 + + return numberFormatter + }() + + iobLabel.setHidden(true) + if let activeInsulin = activeContext.IOB, let valueStr = insulinFormatter.string(from:NSNumber(value:activeInsulin)) { + iobLabel.setText(String(format: NSLocalizedString( + "IOB %1$@ U", + comment: "The subtitle format describing units of active insulin. (1: localized insulin value description)"), + valueStr)) + iobLabel.setHidden(false) + } + + cobLabel.setHidden(true) + if let carbsOnBoard = activeContext.COB { + let carbFormatter = NumberFormatter() + carbFormatter.numberStyle = .decimal + carbFormatter.maximumFractionDigits = 0 + let valueStr = carbFormatter.string(from:NSNumber(value:carbsOnBoard)) + + cobLabel.setText(String(format: NSLocalizedString( + "COB %1$@ g", + comment: "The subtitle format describing grams of active carbs. (1: localized carb value description)"), + valueStr!)) + cobLabel.setHidden(false) + } + + basalLabel.setHidden(true) + if let tempBasal = activeContext.lastNetTempBasalDose { + let basalFormatter = NumberFormatter() + basalFormatter.numberStyle = .decimal + basalFormatter.minimumFractionDigits = 1 + basalFormatter.maximumFractionDigits = 3 + basalFormatter.positivePrefix = basalFormatter.plusSign + let valueStr = basalFormatter.string(from:NSNumber(value:tempBasal)) + + let basalLabelText = String(format: NSLocalizedString( + "%1$@ U/hr", + comment: "The subtitle format describing the current temp basal rate. (1: localized basal rate description)"), + valueStr!) + basalLabel.setText(basalLabelText) + basalLabel.setHidden(false) + } + + updateGlucoseChart() + } + + func updateGlucoseChart() { + guard let activeContext = loopManager?.activeContext else { + return + } + + scene.predictedGlucose = activeContext.predictedGlucose?.values + scene.targetRanges = activeContext.targetRanges + scene.temporaryOverride = activeContext.temporaryOverride + scene.unit = activeContext.preferredGlucoseUnit + + loopManager?.glucoseStore.getCachedGlucoseSamples(start: .EarliestGlucoseCutoff) { (samples) in + DispatchQueue.main.async { + self.scene.historicalGlucose = samples + self.scene.updateNodes(animated: false) + } + } + } + + // MARK: WKCrownDelegate + var crownAccumulator = 0.0 + + func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) { + crownAccumulator += rotationalDelta + if abs(crownAccumulator) >= 0.25 { + scene.visibleBg += Int(sign(crownAccumulator)) + crownAccumulator = 0 + } + } +} diff --git a/WatchApp Extension/Controllers/ContextUpdatable.swift b/WatchApp Extension/Controllers/ContextUpdatable.swift deleted file mode 100644 index 00cc2100d7..0000000000 --- a/WatchApp Extension/Controllers/ContextUpdatable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// ContextUpdatable.swift -// Loop -// -// Created by Nate Racklyeft on 9/19/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -protocol ContextUpdatable { - func update(with context: WatchContext?) -} diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift new file mode 100644 index 0000000000..9e06aafe72 --- /dev/null +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -0,0 +1,81 @@ +// +// HUDInterfaceController.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/29/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import WatchKit + +class HUDInterfaceController: WKInterfaceController { + private var activeContextObserver: NSObjectProtocol? + + @IBOutlet weak var loopHUDImage: WKInterfaceImage! + @IBOutlet weak var loopTimer: WKInterfaceTimer! + @IBOutlet weak var glucoseLabel: WKInterfaceLabel! + @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! + + weak var loopManager: LoopDataManager? + + override init() { + loopManager = ExtensionDelegate.shared().loopManager + } + + override func willActivate() { + super.willActivate() + + if activeContextObserver == nil { + activeContextObserver = NotificationCenter.default.addObserver(forName: .ContextUpdated, object: nil, queue: nil) { _ in + DispatchQueue.main.async { + self.update() + } + } + } + } + + override func didAppear() { + update() + } + + func update() { + guard let activeContext = loopManager?.activeContext, let date = activeContext.loopLastRunDate else { + loopHUDImage.setLoopImage(.unknown) + loopTimer.setHidden(true) + return + } + + loopTimer.setDate(date) + loopTimer.setHidden(false) + loopTimer.start() + + glucoseLabel.setHidden(true) + eventualGlucoseLabel.setHidden(true) + if let glucose = activeContext.glucose, let unit = activeContext.preferredGlucoseUnit { + 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 { + let glucoseValue = formatter.string(from: eventualGlucose.doubleValue(for: unit)) + eventualGlucoseLabel.setText(glucoseValue) + eventualGlucoseLabel.setHidden(false) + } + } + + loopHUDImage.setLoopImage({ + switch date.timeIntervalSinceNow { + case let t where t > .minutes(-6): + return .fresh + case let t where t > .minutes(-20): + return .aging + default: + return .stale + } + }()) + } +} diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 006ff237cd..51011ef413 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -16,6 +16,7 @@ import UserNotifications final class ExtensionDelegate: NSObject, WKExtensionDelegate { + private(set) lazy var loopManager = LoopDataManager() private let log = OSLog(category: "ExtensionDelegate") @@ -57,7 +58,13 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } } + func applicationWillResignActive() { + UserDefaults.standard.startOnChartPage = (WKExtension.shared().visibleInterfaceController as? ChartHUDController) != nil + } + func handle(_ backgroundTasks: Set) { + loopManager.glucoseStore.maybeRequestGlucoseBackfill() + for task in backgroundTasks { switch task { case is WKApplicationRefreshBackgroundTask: @@ -121,9 +128,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } // Main queue only - private(set) var lastContext: WatchContext? { + private(set) var activeContext: WatchContext? { didSet { - WKExtension.shared().rootUpdatableInterfaceController?.update(with: lastContext) + loopManager.activeContext = activeContext if WKExtension.shared().applicationState != .active { WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in @@ -161,15 +168,15 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { context.preferredGlucoseUnit = units[type] DispatchQueue.main.async { - if self.lastContext == nil || context.shouldReplace(self.lastContext!) { - self.lastContext = context + if self.activeContext == nil || context.shouldReplace(self.activeContext!) { + self.activeContext = context } } } } else { DispatchQueue.main.async { - if self.lastContext == nil || context.shouldReplace(self.lastContext!) { - self.lastContext = context + if self.activeContext == nil || context.shouldReplace(self.activeContext!) { + self.activeContext = context } } } @@ -222,8 +229,4 @@ fileprivate extension WKExtension { var extensionDelegate: ExtensionDelegate! { return delegate as? ExtensionDelegate } - - var rootUpdatableInterfaceController: ContextUpdatable? { - return rootInterfaceController as? ContextUpdatable - } } diff --git a/WatchApp Extension/Extensions/Date.swift b/WatchApp Extension/Extensions/Date.swift new file mode 100644 index 0000000000..d70251fec0 --- /dev/null +++ b/WatchApp Extension/Extensions/Date.swift @@ -0,0 +1,20 @@ +// +// Date.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/26/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension Date { + static var EarliestGlucoseCutoff: Date { + return Date().addingTimeInterval(TimeInterval(hours: -3)) + } + + static var StaleGlucoseCutoff: Date { + return Date().addingTimeInterval(-TimeInterval(minutes: 4.5)) + } +} diff --git a/WatchApp Extension/Extensions/GlucoseStore.swift b/WatchApp Extension/Extensions/GlucoseStore.swift new file mode 100644 index 0000000000..a71520a09f --- /dev/null +++ b/WatchApp Extension/Extensions/GlucoseStore.swift @@ -0,0 +1,25 @@ +// +// GlucoseStore.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/26/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import WatchConnectivity + +extension GlucoseStore { + func maybeRequestGlucoseBackfill() { + getCachedGlucoseSamples(start: .EarliestGlucoseCutoff) { samples in + let latestDate = samples.last?.startDate ?? .EarliestGlucoseCutoff + if latestDate < .StaleGlucoseCutoff { + let userInfo = GlucoseBackfillRequestUserInfo(startDate: latestDate) + WCSession.default.sendGlucoseBackfillRequestMessage(userInfo) { (context) in + self.addGlucose(context.samples) { _ in } + } + } + } + } +} diff --git a/WatchApp Extension/Extensions/NSUserDefaults.swift b/WatchApp Extension/Extensions/NSUserDefaults.swift index b45ba55feb..cddb16ca67 100644 --- a/WatchApp Extension/Extensions/NSUserDefaults.swift +++ b/WatchApp Extension/Extensions/NSUserDefaults.swift @@ -12,6 +12,16 @@ import Foundation extension UserDefaults { private enum Key: String { case ComplicationDataLastRefreshed = "com.loudnate.Naterade.ComplicationDataLastRefreshed" + case StartOnChartPage = "com.loudnate.Naterade.StartOnChartPage" + } + + var startOnChartPage: Bool { + get { + return object(forKey: Key.StartOnChartPage.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.StartOnChartPage.rawValue) + } } var complicationDataLastRefreshed: Date { diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 11c9114131..6b8240fd32 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -69,4 +69,20 @@ extension WCSession { errorHandler: errorHandler ) } + + func sendGlucoseBackfillRequestMessage(_ userInfo: GlucoseBackfillRequestUserInfo, successHandler: @escaping (WatchHistoricalGlucose) -> Void) { + // Backfill is optional so we ignore any errors + guard activationState == .activated, isReachable else { + return + } + + sendMessage(userInfo.rawValue, + replyHandler: { reply in + if let context = WatchHistoricalGlucose(rawValue: reply as WatchHistoricalGlucose.RawValue) { + successHandler(context) + } + }, + errorHandler: { reply in } + ) + } } diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist index 44c9ce1e78..e09fc24903 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -48,5 +48,7 @@ $(PRODUCT_MODULE_NAME).StatusInterfaceController WKExtensionDelegateClassName $(PRODUCT_MODULE_NAME).ExtensionDelegate + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift new file mode 100644 index 0000000000..45a34b59f7 --- /dev/null +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -0,0 +1,31 @@ +// +// LoopDataManager.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/21/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +class LoopDataManager { + var glucoseStore: GlucoseStore + var activeContext: WatchContext? { + didSet { + NotificationCenter.default.post(name: .ContextUpdated, object: nil) + } + } + + init() { + glucoseStore = GlucoseStore( + healthStore: HKHealthStore(), + cacheStore: PersistenceController.controllerInAppGroupDirectory(), + cacheLength: .hours(4)) + } +} + +extension Notification.Name { + static let ContextUpdated = Notification.Name(rawValue: "com.loopkit.notification.ContextUpdated") +} diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift new file mode 100644 index 0000000000..4b496314e1 --- /dev/null +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -0,0 +1,298 @@ +// +// GlucoseChartScene.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 7/16/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import SpriteKit +import HealthKit +import LoopKit +import WatchKit +import UIKit + + +// Stashing the extensions here for ease of development, but they should likely +// move into their own files as appropriate +extension UIColor { + static let glucoseTintColor = UIColor(red: 0 / 255, green: 176 / 255, blue: 255 / 255, alpha: 1) + static let gridColor = UIColor(white: 193 / 255, alpha: 1) + static let nowColor = UIColor(white: 0.2, alpha: 1) + static let rangeColor = UIColor(red: 158/255, green: 215/255, blue: 245/255, alpha: 1) + static let backgroundColor = UIColor(white: 0.1, alpha: 1) +} + +extension SKLabelNode { + static func basic(at position: CGPoint) -> SKLabelNode { + let basic = SKLabelNode(text: "--") + basic.fontSize = 12 + basic.fontName = "HelveticaNeue" + basic.fontColor = .white + basic.alpha = 0.8 + basic.verticalAlignmentMode = .top + basic.horizontalAlignmentMode = .left + basic.position = position + return basic + } +} + +extension SKSpriteNode { + func move(to rect: CGRect, animated: Bool) { + if parent == nil || animated == false { + size = rect.size + position = rect.origin + } else { + run(SKAction.group([ + SKAction.move(to: rect.origin, duration: 0.25), + SKAction.resize(toWidth: rect.size.width, duration: 0.25), + SKAction.resize(toHeight: rect.size.height, duration: 0.25)])) + } + } +} + +struct Scaler { + let startDate: Date + let glucoseMin: Double + let xScale: CGFloat + let yScale: CGFloat + + func point(_ x: Date, _ y: Double) -> CGPoint { + return CGPoint(x: CGFloat(x.timeIntervalSince(startDate)) * xScale, y: CGFloat(y - glucoseMin) * yScale) + } + + // By default enforce a minimum height so that the range is visible + func rect(for range: WatchDatedRange, minHeight: CGFloat = 2) -> CGRect { + let a = point(range.startDate, range.minValue) + let b = point(range.endDate, range.maxValue) + let size = CGSize(width: b.x - a.x, height: max(b.y - a.y, minHeight)) + return CGRect(origin: CGPoint(x: a.x + size.width / 2, y: a.y + size.height / 2), size: size) + } +} + +extension HKUnit { + var highWatermarkRange: [Double] { + if unitString == "mg/dL" { + return [150.0, 200.0, 250.0, 300.0, 350.0, 400.0] + } else { + return [8.0, 11.0, 14.0, 17.0, 20.0, 23.0] + } + } + + var lowWatermark: Double { + if unitString == "mg/dL" { + return 50.0 + } else { + return 3.0 + } + } +} + +extension WKInterfaceDevice { + enum WatchSize { + case watch38mm + case watch42mm + } + + func watchSize() -> WatchSize { + switch screenBounds.width { + case 136: + return .watch38mm + default: + return .watch42mm + } + } +} + +extension WatchDatedRange { + var hashValue: UInt64 { + var hashValue: Double + hashValue = 2 * minValue + hashValue += 3 * maxValue + hashValue += 5 * startDate.timeIntervalSince1970 + hashValue += 7 * endDate.timeIntervalSince1970 + return UInt64(hashValue) + } +} + +extension SampleValue { + var hashValue: UInt64 { + var hashValue: Double + hashValue = 2 * startDate.timeIntervalSince1970 + hashValue += 3 * quantity.doubleValue(for: HKUnit.milligramsPerDeciliter) + return UInt64(hashValue) + } +} + + +class GlucoseChartScene: SKScene { + var unit: HKUnit? + var temporaryOverride: WatchDatedRange? + var historicalGlucose: [SampleValue]? + var predictedGlucose: [SampleValue]? + var targetRanges: [WatchDatedRange]? + + var visibleBg: Int = 1 { + didSet { + if let range = unit?.highWatermarkRange, (0.. SKSpriteNode { + if nodes[hashValue] == nil { + nodes[hashValue] = SKSpriteNode(color: .clear, size: CGSize(width: 0, height: 0)) + addChild(nodes[hashValue]!) + } + return nodes[hashValue]! + } + + func updateNodes(animated: Bool) { + dispatchPrecondition(condition: .onQueue(.main)) + + guard let unit = unit else { + return + } + + let window = TimeInterval(hours: Double(visibleHours)) + let scaler = Scaler(startDate: Date() - window, + glucoseMin: unit.lowWatermark, + xScale: size.width / CGFloat(window * 2), + yScale: size.height / CGFloat(unit.highWatermarkRange[visibleBg] - unit.lowWatermark)) + + + let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) + minBGLabel.text = numberFormatter.string(from: unit.lowWatermark) + maxBGLabel.text = numberFormatter.string(from: unit.highWatermarkRange[visibleBg]) + hoursLabel.text = "\(Int(visibleHours))h" + + // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end + var inactiveNodes = nodes + + targetRanges?.forEach { range in + let sprite = getSprite(forHash: range.hashValue) + sprite.color = UIColor.rangeColor.withAlphaComponent(temporaryOverride != nil ? 0.4 : 0.6) + sprite.move(to: scaler.rect(for: range), animated: animated) + inactiveNodes.removeValue(forKey: range.hashValue) + } + + // Make temporary overrides visually match what we do in the Loop app. This means that we have + // one darker box which represents the duration of the override, but we have a second lighter box which + // extends to the end of the visible window. + if let range = temporaryOverride, range.endDate > Date() { + let sprite1 = getSprite(forHash: range.hashValue) + sprite1.color = UIColor.rangeColor.withAlphaComponent(0.6) + sprite1.move(to: scaler.rect(for: range), animated: animated) + inactiveNodes.removeValue(forKey: range.hashValue) + + let extendedRange = WatchDatedRange(startDate: range.startDate, endDate: Date() + window, minValue: range.minValue, maxValue: range.maxValue) + let sprite2 = getSprite(forHash: extendedRange.hashValue) + sprite2.color = UIColor.rangeColor.withAlphaComponent(0.4) + sprite2.move(to: scaler.rect(for: extendedRange), animated: animated) + inactiveNodes.removeValue(forKey: extendedRange.hashValue) + } + + historicalGlucose?.filter { $0.startDate > scaler.startDate }.forEach { + let origin = scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) + let size = CGSize(width: 2, height: 2) + let sprite = getSprite(forHash: $0.hashValue) + sprite.color = .glucoseTintColor + sprite.move(to: CGRect(origin: origin, size: size), animated: animated) + inactiveNodes.removeValue(forKey: $0.hashValue) + } + + predictedPathNode?.removeFromParent() + if let predictedGlucose = predictedGlucose, predictedGlucose.count > 2 { + let predictedPath = CGMutablePath() + predictedPath.addLines(between: predictedGlucose.map { + scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) + }) + + predictedPathNode = SKShapeNode(path: predictedPath.copy(dashingWithPhase: 11, lengths: [5, 3])) + addChild(predictedPathNode!) + + if animated { + // SKShapeNode paths cannot be easily animated. Make it vanish, then fade in at the new location. + predictedPathNode!.alpha = 0 + predictedPathNode!.run(SKAction.sequence([ + SKAction.wait(forDuration: 0.25), + SKAction.fadeIn(withDuration: 0.75) + ]), withKey: "move") + } + } + + // Any inactive nodes can be safely removed + inactiveNodes.forEach { hash, node in + node.removeFromParent() + nodes.removeValue(forKey: hash) + } + + isPaused = false + } +} diff --git a/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-38mm.png new file mode 100644 index 0000000000000000000000000000000000000000..3db2e8a0a2da1c313a8c7a113a315df3c5fd4149 GIT binary patch literal 2658 zcmZuzc{~*A8Xh}?EF)WF8i~OyIii@cXJ3bF$tmk#FpHUyLCJn(-%@rJDwRD;$a2QKq?`Wlwb@5j1owo zy3k+*iu89UfB4bEQryTm5*0@zfRFsTxDx%TXb9ve(4XT+o_Nxqfe4fztQh2u~~-=7VRclP&;H#l9rI$|zwtaVQwl70znNn=ha#)few?}RoQ3Y8c? zdo*1HpnNu1--pL6S_exiI*_3X>ZlrcViDw*Gq^E9gIL zze*hlr#6ZYKdI(FQQ}})?i0;++-+dkNu_X*b3DG(Q=aN0p3rJq`$Z$QY;#rPG<%v=u#9%)g1CRy=T)y(>rhiE;Ao*doOyJ0{#@m z-gJqrfR*dfEesy|6#sgy=I(;-*4gv{QdeJXd|CyCg*1% z5#|22EgV&JPQPt1L^2UnU%KcY^F<^)$fnZ-4IQ#sOmLs}8zMnU~ShR1pnzgVNg!XHC^>H zy7FSoukcalR?P4k#Vz1a;b-jQUH@xHtFM0MggzfnhaOXCnVG_Y7mNEX+f@Fn6LN{G zCZWIMM>`_t=^W{Sb);E!_FfOy(}udr@RL7z3KsJ4u+#lPvn+)T^DK_o zUfUQ8tx8i=Hz4@5hqBs_OuE+1u4=QH(9^axFkjCBbc_Tpw_ImSEOd%7Ugs2gnxwPf)l(C0=$YtH(<1YD8llYQ~~yNVe>vq{3kF zB6Tq-12MJMq%9yL6cJX`;!a?`c~_4k46Z^5Bca^fi|&u_6E>kB9d3O6c^z#@1=W@U z`ZGf}d!C*46ygZ;Ub=es+0HlX<2G}0V%;k5t_un#SBSOT9&v7Vtnd#K7K=Y4V~tS; z>UK6m3LEX}zmzRmpjDq=*}!*7vYnb7P(>? zRT!mae|<0Rfia)d;oDiAg`Q!0bk6ePwqZ!=_RDH%{&yAaQHHM?ywjS3{LC-eO!Y!f zt#B95y{q-fy2%qw5|22EUF3LOrrSO;M}mII-^NLi+u9}*J^Q)Zno#kW z%Io#L7o)!DQAI4Ktq=WPj<(4(ESB?W>6eNE^bUk zK1ug`F@w{q!joSP{p-OsVT)}8*ZKO%`43SyHrA^X&t=t1K1Uw{e_aSH!s=ha9Yk~m zSvyY5Vd*T#t#UPtKM1COmLZE+Uc&foZH<)AHzDxnZUkF#YPA=VB~E*g{g@}C#0^OO z*`_T0=Lt`nj-iIW#@9QG-sE^$O^wqeTO&8M>K}1gNj5A>T*+@(@}cl7=c!134DP%T zn=gM1y)Wgz{h8wMG!d5kB)KiNcZe$yxPS1xyu;b?w|$ zYyib)^_JwVxu(7 zVhQj#$H}4E=NfYPJwYUu2qTOviREbSZ?&#NSuot0!t|ekb4rDmdY?SX2$%sycyIWv z4&U>T&sjc^wa1EBxfASIU9K5DKH}E0<7xUzcvMnnMQg;$iA+$I4C@b=tY}`d4dIVQ78^!~^xaF!DcRHCQ zC6mAuon$VQK@Y3J(fS`~%NHx6ZJdJo->rcarC(>%%%5b3uhDs(3;F!YMo4n-?qP_DgZg@W7=9^oX`%@~KoQG*n zzh8Vfw&Gl~)8L|j?e*&4&3W2HtX_MYu7pkN);82j$>VY|#T{^?mi*4&-hA^s&%07E zd+L^xW1*2(Q`>gLMOfw%i)>}ln4HO(*clTXU1Tu`p#%?~Wy3vA4PKSa$5SPjpD(Q3 zn6yX5O^JrdT!B62%alzVe00L#X$ZGb!auj)znb&cQ!7rH+n~C H=kWgk7Duwp literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-42mm.png b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-42mm.png new file mode 100644 index 0000000000000000000000000000000000000000..63f7579ca6ca6d0617ce634afbe5061e204c8a16 GIT binary patch literal 2687 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 N1fH&bF6*2Ung9aC$x8qL literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-42mm.png b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-42mm.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f39f99050edebaae4533cafaf559ae81ea56e5 GIT binary patch literal 1570 zcmbW1X)qfI6vt!RI;w8#iinnKDI4uX(%NEFEM_aHb=8@YT7oQskf6)douJj2qHM8j zsiHA-NhoO;RaDZ}5u`yYsUxl>iEDLdI@8^0zwC$q`_FrC-kUe?!!O$v>7Z~_=O_RG zP;hcYTsd(1N6AVb#P_2_^8=In#nC4g08m``D5>bdU+)|gwQ=^|ICt!ITmmWv4X_Qt zUPJ3UMWRB{SJ0@C#OQwXMF8MPqZ7jRC;Y_XR2LTL0eHG9k1*9gX&`^XeN?*nxNF|I zs-8gCEl8G+NvkDB!!O%v8GuQDc9mHuI@HsfK|O1!Dl6x9cdIBAPcKh(Wz7w3i5+X9 zt!nn8!YO(}rys0_9VQjImf>-OZ@ZzNq->R?hfu1N8A<;d)Xv zt?8EIqG7gZm(0uKbIzrtwukKOT{P{=d?$~&yy2{5P3D5l62}8kVgpmK3whJ!p~VP% zs`1R>{aEkmSZ5M{^pdQ&e&)lQ^x=8h9H)t>8USx@jjEu+M{6}HN^HZ;hQ(EeGJ*Ty z;L<*N@?>tR2E~S&36>4mSkT)$uNd4DqfJrjnYY6*S85b^ec5#jyd}ZEe!{S{2k++v zOWesT6%jD=&3h{-b;=fd-sYwf$jkik5JA#yDw>NryI)@La>pv!E!lqn$sGBu)EXql z%tDi00xj~iL%aJrRCccT?MedtkZeeVU2v69)2n!S$gievZGIqWuRd&X#VDY?Gy~Eh zxWCRaj2EEk0#5=};kaV5#HgpTks!LQj=3WeO{ei(bU)}88}&eR6?O}&>ic__# zH)t2qd4mmD@0-lqT>b`m|kRh%E=@$ZRv~wYTf@E^Mp$0f*a>LW40jH$+7D z0`=7zbWgDuTeE{@tj0s}XUc6(gN&sG%_8CXdA|6yEkVM8dasQwmo;*H&_e}7dY}-c zNT#rYtSrVT?~*`AW?mXjdkVLq8{)^fzO=fIeE;jSn{}((_M5^wc9k!cWDL|p;bW_K z6)&<0@$r1yiS0h`*_G0vx}%fwex3DXKe{ATJuHUMKIcm;u`c)2b(6&9+Dy@40sF7AvO9cl z(8>+^qmm#&wRmyS`$Wqm(+N(O8U<^jmp`h@WH&HcR-aXQOahQ)uy2z=J>a24;w^uZ zv%^^C>+c74XPCJQIZ#bwRsrUWLaHLmr;@jrmBwFawav4uu8)8+aL=-0GYG$D$Jbta zyfgfO9ibIRF&dgis3ljCwLBb1^pk-T{Q;WA4k$akYG+{E(!#4od6-w(Vy}B+W*F8Q zBAHCCA1v?ecevpe_JsPpFbD=dU7<*d&2$oM=am1TCubQpJ^(L(PFK(|x-JdYgPl_7rgH1hFp1;ECM<-vLhcNW`N{L1}*jdA|vY literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/Contents.json b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/Contents.json new file mode 100644 index 0000000000..e835ece7e8 --- /dev/null +++ b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "watch", + "filename" : "1-hour-graph-38mm.png", + "screen-width" : "<=145", + "scale" : "2x" + }, + { + "idiom" : "watch", + "filename" : "1-hour-graph-42mm.png", + "screen-width" : ">145", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-38mm.png new file mode 100644 index 0000000000000000000000000000000000000000..6f60342c722470b31762a4ea2d6a01da940e2121 GIT binary patch literal 2969 zcmZuz2{_bi7at`=lw_$aLzXBrV_#;52q7l>(#_sjVisd$YHV4e?EAhX8Yb%)gb;nS^6P$0Y8 z7<(j$fWp$y004}jOgo~Gcsn2g<%af9CcuS$Ae3q6Lp4|k_ydA>g$r5e8vw6ja7dsW zNEQSUQeg%HfiT={M`a@otzYIe2`=P}$77YjU@tE(ke3VygL48)DJdy|A<|%JX-OJF z(&HW)Z%2?sdtCVGP?{8LzUw)eXVD&Q?j8+=ut2m?`9)mN%VBA#H4r>S0LD@MW zjgWS?@4>+TMEsR0?9iq14V(*-#`TbqiWCg|FWoOZ417rUf9U_L?GG)jxGK!FNPk?E z3bQz<6HdEoCmjtn69V00ik}vQM*;gXvv@Onl{BN(_ag z4aB8fICD*M9z_EsiX4Yw6^<4mPk>5Eztw^%+F$vY^RP@-*1&U49z>$v*X2xiDbI$EZw3rqS>Wb)_Q zi%LY8!#J|+j6}}H$(>H`>ntq=2A_T`6S4P~1r@6UWB9E7; zN$;e^x*e&j3P{dBzg|HG2!^_gw`D%rO1|wC!XBz{GbJWk*G}}-bk%qbYn5irQVZR?TWZ7z=7;wwqW1J7$9w7J z3Z*n6my-Hse@jdZo(1{SNmOBcF+D>ICErk(PgIJ(^1{m$wG$IMLuGq}B)%Bg7lxpG z{gGEIi{T8=;0p;NXVYvVQxIT3COU=)DgU=>gp7yX_NAeG!^e1(KZ-o%3(Sx+_F763 z2;5$#%2zKnIgTkZXtS3KKf=y{czNL2k;vq{>I-R~tIoQSVqhZ~R$_TO((aqb_1t+s zhA^p(mZc~X&|5o(NAvg<*YI^cAIBRnQ%TwBm4cwS#VA(E=|pdJ@r~$#`hHR9Qj-5^ zayz{#N%!?4lmZWir;LpWav5^cH-#3T&Jsq^+F4_rB?eLGCNzcia&*rBif{%?iCHkNQpK1R!z0eI?4A z|4NijdrQ8pO}3(7(XnDlV&gmfPnP#oiX$Yduj==$%sh~?W-(TQ0HV~k%BOG^Ivf&P zQOjMsgBOp6JMFiX%zPy-eL60qlItL&<-K7))Z2Kj*O}NyefG%q!%pV(lyr#4<|X%W z8*uy0>ao@2lbWuYS&?DcoGZFmcR#}>!fHJC%fanrTU3KkBtA+EYfPfKL_ zEQu$pes^lQ;hA13&l}nBLef^nECI6=E*rASN$p`#NSgAb0X?+{_7Of?pe-=HBm96K`G_3mz%%`7p_r zG~v$Wx);BRurMx3%6ywHHDEr<4!TpT@$mTpQ(1heNY68%fse;W1FBg^osB5{d^LAc z_JjA9A}`kZS6_FtmqW!r=#t~1OHBI#1x2W+cOWz$$o1=>qwEO=*Q}v*&N!=(`Xq_o~-w)_Zx8; z+mzkXerg91K6ZE0joDynCJcQqnX{Y!x~D*)G^O%-Y&Qb3Idej|+|AaT_0-h4v?E1> zk0T~3<>UxCiFln|p5A3g+)eF-wa~QWOmsC*L~S+ z3NpWRPY0L9aV&EC#$A@28=s3p&rCiI8LT-ujcWDlt?|rtN zI$Y|asIDOHQBC3UjDcfJ$)35+nzx-g;T zy5hCL_VmPoQd6e!$E{f3ucsn)RGt0SU*Sx}VT>&6$}7y_D|}iw6P^8h5z>95wn4Ay zt}mBNDlEneJU@eabJdF8EAdRRrl z(uJ}A=y`{l@^y=)b10KPaNcUSR2b?cY&~)l(}~G}@Tnbfml;;LfWlzht0g%YR3` z%qhj*l`HAZ{oVwtcZx*@?cX!(U*^6;h>h4=iNlhg-{rp21d*SAdP5H`mV8!YpR1-4 z7Ghb(cDaD4cweWsM zP|F@)-xu30mpvPgnd0XUS6av@aRsDWA0*}&8eVE>zjKE1;W;NHsQum;;#jA15af#m zD;Hw>6j*%mTWQ3V^`Tm`ZTIN5C#!W^_4BH9vC#Ew_+03@tQ}#1kNn$KtA#AIXQMbh zPn|iA#}jX%xW*Y@onXxPj8}fTy>20JoUVOutqH=vM2e(6l+DI78xr#i-@zMr*E;{4 zBnJUlx8>zF!T9D3+wH(dYQ#y<>z0iC#WJ^M%G)R+Uh62$Go#BVjgqK)94q3nq+l9xYm&R3l^TO_)DAJD4w?LS1bd=4E z?KO3hlbiF+9H*$(%ns1jt%6kYTA#O%ZXA5jSr*`?lvD;uti(nhK{wu= zhBr{YW!e;525(H7e%MHlHQ%W|mevqu%#xB&)bQS1Vt@+c+QO>KHuI%yb}jCxyI01b zQ%@Z%^9A?u_=IO0zc4F?JJeyr_4?fVE0zWCNd3E8`CBj3!+Yd_ZgO~F?#S9T#lwDC MM-!n@tZp0dUz9&!xc~qF literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-42mm.png b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-42mm.png new file mode 100644 index 0000000000000000000000000000000000000000..a09880e901a1aab3fe182595664e2380514a3de4 GIT binary patch literal 3097 zcma)8c{~)_7a#jN){1P6Jz-{yv1aVDjcu$IGWKnZXC_R@PVx{!h|0bvN_Ik{$XH4u zS)we@l8k-(QN8zm+aK?KKKGt`&;6d|d(J;6&eT{J$jHM8004mcdfKRCX?n8hX^+oD z_V1O)f&z!q)dWI48D4oaR2}l=gFo3WZmaJ2K0JZ*x+r9kjhS23>fWA;XV62}TL`F$T2?B*dU@+-pgfx!e zgGUES`{0CsI{DL&whPY5&%+n*f%O5M_(eNnuj3IyLMMU#TEFtd`2H2h2ltEBF+WHk z+7}`NhC=?I5#Gb~@AUq|>g4CA*$-AfgQ*;sMp?_x1&zo0Szxi=sv0M?1L- zN`U(i$E}{z*VeEIq*%@h^5)Y$+uoM>DQKIt<(ma(MLoNHg=Un4KH4gzpnzzcj8adC z0)~cJ?CK^^pBno>qAImNV&`y-1(-bqYU+V>;1mp{=XJK1Vu+#!cWWYtlfWQCd^GVz$;Hf)hR+!jBhJPfUgW7$53-*cq z$ig!aW5?Q88Ppf_Y})yw1^{|_y>XDP7SEm%^3SbrYP7XEm!>LF3YCu_jhv>b%qshY&m>xb3 zkT37vIijr)rnQ-}W!<*jq@gGwLhAjyrj{N!HK{g*g1KzzLpba*^~DG^JU9kswvMX1 z^4je+ittUSVu&+3%Xfy8BeB(|V**G+1knst-cF;Y(@SE93dgDmS-LIVD(fp4driW0C_47KQbi?uHUl}5y=1j)>%bF&+TfU zCj8M_U11~b(NffGMse%p4T>{>uCYD!es7(cJ(FvZ?)w>fQeyFMK{-ON_Gi_cefIB3 zA#-%QS!m7H%FM~kN zhINk%B|eVqx_87O0RCuB&-`$8CYARCa1UdwnZjUvoG-ADDAt@P@DMuQAzJ~?NpFy% zW%Cd!NuXb)uCweyJf9on+uTDp27wqJ1tv)$*=poPFlc$rIfu5MUKySWRB0t`qAl6) z;%n8!Z`4d9gH-(7lhNPomCkl74W-W6GnjSCeV}+Zgo4|jIhvUIR2DgWmEbvJ_vrKL z7$w;}nJxb2hy$^u=H4|bm^M52L#Kquu`hzQYpoGP%m^`r0K=WKpSzWn1u@DaiXqJb z^-sN|M@2$NgLMXZhTqsSUFP&ZR+N-vWG7Rl!8UwF(xcZr#RIXh$1)95*AqAle%{x1lVD-oEI#TL){`ky)3c?-hY|H>JJyPw(KTVMu!y?l%0oEq`Q@_qdS!rNgN>4+ff>N4{|4oO zwGMj(YTWqT zmbHfUAQ7m#$H?>rtPiY(FrO;*WOl^QlNoh25$s6NjaaDQ<;5y8?`0-H?%Qq*#XU_6 zD;LR1Z#Qk0*Rd_7)PrP+dRjF~uJc3l)l&ScUUdvYt@h<>u*JdR(6gkbf;#+SbG9@sUHX+;=0d?NS9A;?tXsFza+F#jPilO z@S}7L3o9B-d2s?#AHUaEv!%T(H6IP^)uMj?wz284K_x`={@}~}rV-W|!|LicNi+6Eo=~#a9*}+S&cxTk#@H)Jq8ic& zQI|wAybfHt7tU3gbo=wW>gjr3&W1;^zlj!M7FtT(^Mc&39~2>lRbq!38>k?HTkxSa z)dab7DABM(i!`jHj_qsGvPXY15lKeuq+{+$eqI}&JIE~$?Cc&moE4JDX(~;K)`5+t z5#n^ruU1z`LZf2Mw))oWDwCMr)=zrZ&q?Rn1Vi0oGH??1ED$@n6oW)evE?E;J=|nx zCXe@F>}5aTHq=sn>Kuk_s2f_#qxQmRd+hSIYZRy@Yv_b+OMQJF3H;B93?2&@-P=E5yqhYB)_lT3}4DZ59iz#1&z0LrPQihZ)H z{G#LQXjp^wjw(46&&h24So@+_cYlu~=>@7UYGG;ONvqZA;F6=Rm9K`|wUJ}3eC^kE z0gd5igoPzDDc!`m7lBct98@K*bw3F`)nQg(uFGzz3FxdZ&J4Q_XN+!v)p=fvz7}xC zVW*&HaxB@^|2|BjMKCmGpiWR_5#yMX-I>2J&lDQIW2_AgUNCyeFO5Tc-tt_2I4M(| zB*-_PIgdre%!R}l`+R^`Tg}#VpLH_S5{qvfS0v@F6>bY`Gu69q%FmE8*v89|jQcoB zb4eAvJzJ()*q2(1R@9Bt)K&Ch`SUu}OuGf!8qaYRMO#9 zn$!cY`-7jnxu2gaS{9WQ6N;9Y1AN#iX2?a~!t!h($kKCf;-2wstBzb>xDj3`59lqn zgDXY_^2?eE7ML~{I5{O{yNQr5j8Py{^sJB$ZU!U?s(GEsEf3xUD&Y6Lqi~(vYLjvON(H{;qHocr1DD#X z4f%ugLi2($PrNK0)+;R>M9 z=$n@ynk?6F_^OM|dL~d;h{0EryhC`wS7C{qD-9eS7oK&9M`z?NuWnS$Hz@;3SMDXF z1Qcs$#DHzs!q*TFTZy5`fv*Q%4mBa96q+n%t+%>2t(!)&->eg3DFNhjHwGPs*mAYR zFb}V6`WMcfVOfot8B7`Fi?*t~Xq=1%1)`BeY-sLCgT)<lkZSX*xvw3kP0@bpQYW literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-38mm.png new file mode 100644 index 0000000000000000000000000000000000000000..f4ff6ee7302c9d30094afaab813feaa34845af87 GIT binary patch literal 1430 zcmeAS@N?(olHy`uVBq!ia0vp^Js`}%1|-)VaI^zboCO|{#S9GG!XV7ZFl&wkP*AeO zHKHUqKdq!Zu_%?nF(p4KRlzeiF+DXXH8G{K@MNkD0|TqFr;B4q#jQ7U?K@;bB@W!5 zxBO$*1a2`iMa!M1Oe}+!TyQX((<|hW6U1>aH;GBJ$amXWHkOC0RyWO!o?)+asH-=D zX}Z(pW0y{eGPCK}7}lF^_-Xe}x?<*@o3rn}dHbhwzkJP}vv=N{G5UP_eeLIGZC1P);iW`u-*;bzyj75i#8AATs`-+T4xp}yFOlFyWu z@2fhVYj!Q)D|F(Sm=y+B;MPLTznnPDdBe$?-_ItQa0_{_w7+}tMnvqp;wehvZYq|F zXEQtbRsT)9Asg{_W!swH4!72;TuYjkt`-zHOX&NPr>2vSrkeYm^RJC-eXUuQjc@=w$H1N*u(~%HSRWv_jtqc=VyIqxN6;`9f_w){yGc!e|dXv zS8>?;KRO%b6JMSFs<$Pf&-Ykl}xQ-{% zZtCQHLX%u4KFz-LW+T%L_oX{EZpS&y_;r!%iq3z}KN?OY+p9L8EID;=^1Wl6Ei2{S znNG6_J^z&Fu;iuZW&V_z!5qQ+lfr*q&h6W-az{GZ=V@8OO~@G4|_9kc+PjY#6v{2QD-KiFwpJwix`YfVNr|SAI zrz{15#AOl6F9d3hzB;`BW9F@MT3NR!_f)g-+oNCv3La&wc&-+=M)htVl1#9zXpFlfO>9E#rEX=hDowXCL+EinpIAa{YF>>#x%S z5!dy;KCeV4Ru-LD6l`&O<29Y7B56%C)9)Gee>V0Hb33Kj?YG}%aypm#p%9(V5>+#+ z55%oj@Q#$!Y}fg9dD7++L5hiPXKc<*K3f0R38eaS>cp??E6-o}FztVGPGfzKTt~yQq>ltzFDZ8iDUfUD3`Y|ta#tY%S(%*V!ty_D2^Dmx6 zuC#myvAsFhSLFeK4y^q6guxKe9 yW;n#4z=ME5E&>u1R^UNW4KZj+Lpl==!@aL_uNe71KMpKY89ZJ6T-G@yGywqQ)_e^B literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-42mm.png b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-42mm.png new file mode 100644 index 0000000000000000000000000000000000000000..c43ed36fab4efc7196b5bac01fb19d147e930b30 GIT binary patch literal 1941 zcmb7_X*e6`7RO^cYN{1WEmOOoLT$MhgW4-%4N|mJXhKj$TKh6digt!-RK{M6ky@%% zI?-AhOYK|HSgNs3kxDDpkdi1@@4a*H%sl-vAI>??^LyX-|DJEZR2Mi*NKjf3000Qt z*;*qGC-rlEbM&yAW~>Dtj$_wsJtF`BVCU!J%RU2?IfSPop%)|1V*?^%P~jMWr9T#p zQM3z11!52wlz-gyK8y(fAONznwseb~WX+i05qFn;v0=mqg}n6=oEMdOyc)vaE-H51 zZ(btjbRkhl#T#1oR@cKoHnoT!Hugie^1iy>P1A#M7}^T+ru$newcS=EqNS%Lu@My* zvzm_N3@;TlIY#fV1aNqGb^5mosV+FU!WKG8T_aJ z1^<5dCrrtd`!fGu)*r6_`-?S!%q6TQx8Uf{jBRJQzQ8Bgs$9~MIHjyhB~=l@ibkUL z%HkArB}uErp!HzI;ihw$0_F>A?F|-RUbQ>D8ECndZ$j`p?TF5|ubq!HO^8%m^3v?M zR-j3z!Pn`!`NE7M7vRlJESUE^gdLdYeH@dz@RvU%n$SMS5WCw>sYLC@rg^K&SaM7qK6bfdrit(6ye*< z8jfykA#kpi6_okuN^Ks9`}4i!n(}Z{rvo_q;E~c^VTTqm?jDgAq3`iYD@*vC24w_g z4*8Bi9l)CjFUtdY8|y}N9-I}`IL9#bS;@gx{MaX>9!?PLkC5O^7i)Bp&*>1~$GDk4 zzeIBw>FFiZb(k&eeU!sdJ{If( zNgA%ljp5TpF(kZxtCrA;$g8Z1dRBXTih62HUEhMhTrYOwO7+g_lpAZNRSrH=TbJ8- z_k=1DC`7^uFo|@}MWSm0`IXBc;^|<-d(43h9p=7Qm~Dnz!NGi_|7qV@+qo}Dn~;^K z+!d?Pc-O<5M_Q`#fsY2D&vxB4x5-=jexk9MtErER@pK7_+bq*& z&2bgM88~W39fKDeA^a7hX%+2@%T-*1#9jsW`eVtg4p4lP;x-aIT#o3Xrsovm-arrZ zvvSX-$YU*cx*f)P%|4KOY~K5NF6W@{6tL7=iDgDr3lB>ZMhGvxoK>Ujk!avSj(i^E zC4E$;X3hWchUaSOaorAZye#RrTiRsU(ATu>M|Tfjl6}F0EkC^zE?{oh2i1!nn{{y3 zMx7YVs|2zOZkZU`@C=C+`wUAvz7#lT@X)ZWnY*E z<@lT&Ed=|E$EqfL|0XRtv@YLn8Sc(vTiTyLzm-raI}8<`3iWUs`o1B3=TfVN$E&LC zSydx5`q`yz^>`ikDhkbJ$AoKerjDM@Z2fUFKz+j3iOA6CU!XWwtbwR{0T;67P?7qC z%jAc}=0#c`O0`|LCZ_a)Wsr5DAH|Z!tv={U4FgL$k8%;gmy=mYWx|~tn3d6~{;Ld9 zte-gnwM#O=)+ljTRN|@HLpH|uz2Uq;hhM*{j((sRaZ8-#Z9J;U{f@zkx`~ap^_16k zma2zOh<}Q8_r(VrI!9OD7*IzKtM1CkfezZnGESS&W1O963&~P*0hf5(n5o%|VVRF= zp6+TL?i6-m^!e3=ov}>4@)$C#4kG*Q;-0O9jO~(qy}OojJ1*3G3i3msR?`N8NjPv#H5vp5qOs$OpY$N{#I5 z$$-jmlAQ4QA1s4Hu=Ures+)n2!^WF@TYJBzLoy145", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-38mm.png new file mode 100644 index 0000000000000000000000000000000000000000..d76ad7aa01822c0d732cbd8f204bcc488dcac0e9 GIT binary patch literal 3043 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 MMFXvJjccL*0u895&Hw-a literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-38mm.png new file mode 100644 index 0000000000000000000000000000000000000000..6f18861f4c01d83f5727ae61d1ad9fc19d0d129d GIT binary patch literal 1655 zcmai#dpOez7{^yiayuH@Tyhyw*eo&S5-a!nge?86&2^$RBy=50Lk|UKyuDb zcHV2A@gt-p*D|9SePGSDo^ry*fIu>eKVk!@uo$`qRbuV^V-J%;W6$8DLqOyf4H#ys(ed0%EN?fn1+e0s%5VkuynZjUGE%T=_x)Mn@HJHpa z5){({2}WewZ8N7U^IC?}Gn+U`(2qh%bgwJws=_9`wNx5ixh4IE&NBts)@Pf8f=@56 zeik_2u1X9Xj`j4ssJR?VTClPJ1k1jI0C1Nz8nva@SUF|Gx@@8A0YL9E2Lt%z+E*KiGxEiUpYvaGCQrz-iD-}P2qHfDj zBM5w^U7eZD*o0B;F(|D9k*=?&k-d0W*x%e0_<+yo6k1a^)2i%qeVCCjvX9?Fff2%f z`y?UVQ16S2I&Gm_%k3evQ=3}tmV&PPs0Su-#pg1RB4bzg?X#}OlwPegz@O&j*S#|S znA*_OW5H&+?8ShwEc6mO%}8Hw+~q`>zuyR*>r_ZA%{pJ!c{Y!W6giHKXluv4L#DIZ ze~rZDJTIXDYVaV8-ZeGvDH$fG<6Z$r+Cuw5N6OUvcjf9zKgGxOPI-4>ym*&7qw<2c z(bO?8F5hY8CLwC8&pWdbqzLZOC4G_HykMqR6t1%sPBkYUg6N`7c)nLlp(xB&M(VfOI>z;B48n&D)4nrKjXwNjgKTl*Y6Vk0k8>+SERBc7&F^l;fo+_uyoM+~EWD&h zpIX&vHGNXoB*SOx(aquPQ`5{9DF_!u#ZANuXhFs3{Byy5?G@v4Pk1Qep%?>ETQ6QZ zwejg}F2|kVPKbFu|9Fx^QXp#B=g-_tWbPWR>$ak3%gZfYu&&2aI~0Kk;G)-;)m^hq z1t_AK6WoM`)a2iJi`k3< zM;RB#KZ^XtOHeb359Gd69-2Gu?Js)1$4jpS&~l~7_8ZQ{vJxV3WA$Z>)}dPw11)}^ z_Ak7cW?3$c@CtXd!CEJZ6Dls5&hh-w=7}L1kTL#n=z9YvcIxruG?sa<79SJe19NLJ z$>e`?~b^SX!K$4#AbB$Fqe6%kH`z(EW41*rOzt&neU4HP~t9yAd`x(z|E$@XA_%#je3EYu` z`7^h%wWXcN@S;ges<@laD-1kix!b#~%i$qSyviNYxN+pcxWJmo$42;pI*!G+mYc~; zDned*v$Mk}lL_N4xp?qXVdYlE%&NPO_GZ!-@2J0Q)GMc(5`1$FheeEIsec_s)u!RN zZE8vzR4Q?A_pLm)Bs~N;$%G{2rGEsHl33R|zJ&a#@E=%8;_m`7blJEW^ejnuaPN1A R*K1D$G=Qv literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-42mm.png b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-42mm.png new file mode 100644 index 0000000000000000000000000000000000000000..b4bd9938b596764ce20d0ff8e5e92f176b32cfb0 GIT binary patch literal 2270 zcmcImX*?8)7N0_c%ym_wQIjZQBIH?m*@nq74YJIjY&DavXpE4-Sh8ffvd&Um)702* zm@wDQeOa?L8bw4U``lMpQ{tG=;zb&Hxb zW2Hx^?9jy5OFLQ~+8ezez_iIjY6~7BhkHdI#5)C|4+Da4M4+?AjciCZ1hBa0ujsGp z{~GZ>m>vr_D<&JRXrntZ?u`M2uLCc<6wDV!?iCK<7-2l^dP9a=I^RD-jp*c+qkDT+ z7b%4_B|^WVXaL`Rw9ClIZo+ z8>CmOqI~rmNd&jeHS-)+`Nb7y^)qD{SPtGyV zYR6?IX;06vW~Ji=2bFxHug7NdJ-RI#)v;rJqPo^&4q*sA{ASf z3ivm@AA#FXBI8FLRx9%TLwp=%n9C4OQZDaa9y7w{*hKu_E;Bi8pRjJGp$_HEd3s-iKxZupPlw0mg z$8+4eR#oiT_F)cX4-&&}d=UhNfxR|4JbJ!L?!mB%6-b|Mt1!xg-NULGtlRGr8z4ta z>rmmcmmS8izjHe?y&iPL7v;mhHGW>UzesT=(V2FPhalGy#4!_|#nBk8byF`_PPT zqcwvYDM5<0t=1Rz)>WO(x=-+RQ0GZkL^zS5MT_(czT$kvtYlM2pr%?(BigpF_th zliqD31v-}zG%M;{8NS6^Mrh+>{%h~kd;;hk~P#aU+FKkFC>Bm`RCrKX>ct#4iK$FaVjO{})sS$bhxMfuRp}xyNC~uzJST9sgrEPi z%#@FtfIW$CFd^t1s_*GqF}AkoIYq6t!pbJwu4xEjV6p8~6~^&`bwpiE#NgpencbXm zta4aJl_y8J$}mG=imSo$0mZI~Yg>m%7^4~-MRPk21ed`S0etb;c&A zm(R|CAADnHn$&+KB@yd4O0u`<3aJo>z{G&gK=fw?g_b~eDrfqM_akV;SLu2o`srj5 z+1Vn8r(ZROCpvW%P8Gm5@u3}N>v}VO&%D#_vs~7rZUx~-RC7SN)2IQu=2gA~CC+g@ zrk~z-&`oyGpM3EP92ON~;XvrGb%F;+9~ie37a^1t1JP+jnVoHN(#$9&=vMoM<8ndJ zTJb<3IFo$<{BxxS`g-S(J#(*b$x&fIU&AItmflfQfZgWbT(m`mHLI1HtIdB+x>I&H zbW%^}*P~2>qQQ`b@)OrY*AJT~&#M~32pw?ag#>LR*9l>9zSO`FtQ~6{970{DgqkMu z!2P}|7ah$|4qY?oMkVV!=YP^id@XK$q$m$_tcS+A%CxMj?Q8UjPy5~!nt5WzYAv9f zo$aEF;uf#VPe{c@xspKiy@lvORU)Aam#!Qou?V7U17`iDvab6NxJm`M@)KANm-7aT z7r1s7TP;ibtd)c%to5fgw{^ttG?VXN2|~17Il_=%VU7tkbcl^!(Z}>qwN;kK0%LFk zjT|PJ6C}ihIIRz}7yvbSKEDX7+Kih+wqhoeG9{rrIE~#5>IJ;Tn3UJG7174sH^7 zu=g#NYR#=Q%``JCBPHSm06Y0VX_j$%OYk>x=_`>aQ7{c(aY1E7TtnFTFKe)Ok8GRn sX+=BvfA{~{`5)2$*CbEdkyHjK9d0i#`bSyW?r#FHhdbI-!Tj$10kV)m#{d8T literal 0 HcmV?d00001 diff --git a/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/Contents.json b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/Contents.json new file mode 100644 index 0000000000..17343c647c --- /dev/null +++ b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "watch", + "filename" : "3-hour-graph-38mm.png", + "screen-width" : "<=145", + "scale" : "2x" + }, + { + "idiom" : "watch", + "filename" : "3-hour-graph-42mm.png", + "screen-width" : ">145", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/Graph menu icons/Contents.json b/WatchApp/Assets.xcassets/Graph menu icons/Contents.json new file mode 100644 index 0000000000..da4a164c91 --- /dev/null +++ b/WatchApp/Assets.xcassets/Graph menu icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard index f2b392d588..3afe28add6 100644 --- a/WatchApp/Base.lproj/Interface.storyboard +++ b/WatchApp/Base.lproj/Interface.storyboard @@ -1,12 +1,12 @@ - - + + - - + + @@ -117,12 +117,12 @@ - + - + @@ -149,16 +149,13 @@ - - - - @@ -260,14 +257,14 @@ - + - + @@ -332,7 +329,96 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -359,7 +445,7 @@ - + From b25546cfe794006ceeade77779fe1235c5faf755 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 4 Oct 2018 21:55:03 -0500 Subject: [PATCH 2/2] chart tweaks adds auto-scaling, handles the SpriteKit lifecycle, and simplifies the sync story for target ranges --- Common/Extensions/Double.swift | 28 ++ .../Extensions/GlucoseRangeSchedule.swift | 18 +- Common/Extensions/HKUnit.swift | 6 +- Common/Extensions/PersistenceController.swift | 10 + Common/Extensions/SampleValue.swift | 38 ++ Common/Models/BolusSuggestionUserInfo.swift | 16 +- ...GlucoseRangeScheduleOverrideUserInfo.swift | 75 ---- Common/Models/GlucoseThreshold.swift | 9 +- Common/Models/LoopSettings.swift | 2 +- Common/Models/LoopSettingsUserInfo.swift | 39 ++ Common/Models/WatchContext.swift | 61 +-- Common/Models/WatchDatedRange.swift | 54 --- Common/Models/WatchHistoricalGlucose.swift | 4 +- Loop.xcodeproj/project.pbxproj | 75 ++-- Loop/Managers/DiagnosticLogger.swift | 2 +- Loop/Managers/WatchDataManager.swift | 149 ++++---- Loop/Models/WatchContext+LoopKit.swift | 3 +- LoopUI/Extensions/ChartPoint.swift | 2 +- .../ComplicationController.swift | 8 +- .../Controllers/ActionHUDController.swift | 99 +++-- .../AddCarbsInterfaceController.swift | 41 +- .../BolusInterfaceController.swift | 25 +- .../Controllers/ChartHUDController.swift | 109 ++++-- .../Controllers/HUDInterfaceController.swift | 30 +- WatchApp Extension/ExtensionDelegate.swift | 132 ++++--- WatchApp Extension/Extensions/Date.swift | 8 +- .../Extensions/GlucoseStore.swift | 12 +- ...ts.swift => NSUserDefaults+WatchApp.swift} | 10 - WatchApp Extension/Extensions/UIColor.swift | 34 +- WatchApp Extension/Extensions/WCSession.swift | 63 +++- WatchApp Extension/Info.plist | 2 - .../Managers/LoopDataManager.swift | 99 ++++- .../Scenes/GlucoseChartScene.swift | 353 +++++++++++------- .../Scenes/GlucoseChartValueHashable.swift | 87 +++++ WatchApp/Base.lproj/Interface.storyboard | 16 +- 35 files changed, 1035 insertions(+), 684 deletions(-) create mode 100644 Common/Extensions/Double.swift rename {Loop => Common}/Extensions/GlucoseRangeSchedule.swift (71%) create mode 100644 Common/Extensions/SampleValue.swift delete mode 100644 Common/Models/GlucoseRangeScheduleOverrideUserInfo.swift create mode 100644 Common/Models/LoopSettingsUserInfo.swift delete mode 100644 Common/Models/WatchDatedRange.swift rename WatchApp Extension/Extensions/{NSUserDefaults.swift => NSUserDefaults+WatchApp.swift} (58%) create mode 100644 WatchApp Extension/Scenes/GlucoseChartValueHashable.swift diff --git a/Common/Extensions/Double.swift b/Common/Extensions/Double.swift new file mode 100644 index 0000000000..2247409097 --- /dev/null +++ b/Common/Extensions/Double.swift @@ -0,0 +1,28 @@ +// +// Double.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension Double { + func floored(to increment: Double) -> Double { + if increment == 0 { + return self + } + + return floor(self / increment) * increment + } + + func ceiled(to increment: Double) -> Double { + if increment == 0 { + return self + } + + return ceil(self / increment) * increment + } +} + diff --git a/Loop/Extensions/GlucoseRangeSchedule.swift b/Common/Extensions/GlucoseRangeSchedule.swift similarity index 71% rename from Loop/Extensions/GlucoseRangeSchedule.swift rename to Common/Extensions/GlucoseRangeSchedule.swift index 9e19e5bf61..4cd1712a52 100644 --- a/Loop/Extensions/GlucoseRangeSchedule.swift +++ b/Common/Extensions/GlucoseRangeSchedule.swift @@ -23,12 +23,26 @@ extension GlucoseRangeSchedule { return override.isActive() } - var activeOverrideContext: GlucoseRangeSchedule.Override.Context? { + var activeOverride: GlucoseRangeSchedule.Override? { guard let override = override, override.isActive() else { return nil } - return override.context + return override + } + + var activeOverrideContext: GlucoseRangeSchedule.Override.Context? { + return activeOverride?.context + } + + var activeOverrideQuantityRange: Range? { + guard let activeOverride = activeOverride else { + return nil + } + + let lowerBound = HKQuantity(unit: unit, doubleValue: activeOverride.value.minValue) + let upperBound = HKQuantity(unit: unit, doubleValue: activeOverride.value.maxValue) + return lowerBound.. PersistenceController { + guard let directoryURL = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + fatalError("Could not access the document directory of the current process") + } + + let isReadOnly = Bundle.main.bundleURL.pathExtension == "appex" + + return self.init(directoryURL: directoryURL.appendingPathComponent("com.loopkit.LoopKit"), isReadOnly: isReadOnly) + } } diff --git a/Common/Extensions/SampleValue.swift b/Common/Extensions/SampleValue.swift new file mode 100644 index 0000000000..d554b7cd1d --- /dev/null +++ b/Common/Extensions/SampleValue.swift @@ -0,0 +1,38 @@ +// +// SampleValue.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit + + +extension Collection where Element == SampleValue { + /// O(n) + var quantityRange: Range? { + var lowest: HKQuantity? + var highest: HKQuantity? + + for sample in self { + if let l = lowest { + lowest = Swift.min(l, sample.quantity) + } else { + lowest = sample.quantity + } + + if let h = highest { + highest = Swift.max(h, sample.quantity) + } else { + highest = sample.quantity + } + } + + guard let l = lowest, let h = highest else { + return nil + } + + return l.. Bool { - return lhs.value == rhs.value - } -} diff --git a/Common/Models/LoopSettings.swift b/Common/Models/LoopSettings.swift index 1dcb0cd83a..ae1c5a33b1 100644 --- a/Common/Models/LoopSettings.swift +++ b/Common/Models/LoopSettings.swift @@ -8,7 +8,7 @@ import LoopKit -struct LoopSettings { +struct LoopSettings: Equatable { var dosingEnabled = false let dynamicCarbAbsorptionEnabled = true diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift new file mode 100644 index 0000000000..06efd35087 --- /dev/null +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -0,0 +1,39 @@ +// +// LoopSettingsUserInfo.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + + +struct LoopSettingsUserInfo { + let settings: LoopSettings +} + + +extension LoopSettingsUserInfo: RawRepresentable { + typealias RawValue = [String: Any] + + static let name = "LoopSettingsUserInfo" + static let version = 1 + + init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, + rawValue["name"] as? String == LoopSettingsUserInfo.name, + let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, + let settings = LoopSettings(rawValue: settingsRaw) + else { + return nil + } + + self.settings = settings + } + + var rawValue: RawValue { + return [ + "v": LoopSettingsUserInfo.version, + "name": LoopSettingsUserInfo.name, + "s": settings.rawValue + ] + } +} diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index f6318e6e4e..d25e5c7e55 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -17,17 +17,15 @@ final class WatchContext: NSObject, RawRepresentable { private let version = 4 var preferredGlucoseUnit: HKUnit? - var maxBolus: Double? var glucose: HKQuantity? var glucoseTrendRawValue: Int? - var eventualGlucose: HKQuantity? var glucoseDate: Date? - var targetRanges: [WatchDatedRange]? - var temporaryOverride: WatchDatedRange? - var glucoseRangeScheduleOverride: GlucoseRangeScheduleOverrideUserInfo? - var configuredOverrideContexts: [GlucoseRangeScheduleOverrideUserInfo.Context] = [] + var predictedGlucose: WatchPredictedGlucose? + var eventualGlucose: HKQuantity? { + return predictedGlucose?.values.last?.quantity + } var loopLastRunDate: Date? var lastNetTempBasalDose: Double? @@ -37,15 +35,14 @@ final class WatchContext: NSObject, RawRepresentable { var bolusSuggestion: BolusSuggestionUserInfo? { guard let recommended = recommendedBolusDose else { return nil } - return BolusSuggestionUserInfo(recommendedBolus: recommended, maxBolus: maxBolus) + return BolusSuggestionUserInfo(recommendedBolus: recommended) } - var COB: Double? - var IOB: Double? + var cob: Double? + var iob: Double? var reservoir: Double? var reservoirPercentage: Double? var batteryPercentage: Double? - var predictedGlucose: WatchPredictedGlucose? var cgmManagerState: CGMManager.RawStateValue? @@ -68,24 +65,9 @@ final class WatchContext: NSObject, RawRepresentable { glucose = HKQuantity(unit: unit, doubleValue: glucoseValue) } - if let glucoseValue = rawValue["egv"] as? Double { - eventualGlucose = HKQuantity(unit: unit, doubleValue: glucoseValue) - } - glucoseTrendRawValue = rawValue["gt"] as? Int glucoseDate = rawValue["gd"] as? Date - - if let overrideUserInfoRawValue = rawValue["grsoc"] as? GlucoseRangeScheduleOverrideUserInfo.RawValue, - let overrideUserInfo = GlucoseRangeScheduleOverrideUserInfo(rawValue: overrideUserInfoRawValue) - { - glucoseRangeScheduleOverride = overrideUserInfo - } - - if let configuredOverrideContextsRawValues = rawValue["coc"] as? [GlucoseRangeScheduleOverrideUserInfo.Context.RawValue] { - configuredOverrideContexts = configuredOverrideContextsRawValues.compactMap(GlucoseRangeScheduleOverrideUserInfo.Context.init(rawValue:)) - } - - IOB = rawValue["iob"] as? Double + iob = rawValue["iob"] as? Double reservoir = rawValue["r"] as? Double reservoirPercentage = rawValue["rp"] as? Double batteryPercentage = rawValue["bp"] as? Double @@ -94,25 +76,13 @@ final class WatchContext: NSObject, RawRepresentable { lastNetTempBasalDose = rawValue["ba"] as? Double lastNetTempBasalDate = rawValue["bad"] as? Date recommendedBolusDose = rawValue["rbo"] as? Double - COB = rawValue["cob"] as? Double - maxBolus = rawValue["mb"] as? Double + cob = rawValue["cob"] as? Double cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue + if let rawValue = rawValue["pg"] as? WatchPredictedGlucose.RawValue { predictedGlucose = WatchPredictedGlucose(rawValue: rawValue) } - - if let rawValue = rawValue["tr"] as? [WatchDatedRange.RawValue] { - targetRanges = rawValue.compactMap({return WatchDatedRange(rawValue: $0)}) - } - - if let rawValue = rawValue["to"] as? WatchDatedRange.RawValue { - temporaryOverride = WatchDatedRange(rawValue: rawValue) - } - - if let cgmRawValue = rawValue["cgm"] as? CGM.RawValue { - cgm = CGM(rawValue: cgmRawValue) - } } var rawValue: RawValue { @@ -126,29 +96,22 @@ final class WatchContext: NSObject, RawRepresentable { raw["cgmManagerState"] = cgmManagerState - raw["cob"] = COB + raw["cob"] = cob let unit = preferredGlucoseUnit ?? .milligramsPerDeciliter - raw["egv"] = eventualGlucose?.doubleValue(for: unit) raw["gu"] = preferredGlucoseUnit?.unitString raw["gv"] = glucose?.doubleValue(for: unit) raw["gt"] = glucoseTrendRawValue raw["gd"] = glucoseDate - raw["grsoc"] = glucoseRangeScheduleOverride?.rawValue - raw["coc"] = configuredOverrideContexts.map { $0.rawValue } - raw["iob"] = IOB + raw["iob"] = iob raw["ld"] = loopLastRunDate - raw["mb"] = maxBolus raw["r"] = reservoir raw["rbo"] = recommendedBolusDose raw["rp"] = reservoirPercentage raw["pg"] = predictedGlucose?.rawValue - raw["tr"] = targetRanges?.map { $0.rawValue } - raw["to"] = temporaryOverride?.rawValue - return raw } } diff --git a/Common/Models/WatchDatedRange.swift b/Common/Models/WatchDatedRange.swift deleted file mode 100644 index 855f22bd6e..0000000000 --- a/Common/Models/WatchDatedRange.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// WatchDatedRange.swift -// WatchApp Extension -// -// Created by Bharat Mediratta on 6/26/18. -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -import Foundation - - -struct WatchDatedRange { - public let startDate: Date - public let endDate: Date - public let minValue: Double - public let maxValue: Double - - public init(startDate: Date, endDate: Date, minValue: Double, maxValue: Double) { - self.startDate = startDate - self.endDate = endDate - self.minValue = minValue - self.maxValue = maxValue - } -} - - -extension WatchDatedRange: RawRepresentable { - typealias RawValue = [String: Any] - - var rawValue: RawValue { - return [ - "sd": startDate, - "ed": endDate, - "mi": minValue, - "ma": maxValue - ] - } - - init?(rawValue: RawValue) { - guard - let startDate = rawValue["sd"] as? Date, - let endDate = rawValue["ed"] as? Date, - let minValue = rawValue["mi"] as? Double, - let maxValue = rawValue["ma"] as? Double - else { - return nil - } - - self.startDate = startDate - self.endDate = endDate - self.minValue = minValue - self.maxValue = maxValue - } -} diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift index f4214625e6..cfba470355 100644 --- a/Common/Models/WatchHistoricalGlucose.swift +++ b/Common/Models/WatchHistoricalGlucose.swift @@ -16,7 +16,7 @@ struct WatchHistoricalGlucose { init(with samples: [StoredGlucoseSample]) { self.samples = samples.map { - NewGlucoseSample(date: $0.startDate, quantity: $0.quantity, isDisplayOnly: false, syncIdentifier: $0.syncIdentifier) + NewGlucoseSample(date: $0.startDate, quantity: $0.quantity, isDisplayOnly: false, syncIdentifier: $0.syncIdentifier, syncVersion: 0) } } } @@ -45,7 +45,7 @@ extension WatchHistoricalGlucose: RawRepresentable { } self.samples = (0.. Date() { - context.temporaryOverride = WatchDatedRange( - startDate: override.start, - endDate: endDate, - minValue: override.value.minValue, - maxValue: override.value.maxValue - ) - } - } - - let configuredOverrideContexts = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.configuredOverrideContexts ?? [] - let configuredUserInfoOverrideContexts = configuredOverrideContexts.map { $0.correspondingUserInfoContext } - context.configuredOverrideContexts = configuredUserInfoOverrideContexts - - context.targetRanges = glucoseTargetRangeSchedule.between(start: startDate, end: endDate).map { - return WatchDatedRange( - startDate: $0.startDate, - endDate: $0.endDate, - minValue: $0.value.minValue, - maxValue: $0.value.maxValue - ) - } - } - if let trend = self.deviceManager.cgmManager?.sensorState?.trendType { context.glucoseTrendRawValue = trend.rawValue } @@ -180,9 +162,9 @@ final class WatchDataManager: NSObject, WCSessionDelegate { manager.doseStore.insulinOnBoard(at: Date()) { (result) in switch result { case .success(let iobValue): - context.IOB = iobValue.value + context.iob = iobValue.value case .failure: - context.IOB = nil + context.iob = nil } updateGroup.leave() } @@ -228,9 +210,10 @@ final class WatchDataManager: NSObject, WCSessionDelegate { completionHandler?(nil) } } +} - // MARK: WCSessionDelegate +extension WatchDataManager: WCSessionDelegate { func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { switch message["name"] as? String { case CarbEntryUserInfo.name?: @@ -247,18 +230,21 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } replyHandler([:]) - case GlucoseRangeScheduleOverrideUserInfo.name?: - // Successful changes will trigger a preferences change which will update the watch with the new overrides - if let overrideUserInfo = GlucoseRangeScheduleOverrideUserInfo(rawValue: message) { - _ = deviceDataManager.loopManager.settings.glucoseTargetRangeSchedule?.setOverride(overrideUserInfo.context.correspondingOverrideContext, from: overrideUserInfo.startDate, until: overrideUserInfo.effectiveEndDate) - } else { - deviceDataManager.loopManager.settings.glucoseTargetRangeSchedule?.clearOverride() + case LoopSettingsUserInfo.name?: + if let watchSettings = LoopSettingsUserInfo(rawValue: message)?.settings { + // So far we only support watch changes of target range overrides + var settings = deviceManager.loopManager.settings + settings.glucoseTargetRangeSchedule = watchSettings.glucoseTargetRangeSchedule + + // Prevent re-sending these updated settings back to the watch + lastSentSettings = settings + deviceManager.loopManager.settings = settings } replyHandler([:]) case GlucoseBackfillRequestUserInfo.name?: if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message), - let manager = deviceDataManager.loopManager { - manager.glucoseStore.getCachedGlucoseSamples(start: userInfo.startDate) { (values) in + let manager = deviceManager.loopManager { + manager.glucoseStore.getCachedGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (values) in replyHandler(WatchHistoricalGlucose(with: values).rawValue) } } else { @@ -278,6 +264,8 @@ final class WatchDataManager: NSObject, WCSessionDelegate { case .activated: if let error = error { log.error(error) + } else { + sendSettingsIfNeeded() } case .inactive, .notActivated: break @@ -287,6 +275,15 @@ final class WatchDataManager: NSObject, WCSessionDelegate { func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { if let error = error { log.error(error) + + // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. + switch userInfoTransfer.userInfo["name"] as? String { + case LoopSettingsUserInfo.name?, .none: + lastSentSettings = nil + sendSettingsIfNeeded() + default: + break + } } } @@ -299,26 +296,8 @@ final class WatchDataManager: NSObject, WCSessionDelegate { watchSession?.delegate = self watchSession?.activate() } -} - -fileprivate extension GlucoseRangeSchedule.Override.Context { - var correspondingUserInfoContext: GlucoseRangeScheduleOverrideUserInfo.Context { - switch self { - case .preMeal: - return .preMeal - case .workout: - return .workout - } - } -} -fileprivate extension GlucoseRangeScheduleOverrideUserInfo.Context { - var correspondingOverrideContext: GlucoseRangeSchedule.Override.Context { - switch self { - case .preMeal: - return .preMeal - case .workout: - return .workout - } + func sessionReachabilityDidChange(_ session: WCSession) { + sendSettingsIfNeeded() } } diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index 24a67c6854..745479ef86 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -11,12 +11,11 @@ import HealthKit import LoopKit extension WatchContext { - convenience init(glucose: GlucoseValue?, eventualGlucose: GlucoseValue?, glucoseUnit: HKUnit?) { + convenience init(glucose: GlucoseValue?, glucoseUnit: HKUnit?) { self.init() self.glucose = glucose?.quantity self.glucoseDate = glucose?.startDate - self.eventualGlucose = eventualGlucose?.quantity self.preferredGlucoseUnit = glucoseUnit } } diff --git a/LoopUI/Extensions/ChartPoint.swift b/LoopUI/Extensions/ChartPoint.swift index 24230d2694..032f934726 100644 --- a/LoopUI/Extensions/ChartPoint.swift +++ b/LoopUI/Extensions/ChartPoint.swift @@ -59,7 +59,7 @@ extension ChartPoint { static func pointsForGlucoseRangeScheduleOverride(_ override: GlucoseRangeSchedule.Override, unit: HKUnit, xAxisValues: [ChartAxisValue], extendEndDateToChart: Bool = false) -> [ChartPoint] { let range = override.value.rangeWithMinimumIncremement(unit.chartableIncrement) let startDate = Date() - let endDate = override.end ?? .distantFuture + let endDate = override.end guard endDate.timeIntervalSince(startDate) > 0, let lastXAxisValue = xAxisValues.last as? ChartAxisValueDate diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 163ffe5220..e889eeb229 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -19,7 +19,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().activeContext?.glucoseDate { + if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { handler(date) } else { handler(nil) @@ -27,7 +27,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().activeContext?.glucoseDate { + if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { handler(date) } else { handler(nil) @@ -45,7 +45,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { let entry: CLKComplicationTimelineEntry? - if let context = ExtensionDelegate.shared().activeContext, + if let context = ExtensionDelegate.shared().loopManager.activeContext, let glucoseDate = context.glucoseDate, glucoseDate.timeIntervalSinceNow.minutes >= -15, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context) @@ -68,7 +68,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // Call the handler with the timeline entries after to the given date let entries: [CLKComplicationTimelineEntry]? - if let context = ExtensionDelegate.shared().activeContext, + if let context = ExtensionDelegate.shared().loopManager.activeContext, let glucoseDate = context.glucoseDate, glucoseDate.timeIntervalSince(date) > 0, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context) diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index 097770cdc9..e5bfd49e68 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -19,8 +19,6 @@ final class ActionHUDController: HUDInterfaceController { @IBOutlet var workoutButtonImage: WKInterfaceImage! @IBOutlet var workoutButtonBackground: WKInterfaceGroup! - private var lastOverrideContext: GlucoseRangeScheduleOverrideUserInfo.Context? - private lazy var preMealButtonGroup = ButtonGroup(button: preMealButton, image: preMealButtonImage, background: preMealButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor) private lazy var workoutButtonGroup = ButtonGroup(button: workoutButton, image: workoutButtonImage, background: workoutButtonBackground, onBackgroundColor: .workoutColor, offBackgroundColor: .darkWorkoutColor) @@ -28,8 +26,6 @@ final class ActionHUDController: HUDInterfaceController { override func willActivate() { super.willActivate() - updateLoopHUD() - let userActivity = NSUserActivity.forViewLoopStatus() if #available(watchOSApplicationExtension 5.0, *) { update(userActivity) @@ -40,23 +36,19 @@ final class ActionHUDController: HUDInterfaceController { override func update() { super.update() - guard let activeContext = loopManager?.activeContext else { - return - } - - let overrideContext: GlucoseRangeScheduleOverrideUserInfo.Context? - if let glucoseRangeScheduleOverride = activeContext.glucoseRangeScheduleOverride, glucoseRangeScheduleOverride.dateInterval.contains(Date()) + let schedule = loopManager?.settings.glucoseTargetRangeSchedule + let activeOverrideContext: GlucoseRangeSchedule.Override.Context? + if let glucoseRangeScheduleOverride = schedule?.override, glucoseRangeScheduleOverride.isActive() { - overrideContext = glucoseRangeScheduleOverride.context + activeOverrideContext = glucoseRangeScheduleOverride.context } else { - overrideContext = nil + activeOverrideContext = nil } - updateForOverrideContext(overrideContext) - lastOverrideContext = overrideContext + updateForOverrideContext(activeOverrideContext) - for overrideContext in GlucoseRangeScheduleOverrideUserInfo.Context.allContexts { + for overrideContext in GlucoseRangeSchedule.Override.Context.all { let contextButtonGroup = buttonGroup(for: overrideContext) - if !activeContext.configuredOverrideContexts.contains(overrideContext) { + if schedule == nil || !(schedule!.configuredOverrideContexts.contains(overrideContext)) { contextButtonGroup.state = .disabled } else if contextButtonGroup.state == .disabled { contextButtonGroup.state = .off @@ -64,7 +56,7 @@ final class ActionHUDController: HUDInterfaceController { } } - private func updateForOverrideContext(_ context: GlucoseRangeScheduleOverrideUserInfo.Context?) { + private func updateForOverrideContext(_ context: GlucoseRangeSchedule.Override.Context?) { switch context { case .preMeal?: preMealButtonGroup.state = .on @@ -78,7 +70,7 @@ final class ActionHUDController: HUDInterfaceController { } } - private func buttonGroup(for overrideContext: GlucoseRangeScheduleOverrideUserInfo.Context) -> ButtonGroup { + private func buttonGroup(for overrideContext: GlucoseRangeSchedule.Override.Context) -> ButtonGroup { switch overrideContext { case .preMeal: return preMealButtonGroup @@ -89,67 +81,65 @@ final class ActionHUDController: HUDInterfaceController { // MARK: - Menu Items - @IBAction func addCarbs() { - presentController(withName: AddCarbsInterfaceController.className, context: nil) - } - - @IBAction func setBolus() { - presentController(withName: BolusInterfaceController.className, context: loopManager?.activeContext?.bolusSuggestion ?? 0) - } - @IBAction func togglePreMealMode() { - let userInfo: GlucoseRangeScheduleOverrideUserInfo? + guard var glucoseTargetRangeSchedule = loopManager?.settings.glucoseTargetRangeSchedule else { + return + } if preMealButtonGroup.state == .on { - userInfo = nil + glucoseTargetRangeSchedule.clearOverride() } else { - userInfo = GlucoseRangeScheduleOverrideUserInfo(context: .preMeal, startDate: Date(), endDate: Date(timeIntervalSinceNow: .hours(1))) + guard glucoseTargetRangeSchedule.setOverride(.preMeal, until: Date(timeIntervalSinceNow: .hours(1))) else { + return + } } - updateForOverrideContext(userInfo?.context) - sendGlucoseRangeOverride(userInfo: userInfo) + sendGlucoseRangeSchedule(glucoseTargetRangeSchedule) } @IBAction func toggleWorkoutMode() { - let userInfo: GlucoseRangeScheduleOverrideUserInfo? + guard var glucoseTargetRangeSchedule = loopManager?.settings.glucoseTargetRangeSchedule else { + return + } if workoutButtonGroup.state == .on { - userInfo = nil + glucoseTargetRangeSchedule.clearOverride() } else { - userInfo = GlucoseRangeScheduleOverrideUserInfo(context: .workout, startDate: Date(), endDate: nil) + guard glucoseTargetRangeSchedule.setOverride(.workout, until: .distantFuture) else { + return + } } - updateForOverrideContext(userInfo?.context) - sendGlucoseRangeOverride(userInfo: userInfo) + sendGlucoseRangeSchedule(glucoseTargetRangeSchedule) } private var pendingMessageResponses = 0 - private func sendGlucoseRangeOverride(userInfo: GlucoseRangeScheduleOverrideUserInfo?) { + private func sendGlucoseRangeSchedule(_ schedule: GlucoseRangeSchedule) { + updateForOverrideContext(schedule.override?.context) pendingMessageResponses += 1 do { - try WCSession.default.sendGlucoseRangeScheduleOverrideMessage(userInfo, - replyHandler: { _ in - DispatchQueue.main.async { - self.pendingMessageResponses -= 1 + var settings = LoopSettings() + settings.glucoseTargetRangeSchedule = schedule + let userInfo = LoopSettingsUserInfo(settings: settings) + + try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (error) in + DispatchQueue.main.async { + self.pendingMessageResponses -= 1 + if let error = error { if self.pendingMessageResponses == 0 { - self.updateForOverrideContext(userInfo?.context) + ExtensionDelegate.shared().present(error) + self.updateForOverrideContext(self.loopManager?.settings.glucoseTargetRangeSchedule?.override?.context) } - self.lastOverrideContext = userInfo?.context - } - }, - errorHandler: { error in - DispatchQueue.main.async { - self.pendingMessageResponses -= 1 + } else { if self.pendingMessageResponses == 0 { - self.updateForOverrideContext(self.lastOverrideContext) + self.loopManager?.settings.glucoseTargetRangeSchedule = schedule } - ExtensionDelegate.shared().present(error) } } - ) + }) } catch { pendingMessageResponses -= 1 if pendingMessageResponses == 0 { - updateForOverrideContext(lastOverrideContext) + updateForOverrideContext(self.loopManager?.settings.glucoseTargetRangeSchedule?.override?.context) } presentAlert( withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), @@ -160,3 +150,8 @@ final class ActionHUDController: HUDInterfaceController { } } } + + +extension GlucoseRangeSchedule.Override.Context { + static let all: [GlucoseRangeSchedule.Override.Context] = [.preMeal, .workout] +} diff --git a/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift b/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift index 1425509e40..43e66d96e2 100644 --- a/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift +++ b/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift @@ -17,16 +17,19 @@ final class AddCarbsInterfaceController: WKInterfaceController, IdentifiableClas private var carbValue: Int = 15 { didSet { - if carbValue < 0 { - carbValue = 0 - } else if carbValue > 100 { - carbValue = 100 + if carbValue < minimumCarbValue { + carbValue = minimumCarbValue + } else if carbValue > maximumCarbValue { + carbValue = maximumCarbValue } valueLabel.setText(String(carbValue)) } } + private let minimumCarbValue = 0 + private let maximumCarbValue = 100 + private let maximumDatePastInterval = TimeInterval(hours: 8) private let maximumDateFutureInterval = TimeInterval(hours: 4) @@ -138,6 +141,8 @@ final class AddCarbsInterfaceController: WKInterfaceController, IdentifiableClas case .date: date -= dateIncrement } + + WKInterfaceDevice.current().play(.directionDown) } @IBAction func increment() { @@ -147,6 +152,8 @@ final class AddCarbsInterfaceController: WKInterfaceController, IdentifiableClas case .date: date += dateIncrement } + + WKInterfaceDevice.current().play(.directionUp) } @IBAction func toggleInputMode() { @@ -173,11 +180,13 @@ final class AddCarbsInterfaceController: WKInterfaceController, IdentifiableClas try WCSession.default.sendCarbEntryMessage(entry, replyHandler: { (suggestion) in DispatchQueue.main.async { + WKInterfaceDevice.current().play(.success) WKExtension.shared().rootInterfaceController?.presentController(withName: BolusInterfaceController.className, context: suggestion) } }, errorHandler: { (error) in DispatchQueue.main.async { + WKInterfaceDevice.current().play(.failure) ExtensionDelegate.shared().present(error) } } @@ -206,13 +215,31 @@ extension AddCarbsInterfaceController: WKCrownDelegate { func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) { accumulatedRotation += rotationalDelta let remainder = accumulatedRotation.truncatingRemainder(dividingBy: rotationsPerIncrement) - let delta = (accumulatedRotation - remainder) / rotationsPerIncrement + var delta = Int((accumulatedRotation - remainder) / rotationsPerIncrement) switch inputMode { case .value: - carbValue += Int(delta) + let oldValue = carbValue + carbValue += delta + + // If we didn't change, adjust the delta to prevent the haptic + if oldValue == carbValue { + delta = 0 + } case .date: - date += TimeInterval(minutes: delta) + let oldValue = date + date += TimeInterval(minutes: Double(delta)) + + // If we didn't change, adjust the delta to prevent the haptic + if oldValue == date { + delta = 0 + } + } + + if delta > 0 { + WKInterfaceDevice.current().play(.click) + } else if delta < 0 { + WKInterfaceDevice.current().play(.click) } accumulatedRotation = remainder diff --git a/WatchApp Extension/Controllers/BolusInterfaceController.swift b/WatchApp Extension/Controllers/BolusInterfaceController.swift index ff2bf99613..e3015a67ca 100644 --- a/WatchApp Extension/Controllers/BolusInterfaceController.swift +++ b/WatchApp Extension/Controllers/BolusInterfaceController.swift @@ -85,7 +85,7 @@ final class BolusInterfaceController: WKInterfaceController, IdentifiableClass { var pickerValue = 0 if let context = context as? BolusSuggestionUserInfo { - let recommendedBolus = context.recommendedBolus + let recommendedBolus = context.recommendedBolus ?? 0 if let maxBolus = context.maxBolus { maxBolusValue = maxBolus @@ -95,7 +95,7 @@ final class BolusInterfaceController: WKInterfaceController, IdentifiableClass { pickerValue = pickerValueFromBolusValue(recommendedBolus * 0.75) - if let valueString = formatter.string(from: recommendedBolus) { + if let recommendedBolus = context.recommendedBolus, let valueString = formatter.string(from: recommendedBolus) { recommendedValueLabel.setText(String(format: NSLocalizedString("Rec: %@ U", comment: "The label and value showing the recommended bolus"), valueString).localizedUppercase) } } @@ -122,10 +122,14 @@ final class BolusInterfaceController: WKInterfaceController, IdentifiableClass { @IBAction func decrement() { pickerValue -= 10 + + WKInterfaceDevice.current().play(.directionDown) } @IBAction func increment() { pickerValue += 10 + + WKInterfaceDevice.current().play(.directionUp) } @IBAction func deliver() { @@ -166,7 +170,22 @@ extension BolusInterfaceController: WKCrownDelegate { accumulatedRotation += rotationalDelta let remainder = accumulatedRotation.truncatingRemainder(dividingBy: rotationsPerValue) - pickerValue += Int((accumulatedRotation - remainder) / rotationsPerValue) + var delta = Int((accumulatedRotation - remainder) / rotationsPerValue) + + let oldValue = pickerValue + pickerValue += delta + + // If we didn't change, adjust the delta to prevent the haptic + if oldValue == pickerValue { + delta = 0 + } + + if delta > 0 { + WKInterfaceDevice.current().play(.click) + } else if delta < 0 { + WKInterfaceDevice.current().play(.click) + } + accumulatedRotation = remainder } } diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index 2a896c429a..2545739dc7 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -11,6 +11,7 @@ import WatchConnectivity import CGMBLEKit import LoopKit import SpriteKit +import os.log final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { @IBOutlet weak var basalLabel: WKInterfaceLabel! @@ -18,23 +19,31 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { @IBOutlet weak var cobLabel: WKInterfaceLabel! @IBOutlet weak var glucoseScene: WKInterfaceSKScene! @IBAction func setChartWindow1Hour() { - scene.visibleHours = 1 + scene.visibleDuration = .hours(2) } @IBAction func setChartWindow2Hours() { - scene.visibleHours = 2 + scene.visibleDuration = .hours(4) } @IBAction func setChartWindow3Hours() { - scene.visibleHours = 3 + scene.visibleDuration = .hours(6) } private let scene = GlucoseChartScene() + private var timer: Timer? { + didSet { + oldValue?.invalidate() + } + } + private let log = OSLog(category: "ChartHUDController") override init() { super.init() loopManager = ExtensionDelegate.shared().loopManager - NotificationCenter.default.addObserver(forName: .GlucoseSamplesDidChange, object: loopManager?.glucoseStore, queue: nil) { _ in + NotificationCenter.default.addObserver(forName: .GlucoseSamplesDidChange, object: loopManager?.glucoseStore, queue: nil) { [weak self] (note) in + self?.log.default("Received GlucoseSamplesDidChange notification: %{public}@. Updating chart", String(describing: note.userInfo ?? [:])) + DispatchQueue.main.async { - self.updateGlucoseChart() + self?.updateGlucoseChart() } } @@ -42,8 +51,11 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { } override func awake(withContext context: Any?) { + super.awake(withContext: context) + if UserDefaults.standard.startOnChartPage { - self.becomeCurrentPage() + log.default("Switching to startOnChartPage") + becomeCurrentPage() // For some reason, .didAppear() does not get called when we do this. It gets called *twice* the next // time this view appears. Force it by hand now, until we figure out the root cause. @@ -57,16 +69,44 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { override func didAppear() { super.didAppear() + + log.default("didAppear") + + // Force an update when our pixels need to move + let pixelsWide = scene.size.width * WKInterfaceDevice.current().screenScale + let pixelInterval = scene.visibleDuration / TimeInterval(pixelsWide) + + timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in + self?.scene.setNeedsUpdate() + } } - override func willActivate() { - crownSequencer.delegate = self - crownSequencer.focus() + override func willDisappear() { + super.willDisappear() + + log.default("willDisappear") + + timer = nil + } + override func willActivate() { super.willActivate() - loopManager?.glucoseStore.maybeRequestGlucoseBackfill() - glucoseScene.isPaused = false + if glucoseScene.isPaused { + log.default("willActivate() unpausing") + glucoseScene.isPaused = false + } else { + log.default("willActivate() unpausing") + } + + loopManager?.requestGlucoseBackfillIfNecessary() + } + + override func didDeactivate() { + super.didDeactivate() + + log.default("didDeactivate() pausing") + glucoseScene.isPaused = true } override func update() { @@ -87,25 +127,29 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { }() iobLabel.setHidden(true) - if let activeInsulin = activeContext.IOB, let valueStr = insulinFormatter.string(from:NSNumber(value:activeInsulin)) { + if let activeInsulin = activeContext.iob, let valueStr = insulinFormatter.string(from: activeInsulin) { iobLabel.setText(String(format: NSLocalizedString( - "IOB %1$@ U", - comment: "The subtitle format describing units of active insulin. (1: localized insulin value description)"), - valueStr)) + "IOB %1$@ U", + comment: "The subtitle format describing units of active insulin. (1: localized insulin value description)" + ), + valueStr + )) iobLabel.setHidden(false) } cobLabel.setHidden(true) - if let carbsOnBoard = activeContext.COB { + if let carbsOnBoard = activeContext.cob { let carbFormatter = NumberFormatter() carbFormatter.numberStyle = .decimal carbFormatter.maximumFractionDigits = 0 - let valueStr = carbFormatter.string(from:NSNumber(value:carbsOnBoard)) + let valueStr = carbFormatter.string(from: carbsOnBoard) cobLabel.setText(String(format: NSLocalizedString( - "COB %1$@ g", - comment: "The subtitle format describing grams of active carbs. (1: localized carb value description)"), - valueStr!)) + "COB %1$@ g", + comment: "The subtitle format describing grams of active carbs. (1: localized carb value description)" + ), + valueStr! + )) cobLabel.setHidden(false) } @@ -116,7 +160,7 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { basalFormatter.minimumFractionDigits = 1 basalFormatter.maximumFractionDigits = 3 basalFormatter.positivePrefix = basalFormatter.plusSign - let valueStr = basalFormatter.string(from:NSNumber(value:tempBasal)) + let valueStr = basalFormatter.string(from: tempBasal) let basalLabelText = String(format: NSLocalizedString( "%1$@ U/hr", @@ -126,6 +170,11 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { basalLabel.setHidden(false) } + if glucoseScene.isPaused { + log.default("update() unpausing") + glucoseScene.isPaused = false + } + updateGlucoseChart() } @@ -135,26 +184,14 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { } scene.predictedGlucose = activeContext.predictedGlucose?.values - scene.targetRanges = activeContext.targetRanges - scene.temporaryOverride = activeContext.temporaryOverride + scene.correctionRange = loopManager?.settings.glucoseTargetRangeSchedule scene.unit = activeContext.preferredGlucoseUnit - loopManager?.glucoseStore.getCachedGlucoseSamples(start: .EarliestGlucoseCutoff) { (samples) in + loopManager?.glucoseStore.getCachedGlucoseSamples(start: .earliestGlucoseCutoff) { (samples) in DispatchQueue.main.async { self.scene.historicalGlucose = samples - self.scene.updateNodes(animated: false) + self.scene.setNeedsUpdate() } } } - - // MARK: WKCrownDelegate - var crownAccumulator = 0.0 - - func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) { - crownAccumulator += rotationalDelta - if abs(crownAccumulator) >= 0.25 { - scene.visibleBg += Int(sign(crownAccumulator)) - crownAccumulator = 0 - } - } } diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index 9e06aafe72..55eb8299d7 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -25,17 +25,24 @@ class HUDInterfaceController: WKInterfaceController { override func willActivate() { super.willActivate() + update() + if activeContextObserver == nil { - activeContextObserver = NotificationCenter.default.addObserver(forName: .ContextUpdated, object: nil, queue: nil) { _ in + activeContextObserver = NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] _ in DispatchQueue.main.async { - self.update() + self?.update() } } } } - override func didAppear() { - update() + override func didDeactivate() { + super.didDeactivate() + + if let observer = activeContextObserver { + NotificationCenter.default.removeObserver(observer) + } + activeContextObserver = nil } func update() { @@ -78,4 +85,19 @@ class HUDInterfaceController: WKInterfaceController { } }()) } + + @IBAction func addCarbs() { + presentController(withName: AddCarbsInterfaceController.className, context: nil) + } + + @IBAction func setBolus() { + var context = loopManager?.activeContext?.bolusSuggestion ?? BolusSuggestionUserInfo(recommendedBolus: nil) + + if context.maxBolus == nil { + context.maxBolus = loopManager?.settings.maximumBolus + } + + presentController(withName: BolusInterfaceController.className, context: context) + } + } diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 51011ef413..52ea89d225 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -20,6 +20,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { private let log = OSLog(category: "ExtensionDelegate") + private var observers: [NSKeyValueObservation] = [] + private var notifications: [NSObjectProtocol] = [] + static func shared() -> ExtensionDelegate { return WKExtension.shared().extensionDelegate } @@ -33,15 +36,34 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { // It seems, according to [this sample code](https://developer.apple.com/library/prerelease/content/samplecode/QuickSwitch/Listings/QuickSwitch_WatchKit_Extension_ExtensionDelegate_swift.html#//apple_ref/doc/uid/TP40016647-QuickSwitch_WatchKit_Extension_ExtensionDelegate_swift-DontLinkElementID_8) // that WCSession activation and delegation and WKWatchConnectivityRefreshBackgroundTask don't have any determinism, // and that KVO is the "recommended" way to deal with it. - session.addObserver(self, forKeyPath: #keyPath(WCSession.activationState), options: [], context: nil) - session.addObserver(self, forKeyPath: #keyPath(WCSession.hasContentPending), options: [], context: nil) + observers.append(session.observe(\WCSession.activationState) { [weak self] (session, change) in + self?.log.default("WCSession.applicationState did change to %d", session.activationState.rawValue) + + DispatchQueue.main.async { + self?.completePendingConnectivityTasksIfNeeded() + } + }) + observers.append(session.observe(\WCSession.hasContentPending) { [weak self] (session, change) in + self?.log.default("WCSession.hasContentPending did change to %d", session.hasContentPending) + + DispatchQueue.main.async { + self?.loopManager.sendDidUpdateContextNotificationIfNecessary() + self?.completePendingConnectivityTasksIfNeeded() + } + }) + + notifications.append(NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] (_) in + DispatchQueue.main.async { + self?.loopManagerDidUpdateContext() + } + }) session.activate() } - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - DispatchQueue.main.async { - self.completePendingConnectivityTasksIfNeeded() + deinit { + for notification in notifications { + NotificationCenter.default.removeObserver(notification) } } @@ -62,8 +84,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { UserDefaults.standard.startOnChartPage = (WKExtension.shared().visibleInterfaceController as? ChartHUDController) != nil } + // Presumably the main thread? func handle(_ backgroundTasks: Set) { - loopManager.glucoseStore.maybeRequestGlucoseBackfill() + loopManager.requestGlucoseBackfillIfNecessary() for task in backgroundTasks { switch task { @@ -104,6 +127,7 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { private func completePendingConnectivityTasksIfNeeded() { if WCSession.default.activationState == .activated && !WCSession.default.hasContentPending { pendingConnectivityTasks.forEach { (task) in + self.log.default("Completing WKWatchConnectivityRefreshBackgroundTask %{public}@", String(describing: task)) if #available(watchOSApplicationExtension 4.0, *) { task.setTaskCompletedWithSnapshot(false) } else { @@ -118,7 +142,7 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { if #available(watchOSApplicationExtension 5.0, *) { switch userActivity.activityType { case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: - if let statusController = WKExtension.shared().visibleInterfaceController as? StatusInterfaceController { + if let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController { statusController.addCarbs() } default: @@ -127,60 +151,45 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } } - // Main queue only - private(set) var activeContext: WatchContext? { - didSet { - loopManager.activeContext = activeContext + private func updateContext(_ data: [String: Any]) { + guard let context = WatchContext(rawValue: data) else { + log.error("Could not decode WatchContext: %{public}@", data) + return + } - if WKExtension.shared().applicationState != .active { - WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in - if let error = error { - self.log.error("scheduleSnapshotRefresh error: %{public}@", String(describing: error)) - } - } - } + if context.preferredGlucoseUnit == nil { + let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose)! + loopManager.healthStore.preferredUnits(for: [type]) { (units, error) in + context.preferredGlucoseUnit = units[type] - // Update complication data if needed - let server = CLKComplicationServer.sharedInstance() - for complication in server.activeComplications ?? [] { - // In watchOS 2, we forced a timeline reload every 8 hours because attempting to extend it indefinitely seemed to lead to the complication "freezing". - if UserDefaults.standard.complicationDataLastRefreshed.timeIntervalSinceNow < TimeInterval(hours: -8) { - UserDefaults.standard.complicationDataLastRefreshed = Date() - log.default("Reloading complication timeline") - server.reloadTimeline(for: complication) - } else { - log.default("Extending complication timeline") - // TODO: Switch this back to extendTimeline if things are working correctly. - // Time Travel appears to be disabled by default in watchOS 3 anyway - server.reloadTimeline(for: complication) + DispatchQueue.main.async { + self.loopManager.updateContext(context) } } + } else { + DispatchQueue.main.async { + self.loopManager.updateContext(context) + } } } - private lazy var healthStore = HKHealthStore() + private func loopManagerDidUpdateContext() { + dispatchPrecondition(condition: .onQueue(.main)) - fileprivate func updateContext(_ data: [String: Any]) { - if let context = WatchContext(rawValue: data as WatchContext.RawValue) { - if context.preferredGlucoseUnit == nil { - let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose)! - healthStore.preferredUnits(for: [type]) { (units, error) in - context.preferredGlucoseUnit = units[type] - - DispatchQueue.main.async { - if self.activeContext == nil || context.shouldReplace(self.activeContext!) { - self.activeContext = context - } - } - } - } else { - DispatchQueue.main.async { - if self.activeContext == nil || context.shouldReplace(self.activeContext!) { - self.activeContext = context - } + if WKExtension.shared().applicationState != .active { + WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in + if let error = error { + self.log.error("scheduleSnapshotRefresh error: %{public}@", String(describing: error)) } } } + + // Update complication data if needed + let server = CLKComplicationServer.sharedInstance() + for complication in server.activeComplications ?? [] { + log.default("Reloading complication timeline") + server.reloadTimeline(for: complication) + } } } @@ -197,11 +206,26 @@ extension ExtensionDelegate: WCSessionDelegate { updateContext(applicationContext) } + // This method is called on a background thread of your app func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { - log.default("didReceiveUserInfo") - // WatchContext is the only userInfo type without a "name" key. This isn't a great heuristic. - if !(userInfo["name"] is String) { + let name = userInfo["name"] as? String ?? "WatchContext" + + log.default("didReceiveUserInfo: %{public}@", name) + + switch name { + case LoopSettingsUserInfo.name: + if let settings = LoopSettingsUserInfo(rawValue: userInfo)?.settings { + DispatchQueue.main.async { + self.loopManager.settings = settings + } + } else { + log.error("Could not decode LoopSettingsUserInfo: %{public}@", userInfo) + } + case "WatchContext": + // WatchContext is the only userInfo type without a "name" key. This isn't a great heuristic. updateContext(userInfo) + default: + break } } } @@ -220,6 +244,8 @@ extension ExtensionDelegate { /// /// - parameter error: The error whose contents to display func present(_ error: Error) { + dispatchPrecondition(condition: .onQueue(.main)) + WKExtension.shared().rootInterfaceController?.presentAlert(withTitle: error.localizedDescription, message: (error as NSError).localizedRecoverySuggestion ?? (error as NSError).localizedFailureReason, preferredStyle: .alert, actions: [WKAlertAction.dismissAction()]) } } diff --git a/WatchApp Extension/Extensions/Date.swift b/WatchApp Extension/Extensions/Date.swift index d70251fec0..9af6427d7c 100644 --- a/WatchApp Extension/Extensions/Date.swift +++ b/WatchApp Extension/Extensions/Date.swift @@ -10,11 +10,11 @@ import Foundation extension Date { - static var EarliestGlucoseCutoff: Date { - return Date().addingTimeInterval(TimeInterval(hours: -3)) + static var earliestGlucoseCutoff: Date { + return Date(timeIntervalSinceNow: .hours(-3)) } - static var StaleGlucoseCutoff: Date { - return Date().addingTimeInterval(-TimeInterval(minutes: 4.5)) + static var staleGlucoseCutoff: Date { + return Date(timeIntervalSinceNow: .minutes(-5)) } } diff --git a/WatchApp Extension/Extensions/GlucoseStore.swift b/WatchApp Extension/Extensions/GlucoseStore.swift index a71520a09f..f866595ea0 100644 --- a/WatchApp Extension/Extensions/GlucoseStore.swift +++ b/WatchApp Extension/Extensions/GlucoseStore.swift @@ -11,15 +11,5 @@ import LoopKit import WatchConnectivity extension GlucoseStore { - func maybeRequestGlucoseBackfill() { - getCachedGlucoseSamples(start: .EarliestGlucoseCutoff) { samples in - let latestDate = samples.last?.startDate ?? .EarliestGlucoseCutoff - if latestDate < .StaleGlucoseCutoff { - let userInfo = GlucoseBackfillRequestUserInfo(startDate: latestDate) - WCSession.default.sendGlucoseBackfillRequestMessage(userInfo) { (context) in - self.addGlucose(context.samples) { _ in } - } - } - } - } + } diff --git a/WatchApp Extension/Extensions/NSUserDefaults.swift b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift similarity index 58% rename from WatchApp Extension/Extensions/NSUserDefaults.swift rename to WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift index cddb16ca67..012d29b1f2 100644 --- a/WatchApp Extension/Extensions/NSUserDefaults.swift +++ b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift @@ -11,7 +11,6 @@ import Foundation extension UserDefaults { private enum Key: String { - case ComplicationDataLastRefreshed = "com.loudnate.Naterade.ComplicationDataLastRefreshed" case StartOnChartPage = "com.loudnate.Naterade.StartOnChartPage" } @@ -23,13 +22,4 @@ extension UserDefaults { set(newValue, forKey: Key.StartOnChartPage.rawValue) } } - - var complicationDataLastRefreshed: Date { - get { - return object(forKey: Key.ComplicationDataLastRefreshed.rawValue) as? Date ?? Date.distantPast - } - set { - set(newValue, forKey: Key.ComplicationDataLastRefreshed.rawValue) - } - } } diff --git a/WatchApp Extension/Extensions/UIColor.swift b/WatchApp Extension/Extensions/UIColor.swift index 4f9d036a63..02328e78ec 100644 --- a/WatchApp Extension/Extensions/UIColor.swift +++ b/WatchApp Extension/Extensions/UIColor.swift @@ -10,21 +10,32 @@ import UIKit extension UIColor { - @nonobjc static let tintColor = UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1) + static let tintColor = UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1) - @nonobjc static let carbsColor = UIColor(red: 99 / 255, green: 218 / 255, blue: 56 / 255, alpha: 1) + static let carbsColor = UIColor(red: 99 / 255, green: 218 / 255, blue: 56 / 255, alpha: 1) // Equivalent to carbsColor with alpha 0.14 on a black background - @nonobjc static let darkCarbsColor = UIColor(red: 0.07, green: 0.12, blue: 0.04, alpha: 1) + static let darkCarbsColor = UIColor(red: 0.07, green: 0.12, blue: 0.04, alpha: 1) - @nonobjc static let workoutColor = UIColor(red: 79 / 255, green: 173 / 255, blue: 248 / 255, alpha: 1) + static let glucose = UIColor(red: 79 / 255, green: 173 / 255, blue: 248 / 255, alpha: 1) + + // Equivalent to glucoseColor with alpha 0.14 on a black background + static let darkGlucose = UIColor(red: 0.02, green: 0.10, blue: 0.14, alpha: 1) + + static let workoutColor = glucose // Equivalent to workoutColor with alpha 0.14 on a black background - @nonobjc static let darkWorkoutColor = UIColor(red: 0.02, green: 0.10, blue: 0.14, alpha: 1) + static let darkWorkoutColor = darkGlucose + + static let disabledButtonColor = UIColor.gray + + static let darkDisabledButtonColor = UIColor.disabledButtonColor.withAlphaComponent(0.14) + + static let chartLabel = HIGWhiteColor() - @nonobjc static let disabledButtonColor = UIColor.gray + static let chartNowLine = HIGWhiteColor().withAlphaComponent(0.2) - @nonobjc static let darkDisabledButtonColor = UIColor.disabledButtonColor.withAlphaComponent(0.14) + static let chartPlatter = HIGWhiteColorDark() // MARK: - HIG colors // See: https://developer.apple.com/watch/human-interface-guidelines/visual-design/#color @@ -68,4 +79,13 @@ extension UIColor { private static func HIGGreenColorDark() -> UIColor { return HIGGreenColor().withAlphaComponent(0.14) } + + private static func HIGWhiteColor() -> UIColor { + return UIColor(red: 242 / 255, green: 244 / 255, blue: 1, alpha: 1) + } + + private static func HIGWhiteColorDark() -> UIColor { + // Equivalent to HIGWhiteColor().withAlphaComponent(0.14) on black + return UIColor(red: 33 / 255, green: 34 / 255, blue: 35 / 255, alpha: 1) + } } diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 6b8240fd32..b135fede49 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -7,22 +7,31 @@ // import WatchConnectivity +import os.log enum MessageError: Error { - case activationError - case decodingError - case reachabilityError + case activation + case decoding + case reachability + case send(Error) } +enum WCSessionMessageResult { + case success(T) + case failure(MessageError) +} + +private let log = OSLog(category: "WCSession Extension") extension WCSession { func sendCarbEntryMessage(_ carbEntry: CarbEntryUserInfo, replyHandler: @escaping (BolusSuggestionUserInfo) -> Void, errorHandler: @escaping (Error) -> Void) throws { guard activationState == .activated else { - throw MessageError.activationError + throw MessageError.activation } guard isReachable else { + log.default("sendCarbEntryMessage: Phone is unreachable, sending as userInfo") transferUserInfo(carbEntry.rawValue) return } @@ -30,7 +39,7 @@ extension WCSession { sendMessage(carbEntry.rawValue, replyHandler: { reply in guard let suggestion = BolusSuggestionUserInfo(rawValue: reply as BolusSuggestionUserInfo.RawValue) else { - errorHandler(MessageError.decodingError) + errorHandler(MessageError.decoding) return } @@ -42,11 +51,11 @@ extension WCSession { func sendBolusMessage(_ userInfo: SetBolusUserInfo, errorHandler: @escaping (Error) -> Void) throws { guard activationState == .activated else { - throw MessageError.activationError + throw MessageError.activation } guard isReachable else { - throw MessageError.reachabilityError + throw MessageError.reachability } sendMessage(userInfo.rawValue, @@ -55,34 +64,52 @@ extension WCSession { ) } - func sendGlucoseRangeScheduleOverrideMessage(_ userInfo: GlucoseRangeScheduleOverrideUserInfo?, replyHandler: @escaping ([String: Any]) -> Void, errorHandler: @escaping (Error) -> Void) throws { + func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Error?) -> Void) throws { guard activationState == .activated else { - throw MessageError.activationError + throw MessageError.activation } guard isReachable else { - throw MessageError.reachabilityError + throw MessageError.reachability } - sendMessage(userInfo?.rawValue ?? GlucoseRangeScheduleOverrideUserInfo.clearOverride, - replyHandler: replyHandler, - errorHandler: errorHandler - ) + sendMessage(userInfo.rawValue, replyHandler: { (reply) in + completionHandler(nil) + }, errorHandler: { (error) in + completionHandler(error) + }) } - func sendGlucoseBackfillRequestMessage(_ userInfo: GlucoseBackfillRequestUserInfo, successHandler: @escaping (WatchHistoricalGlucose) -> Void) { + func sendGlucoseBackfillRequestMessage(_ userInfo: GlucoseBackfillRequestUserInfo, completionHandler: @escaping (WCSessionMessageResult) -> Void) { + log.default("sendGlucoseBackfillRequestMessage: since %{public}@", String(describing: userInfo.startDate)) + // Backfill is optional so we ignore any errors - guard activationState == .activated, isReachable else { + guard activationState == .activated else { + log.error("sendGlucoseBackfillRequestMessage failed: not activated") + completionHandler(.failure(.activation)) + return + } + + guard isReachable else { + log.error("sendGlucoseBackfillRequestMessage failed: not reachable") + completionHandler(.failure(.reachability)) return } sendMessage(userInfo.rawValue, replyHandler: { reply in if let context = WatchHistoricalGlucose(rawValue: reply as WatchHistoricalGlucose.RawValue) { - successHandler(context) + log.default("sendGlucoseBackfillRequestMessage succeeded with %d samples", context.samples.count) + completionHandler(.success(context)) + } else { + log.error("sendGlucoseBackfillRequestMessage failed: could not decode reply %{public}@", reply) + completionHandler(.failure(.decoding)) } }, - errorHandler: { reply in } + errorHandler: { error in + log.error("sendGlucoseBackfillRequestMessage error: %{public}@", String(describing: error)) + completionHandler(.failure(.send(error))) + } ) } } diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist index e09fc24903..44c9ce1e78 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -48,7 +48,5 @@ $(PRODUCT_MODULE_NAME).StatusInterfaceController WKExtensionDelegateClassName $(PRODUCT_MODULE_NAME).ExtensionDelegate - AppGroupIdentifier - $(APP_GROUP_IDENTIFIER) diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 45a34b59f7..b8ac90d1f5 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -9,23 +9,106 @@ import Foundation import HealthKit import LoopKit +import WatchConnectivity +import os.log class LoopDataManager { - var glucoseStore: GlucoseStore - var activeContext: WatchContext? { + let glucoseStore: GlucoseStore + + var healthStore: HKHealthStore { + return glucoseStore.healthStore + } + + // Main queue only + var settings = LoopSettings() { + didSet { + UserDefaults.standard.loopSettings = settings + needsDidUpdateContextNotification = true + sendDidUpdateContextNotificationIfNecessary() + } + } + + private let log = OSLog(category: "LoopDataManager") + + // Main queue only + private(set) var activeContext: WatchContext? { didSet { - NotificationCenter.default.post(name: .ContextUpdated, object: nil) + needsDidUpdateContextNotification = true + sendDidUpdateContextNotificationIfNecessary() } } - init() { + private var needsDidUpdateContextNotification: Bool = false + + /// The last attempt to backfill glucose. We use a date because the message timeout is longer + /// than our desired retry interval, so we allow multiple messages in-flight + /// Main queue only + private var lastGlucoseBackfill = Date.distantPast + + init(settings: LoopSettings = UserDefaults.standard.loopSettings ?? LoopSettings()) { + self.settings = settings + glucoseStore = GlucoseStore( healthStore: HKHealthStore(), - cacheStore: PersistenceController.controllerInAppGroupDirectory(), - cacheLength: .hours(4)) + cacheStore: PersistenceController.controllerInLocalDirectory(), + cacheLength: .hours(4) + ) } } -extension Notification.Name { - static let ContextUpdated = Notification.Name(rawValue: "com.loopkit.notification.ContextUpdated") +extension LoopDataManager { + static let didUpdateContextNotification = Notification.Name(rawValue: "com.loopkit.notification.ContextUpdated") +} + +extension LoopDataManager { + func updateContext(_ context: WatchContext) { + dispatchPrecondition(condition: .onQueue(.main)) + + if activeContext == nil || context.shouldReplace(activeContext!) { + activeContext = context + } + } + + func sendDidUpdateContextNotificationIfNecessary() { + dispatchPrecondition(condition: .onQueue(.main)) + + if needsDidUpdateContextNotification && !WCSession.default.hasContentPending { + needsDidUpdateContextNotification = false + NotificationCenter.default.post(name: LoopDataManager.didUpdateContextNotification, object: self) + } + } + + @discardableResult + func requestGlucoseBackfillIfNecessary() -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + + guard lastGlucoseBackfill < .staleGlucoseCutoff else { + log.default("Skipping glucose backfill request because our latest attempt was %{public}@", String(describing: lastGlucoseBackfill)) + return false + } + + let latestDate = glucoseStore.latestGlucose?.startDate ?? .earliestGlucoseCutoff + guard latestDate < .staleGlucoseCutoff else { + self.log.default("Skipping glucose backfill request because our latest sample date is %{public}@", String(describing: latestDate)) + return false + } + + lastGlucoseBackfill = Date() + let userInfo = GlucoseBackfillRequestUserInfo(startDate: latestDate) + WCSession.default.sendGlucoseBackfillRequestMessage(userInfo) { (result) in + switch result { + case .success(let context): + self.glucoseStore.addGlucose(context.samples) { _ in } + case .failure: + // Already logged + // Reset our last date to immediately retry + DispatchQueue.main.async { + self.lastGlucoseBackfill = .staleGlucoseCutoff + } + break + } + } + + return true + } } diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 4b496314e1..26264c64d7 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -11,77 +11,95 @@ import SpriteKit import HealthKit import LoopKit import WatchKit -import UIKit +import os.log +private enum NodePlane: Int { + case lines = 0 + case ranges + case overrideRanges + case values + case labels -// Stashing the extensions here for ease of development, but they should likely -// move into their own files as appropriate -extension UIColor { - static let glucoseTintColor = UIColor(red: 0 / 255, green: 176 / 255, blue: 255 / 255, alpha: 1) - static let gridColor = UIColor(white: 193 / 255, alpha: 1) - static let nowColor = UIColor(white: 0.2, alpha: 1) - static let rangeColor = UIColor(red: 158/255, green: 215/255, blue: 245/255, alpha: 1) - static let backgroundColor = UIColor(white: 0.1, alpha: 1) + var zPosition: CGFloat { + return CGFloat(rawValue) + } } -extension SKLabelNode { +private extension SKLabelNode { static func basic(at position: CGPoint) -> SKLabelNode { let basic = SKLabelNode(text: "--") basic.fontSize = 12 basic.fontName = "HelveticaNeue" - basic.fontColor = .white + basic.fontColor = .chartLabel basic.alpha = 0.8 basic.verticalAlignmentMode = .top basic.horizontalAlignmentMode = .left basic.position = position + basic.zPosition = NodePlane.labels.zPosition return basic } } -extension SKSpriteNode { +private extension SKSpriteNode { func move(to rect: CGRect, animated: Bool) { if parent == nil || animated == false { size = rect.size position = rect.origin } else { - run(SKAction.group([ - SKAction.move(to: rect.origin, duration: 0.25), - SKAction.resize(toWidth: rect.size.width, duration: 0.25), - SKAction.resize(toHeight: rect.size.height, duration: 0.25)])) + run(.group([ + .move(to: rect.origin, duration: 0.25), + .resize(toWidth: rect.size.width, duration: 0.25), + .resize(toHeight: rect.size.height, duration: 0.25) + ])) } } } -struct Scaler { - let startDate: Date +private struct Scaler { + let dates: DateInterval let glucoseMin: Double let xScale: CGFloat let yScale: CGFloat func point(_ x: Date, _ y: Double) -> CGPoint { - return CGPoint(x: CGFloat(x.timeIntervalSince(startDate)) * xScale, y: CGFloat(y - glucoseMin) * yScale) + return CGPoint(x: CGFloat(x.timeIntervalSince(dates.start)) * xScale, y: CGFloat(y - glucoseMin) * yScale) } // By default enforce a minimum height so that the range is visible - func rect(for range: WatchDatedRange, minHeight: CGFloat = 2) -> CGRect { - let a = point(range.startDate, range.minValue) - let b = point(range.endDate, range.maxValue) + func rect(for range: GlucoseChartValueHashable, unit: HKUnit, minHeight: CGFloat = 2) -> CGRect { + let minY: Double + let maxY: Double + + if unit != .milligramsPerDeciliter { + minY = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: range.min).doubleValue(for: unit) + maxY = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: range.max).doubleValue(for: unit) + } else { + minY = range.min + maxY = range.max + } + + let a = point(max(dates.start, range.start), minY) + let b = point(min(dates.end, range.end), maxY) let size = CGSize(width: b.x - a.x, height: max(b.y - a.y, minHeight)) return CGRect(origin: CGPoint(x: a.x + size.width / 2, y: a.y + size.height / 2), size: size) } } -extension HKUnit { - var highWatermarkRange: [Double] { - if unitString == "mg/dL" { - return [150.0, 200.0, 250.0, 300.0, 350.0, 400.0] +private extension HKUnit { + var axisIncrement: Double { + return chartableIncrement * 25 + } + + var highWatermark: Double { + if self == .milligramsPerDeciliter { + return 150 } else { - return [8.0, 11.0, 14.0, 17.0, 20.0, 23.0] + return 8 } } var lowWatermark: Double { - if unitString == "mg/dL" { + if self == .milligramsPerDeciliter { return 50.0 } else { return 3.0 @@ -89,90 +107,103 @@ extension HKUnit { } } -extension WKInterfaceDevice { - enum WatchSize { - case watch38mm - case watch42mm - } +class GlucoseChartScene: SKScene { + let log = OSLog(category: "GlucoseChartScene") - func watchSize() -> WatchSize { - switch screenBounds.width { - case 136: - return .watch38mm - default: - return .watch42mm + var unit: HKUnit? + var correctionRange: GlucoseRangeSchedule? + var historicalGlucose: [SampleValue]? { + didSet { + historicalGlucoseRange = historicalGlucose?.quantityRange } } -} + private(set) var historicalGlucoseRange: Range? + var predictedGlucose: [SampleValue]? { + didSet { + predictedGlucoseRange = predictedGlucose?.quantityRange -extension WatchDatedRange { - var hashValue: UInt64 { - var hashValue: Double - hashValue = 2 * minValue - hashValue += 3 * maxValue - hashValue += 5 * startDate.timeIntervalSince1970 - hashValue += 7 * endDate.timeIntervalSince1970 - return UInt64(hashValue) + if let firstNewValue = predictedGlucose?.first { + if oldValue?.first == nil || oldValue?.first!.startDate != firstNewValue.startDate { + shouldAnimatePredictionPath = true + } + } + } } -} + private(set) var predictedGlucoseRange: Range? -extension SampleValue { - var hashValue: UInt64 { - var hashValue: Double - hashValue = 2 * startDate.timeIntervalSince1970 - hashValue += 3 * quantity.doubleValue(for: HKUnit.milligramsPerDeciliter) - return UInt64(hashValue) - } -} + private func chartableGlucoseRange(from start: Date, to end: Date) -> Range { + let unit = self.unit ?? .milligramsPerDeciliter + // Defaults + var min = unit.lowWatermark + var max = unit.highWatermark -class GlucoseChartScene: SKScene { - var unit: HKUnit? - var temporaryOverride: WatchDatedRange? - var historicalGlucose: [SampleValue]? - var predictedGlucose: [SampleValue]? - var targetRanges: [WatchDatedRange]? + for correction in correctionRange?.quantityBetween(start: start, end: end) ?? [] { + min = Swift.min(min, correction.value.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, correction.value.upperBound.doubleValue(for: unit)) + } - var visibleBg: Int = 1 { - didSet { - if let range = unit?.highWatermarkRange, (0.. SKSpriteNode { + override func didFinishUpdate() { + let isPaused = self.isPaused + let childNodesHaveActions = self.childNodesHaveActions + log.default("didFinishUpdate() needsUpdate: %d isPaused: %d childNodesHaveActions: %d", needsUpdate, isPaused, childNodesHaveActions) + + super.didFinishUpdate() + + if !needsUpdate && !isPaused && !childNodesHaveActions { + log.default("didFinishUpdate() pausing") + self.isPaused = true + } + } + + private func getSprite(forHash hashValue: Int) -> (sprite: SKSpriteNode, created: Bool) { + var created = false if nodes[hashValue] == nil { nodes[hashValue] = SKSpriteNode(color: .clear, size: CGSize(width: 0, height: 0)) addChild(nodes[hashValue]!) + created = true } - return nodes[hashValue]! + return (sprite: nodes[hashValue]!, created: created) } - func updateNodes(animated: Bool) { + func setNeedsUpdate() { dispatchPrecondition(condition: .onQueue(.main)) + needsUpdate = true + + if isPaused { + log.default("updateNodes(animated:) unpausing") + isPaused = false + } + } + private func performUpdate(animated: Bool) { guard let unit = unit else { return } - let window = TimeInterval(hours: Double(visibleHours)) - let scaler = Scaler(startDate: Date() - window, - glucoseMin: unit.lowWatermark, - xScale: size.width / CGFloat(window * 2), - yScale: size.height / CGFloat(unit.highWatermarkRange[visibleBg] - unit.lowWatermark)) - + let window = visibleDuration / 2 + let start = Date(timeIntervalSinceNow: -window) + let end = start.addingTimeInterval(visibleDuration) + let yRange = chartableGlucoseRange(from: start, to: end) + let scaler = Scaler( + dates: DateInterval(start: start, end: end), + glucoseMin: yRange.lowerBound.doubleValue(for: unit), + xScale: size.width / CGFloat(window * 2), + yScale: size.height / CGFloat(yRange.upperBound.doubleValue(for: unit) - yRange.lowerBound.doubleValue(for: unit)) + ) let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) - minBGLabel.text = numberFormatter.string(from: unit.lowWatermark) - maxBGLabel.text = numberFormatter.string(from: unit.highWatermarkRange[visibleBg]) - hoursLabel.text = "\(Int(visibleHours))h" + minBGLabel.text = numberFormatter.string(from: yRange.lowerBound.doubleValue(for: unit)) + maxBGLabel.text = numberFormatter.string(from: yRange.upperBound.doubleValue(for: unit)) + hoursLabel.text = dateFormatter.string(from: visibleDuration) // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end var inactiveNodes = nodes - targetRanges?.forEach { range in - let sprite = getSprite(forHash: range.hashValue) - sprite.color = UIColor.rangeColor.withAlphaComponent(temporaryOverride != nil ? 0.4 : 0.6) - sprite.move(to: scaler.rect(for: range), animated: animated) - inactiveNodes.removeValue(forKey: range.hashValue) - } + let activeOverride = correctionRange?.activeOverride + + correctionRange?.quantityBetween(start: start, end: end).forEach({ (range) in + let (sprite, created) = getSprite(forHash: range.chartHashValue) + sprite.color = UIColor.glucose.withAlphaComponent(activeOverride != nil ? 0.2 : 0.3) + sprite.zPosition = NodePlane.ranges.zPosition + sprite.move(to: scaler.rect(for: range, unit: unit), animated: !created) + inactiveNodes.removeValue(forKey: range.chartHashValue) + }) // Make temporary overrides visually match what we do in the Loop app. This means that we have // one darker box which represents the duration of the override, but we have a second lighter box which // extends to the end of the visible window. - if let range = temporaryOverride, range.endDate > Date() { - let sprite1 = getSprite(forHash: range.hashValue) - sprite1.color = UIColor.rangeColor.withAlphaComponent(0.6) - sprite1.move(to: scaler.rect(for: range), animated: animated) - inactiveNodes.removeValue(forKey: range.hashValue) - - let extendedRange = WatchDatedRange(startDate: range.startDate, endDate: Date() + window, minValue: range.minValue, maxValue: range.maxValue) - let sprite2 = getSprite(forHash: extendedRange.hashValue) - sprite2.color = UIColor.rangeColor.withAlphaComponent(0.4) - sprite2.move(to: scaler.rect(for: extendedRange), animated: animated) - inactiveNodes.removeValue(forKey: extendedRange.hashValue) + if let range = activeOverride { + let (sprite1, created) = getSprite(forHash: range.chartHashValue) + sprite1.color = UIColor.glucose.withAlphaComponent(0.4) + sprite1.zPosition = NodePlane.overrideRanges.zPosition + sprite1.move(to: scaler.rect(for: range, unit: unit), animated: !created) + inactiveNodes.removeValue(forKey: range.chartHashValue) + + if range.end < end { + let extendedRange = GlucoseRangeSchedule.Override(context: range.context, start: range.start, end: end, value: range.value) + let (sprite2, created) = getSprite(forHash: extendedRange.chartHashValue) + sprite2.color = UIColor.glucose.withAlphaComponent(0.25) + sprite2.zPosition = NodePlane.overrideRanges.zPosition + sprite2.move(to: scaler.rect(for: extendedRange, unit: unit), animated: !created) + inactiveNodes.removeValue(forKey: extendedRange.chartHashValue) + } } - historicalGlucose?.filter { $0.startDate > scaler.startDate }.forEach { + historicalGlucose?.filter { scaler.dates.contains($0.startDate) }.forEach { let origin = scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) let size = CGSize(width: 2, height: 2) - let sprite = getSprite(forHash: $0.hashValue) - sprite.color = .glucoseTintColor - sprite.move(to: CGRect(origin: origin, size: size), animated: animated) - inactiveNodes.removeValue(forKey: $0.hashValue) + let (sprite, created) = getSprite(forHash: $0.chartHashValue) + sprite.color = .glucose + sprite.zPosition = NodePlane.values.zPosition + sprite.move(to: CGRect(origin: origin, size: size), animated: !created) + inactiveNodes.removeValue(forKey: $0.chartHashValue) } predictedPathNode?.removeFromParent() @@ -275,15 +352,19 @@ class GlucoseChartScene: SKScene { }) predictedPathNode = SKShapeNode(path: predictedPath.copy(dashingWithPhase: 11, lengths: [5, 3])) + predictedPathNode?.zPosition = NodePlane.values.zPosition addChild(predictedPathNode!) - if animated { + if shouldAnimatePredictionPath { + shouldAnimatePredictionPath = false // SKShapeNode paths cannot be easily animated. Make it vanish, then fade in at the new location. predictedPathNode!.alpha = 0 - predictedPathNode!.run(SKAction.sequence([ - SKAction.wait(forDuration: 0.25), - SKAction.fadeIn(withDuration: 0.75) - ]), withKey: "move") + predictedPathNode!.run(.sequence([ + .wait(forDuration: 0.25), + .fadeIn(withDuration: 0.75) + ]), + withKey: "move" + ) } } @@ -292,7 +373,5 @@ class GlucoseChartScene: SKScene { node.removeFromParent() nodes.removeValue(forKey: hash) } - - isPaused = false } } diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift new file mode 100644 index 0000000000..869a1a3d93 --- /dev/null +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -0,0 +1,87 @@ +// +// GlucoseChartValueHashable.swift +// WatchApp Extension +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + + +protocol GlucoseChartValueHashable { + var start: Date { get } + var end: Date { get } + var min: Double { get } + var max: Double { get } + + var chartHashValue: Int { get } +} + +extension GlucoseChartValueHashable { + var chartHashValue: Int { + var hashValue = start.timeIntervalSinceReferenceDate.hashValue + hashValue ^= end.timeIntervalSince(start).hashValue + hashValue ^= min.hashValue + if min != max { + hashValue ^= max.hashValue + } + return hashValue + } +} + + +extension SampleValue { + var start: Date { + return startDate + } + + var end: Date { + return endDate + } + + var min: Double { + return quantity.doubleValue(for: .milligramsPerDeciliter) + } + + var max: Double { + return quantity.doubleValue(for: .milligramsPerDeciliter) + } + + var chartHashValue: Int { + var hashValue = start.timeIntervalSinceReferenceDate.hashValue + hashValue ^= end.timeIntervalSince(start).hashValue + hashValue ^= min.hashValue + return hashValue + } +} + + +extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == Range { + var start: Date { + return startDate + } + + var end: Date { + return endDate + } + + var min: Double { + return value.lowerBound.doubleValue(for: .milligramsPerDeciliter) + } + + var max: Double { + return value.upperBound.doubleValue(for: .milligramsPerDeciliter) + } +} + + +extension GlucoseRangeSchedule.Override: GlucoseChartValueHashable { + var min: Double { + return value.minValue + } + + var max: Double { + return value.maxValue + } +} diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard index 3afe28add6..8da55eb2df 100644 --- a/WatchApp/Base.lproj/Interface.storyboard +++ b/WatchApp/Base.lproj/Interface.storyboard @@ -1,12 +1,12 @@ - + - - + + @@ -334,7 +334,7 @@ - + @@ -368,13 +368,11 @@ - + - - - - + +