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 8181fd3344..d25e5c7e55 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -10,21 +10,22 @@ import Foundation import HealthKit import LoopKit + final class WatchContext: NSObject, RawRepresentable { typealias RawValue = [String: Any] private let version = 4 var preferredGlucoseUnit: HKUnit? - var maxBolus: Double? var glucose: HKQuantity? var glucoseTrendRawValue: Int? - var eventualGlucose: HKQuantity? var glucoseDate: Date? - var glucoseRangeScheduleOverride: GlucoseRangeScheduleOverrideUserInfo? - var configuredOverrideContexts: [GlucoseRangeScheduleOverrideUserInfo.Context] = [] + var predictedGlucose: WatchPredictedGlucose? + var eventualGlucose: HKQuantity? { + return predictedGlucose?.values.last?.quantity + } var loopLastRunDate: Date? var lastNetTempBasalDose: Double? @@ -34,11 +35,11 @@ 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? @@ -57,32 +58,16 @@ 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) - } - - if let glucoseValue = rawValue["egv"] as? Double { - eventualGlucose = HKQuantity(unit: preferredGlucoseUnit ?? .milligramsPerDeciliter, doubleValue: glucoseValue) + glucose = 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 @@ -91,10 +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) + } } var rawValue: RawValue { @@ -108,24 +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 + return raw } } diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift new file mode 100644 index 0000000000..cfba470355 --- /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, syncVersion: 0) + } + } +} + + +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..81db1cb41f 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 */; }; @@ -51,7 +51,7 @@ 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E02E1CFBF81800E199AA /* WKInterfaceImage.swift */; }; 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */; }; 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */; }; - 432CF87520D8AC950066B889 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */; }; + 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */; }; 432E73CB1D24B3D6009AD15D /* RemoteDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432E73CA1D24B3D6009AD15D /* RemoteDataManager.swift */; }; 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */; }; 4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4341F4EA1EDB92AC001C936B /* LogglyService.swift */; }; @@ -86,6 +86,22 @@ 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0DA41D236A2A00104B24 /* LoopError.swift */; }; 436D9BF81F6F4EA100CFA75F /* recommended_temp_start_low_end_just_above_range.json in Resources */ = {isa = PBXBuildFile; fileRef = 436D9BF71F6F4EA100CFA75F /* recommended_temp_start_low_end_just_above_range.json */; }; 43709AEA20DF3F8200F941B3 /* MinimedKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43709AE920DF3F8200F941B3 /* MinimedKitUI.framework */; }; + 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */; }; + 4372E487213C86240068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; + 4372E488213C862B0068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; + 4372E48B213CB5F00068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; + 4372E48C213CB6750068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; + 4372E48D213CF8A70068E043 /* GlucoseThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B298D2041F56500BA9F93 /* GlucoseThreshold.swift */; }; + 4372E48E213CF8AD0068E043 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B298C2041F56500BA9F93 /* LoopSettings.swift */; }; + 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; + 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */; }; + 4372E497213F79F90068E043 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */; }; + 4372E498213F7A550068E043 /* InsulinModelSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435CB6281F37B01300C320C7 /* InsulinModelSettings.swift */; }; + 4372E499213F7A6D0068E043 /* ExponentialInsulinModelPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435CB6241F37ABFC00C320C7 /* ExponentialInsulinModelPreset.swift */; }; + 4372E49A213F7A830068E043 /* WalshInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435CB6261F37AE5600C320C7 /* WalshInsulinModel.swift */; }; + 4372E49B213F7B340068E043 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; 4374B5F0209D857E00D17AA8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; 4374B5F2209D897600D17AA8 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5F1209D897600D17AA8 /* Locked.swift */; }; @@ -104,8 +120,6 @@ 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; 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 +213,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 +237,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 +250,11 @@ 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 */; }; + 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 +270,14 @@ 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 */; }; 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 */; }; @@ -264,8 +289,6 @@ 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7D7076681FE0702F004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D70766A1FE0702F004AC8EA /* InfoPlist.strings */; }; - 894B91CD1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894B91CC1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift */; }; - 894B91CE1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894B91CC1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift */; }; 894F71E21FFEC4D8007D365C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 894F71E11FFEC4D8007D365C /* Assets.xcassets */; }; C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */; }; C10B28461EA9BA5E006EA1FC /* far_future_high_bg_forecast.json in Resources */ = {isa = PBXBuildFile; fileRef = C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */; }; @@ -436,12 +459,12 @@ 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 = ""; }; 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CLKComplicationTemplate.swift; sourceTree = ""; }; - 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; + 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+WatchApp.swift"; sourceTree = ""; }; 4328E0241CFBE2C500E199AA /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKAlertAction.swift; sourceTree = ""; }; 4328E02E1CFBF81800E199AA /* WKInterfaceImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKInterfaceImage.swift; sourceTree = ""; }; @@ -477,6 +500,10 @@ 436A0DA41D236A2A00104B24 /* LoopError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopError.swift; sourceTree = ""; }; 436D9BF71F6F4EA100CFA75F /* recommended_temp_start_low_end_just_above_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommended_temp_start_low_end_just_above_range.json; sourceTree = ""; }; 43709AE920DF3F8200F941B3 /* MinimedKitUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MinimedKitUI.framework; path = Carthage/Build/iOS/MinimedKitUI.framework; sourceTree = ""; }; + 4372E486213C86240068E043 /* SampleValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleValue.swift; sourceTree = ""; }; + 4372E48A213CB5F00068E043 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; + 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettingsUserInfo.swift; sourceTree = ""; }; + 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartValueHashable.swift; sourceTree = ""; }; 4374B5EE209D84BE00D17AA8 /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; 4374B5F1209D897600D17AA8 /* Locked.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; 4374B5F3209D89A900D17AA8 /* TextFieldTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewCell.swift; sourceTree = ""; }; @@ -502,8 +529,6 @@ 437D9BA11D7B5203007245E8 /* Loop.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Loop.xcconfig; sourceTree = ""; }; 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 +626,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 +643,20 @@ 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 = ""; }; + 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 = ""; }; @@ -789,7 +823,6 @@ 7DD382771F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; 7DD382781F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainInterface.strings; sourceTree = ""; }; 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; - 894B91CC1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseRangeScheduleOverrideUserInfo.swift; sourceTree = ""; }; 894F71E11FFEC4D8007D365C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NightscoutUploadKit.framework; path = Carthage/Build/iOS/NightscoutUploadKit.framework; sourceTree = SOURCE_ROOT; }; C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = far_future_high_bg_forecast.json; sourceTree = ""; }; @@ -888,11 +921,12 @@ 4328E0121CFBE1B700E199AA /* Controllers */ = { isa = PBXGroup; children = ( + 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */, 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */, 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */, - 43846ADA1D91057000799272 /* ContextUpdatable.swift */, + 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */, + 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */, 43A943891B926B7B0051FA24 /* NotificationController.swift */, - 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */, ); path = Controllers; sourceTree = ""; @@ -902,9 +936,11 @@ children = ( 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, + 4F7E8AC420E2AB9600AEA65E /* Date.swift */, + 4F73F5FB20E2E7FA00E8D82C /* GlucoseStore.swift */, 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */, 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */, - 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */, + 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */, 4328E0241CFBE2C500E199AA /* UIColor.swift */, 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */, 43CB2B2A1D924D450079823D /* WCSession.swift */, @@ -1024,13 +1060,14 @@ 7D7076411FE06EDF004AC8EA /* ckcomplication.strings */, 7D70763C1FE06EDF004AC8EA /* InfoPlist.strings */, 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */, - 43846AD41D8FA67800799272 /* Base.lproj */, 43A943911B926B7B0051FA24 /* Info.plist */, 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */, 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */, 43A9438F1B926B7B0051FA24 /* Assets.xcassets */, 4328E0121CFBE1B700E199AA /* Controllers */, 4328E01F1CFBE2B100E199AA /* Extensions */, + 4FE3475F20D5D7FA00A86D03 /* Managers */, + 4F75F0052100146B00B5570E /* Scenes */, 43A943831B926B7B0051FA24 /* Supporting Files */, ); path = "WatchApp Extension"; @@ -1087,7 +1124,6 @@ 4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */, 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */, 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */, - 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */, C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */, 438172D81F4E9E37003C3328 /* NewPumpEvent.swift */, 43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */, @@ -1240,6 +1276,15 @@ path = Models; sourceTree = ""; }; + 4F75F0052100146B00B5570E /* Scenes */ = { + isa = PBXGroup; + children = ( + 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */, + 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */, + ); + path = Scenes; + sourceTree = ""; + }; 4FB76FC31E8C575900B39636 /* Managers */ = { isa = PBXGroup; children = ( @@ -1258,6 +1303,14 @@ path = Extensions; sourceTree = ""; }; + 4FE3475F20D5D7FA00A86D03 /* Managers */ = { + isa = PBXGroup; + children = ( + 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; 4FF4D0FA1E1834BD00846527 /* Common */ = { isa = PBXGroup; children = ( @@ -1274,12 +1327,15 @@ 43673E2E1F37BDA10058AC7C /* Insulin */, 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */, 43DE92581C5479E4001FFDE1 /* CarbEntryUserInfo.swift */, - 894B91CC1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift */, + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, 430B298D2041F56500BA9F93 /* GlucoseThreshold.swift */, 430B298C2041F56500BA9F93 /* LoopSettings.swift */, + 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */, 4FF4D0FF1E18374700846527 /* WatchContext.swift */, + 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */, + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, ); path = Models; sourceTree = ""; @@ -1287,7 +1343,9 @@ 4FF4D0FC1E1834CC00846527 /* Extensions */ = { isa = PBXGroup; children = ( + 4372E48A213CB5F00068E043 /* Double.swift */, 4F526D5E1DF2459000A04910 /* HKUnit.swift */, + 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, 43785E922120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift */, 434F54561D287FDB002A9274 /* NibLoadable.swift */, @@ -1299,6 +1357,7 @@ 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */, 4374B5EE209D84BE00D17AA8 /* OSLog.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, + 4372E486213C86240068E043 /* SampleValue.swift */, 43BFF0B11E45C18400FF19A9 /* UIColor.swift */, 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */, 4344628D20A7ADD100C4BE6F /* UserDefaults+CGM.swift */, @@ -1500,7 +1559,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0730; - LastUpgradeCheck = 0930; + LastUpgradeCheck = 0940; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { 432CF87720D8B8380066B889 = { @@ -1646,7 +1705,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43846AD51D8FA67800799272 /* Base.lproj in Resources */, 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */, 7D70763A1FE06EDF004AC8EA /* InfoPlist.strings in Resources */, 7D70763F1FE06EDF004AC8EA /* ckcomplication.strings in Resources */, @@ -1684,7 +1742,6 @@ buildActionMask = 2147483647; files = ( 7D2366E621250E0A0028B67D /* InfoPlist.strings in Resources */, - 7D2366E821250E7B0028B67D /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1810,11 +1867,13 @@ 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, + 4372E48B213CB5F00068E043 /* Double.swift in Sources */, 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, 4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */, 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 */, @@ -1831,6 +1890,7 @@ 430B298F2041F56500BA9F93 /* GlucoseThreshold.swift in Sources */, C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 435CB6251F37ABFC00C320C7 /* ExponentialInsulinModelPreset.swift in Sources */, + 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, 43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */, @@ -1864,7 +1924,6 @@ 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, 437D9BA31D7BC977007245E8 /* PredictionTableViewController.swift in Sources */, 4344628F20A7ADD500C4BE6F /* UserDefaults+CGM.swift in Sources */, - 894B91CD1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift in Sources */, 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, 43D2E8231F00425400AE5CBF /* BolusViewController+LoopDataManager.swift in Sources */, @@ -1875,9 +1934,12 @@ 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 */, + 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, 43F78D261C8FC000002152D1 /* DoseMath.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* StatusExtensionDataManager.swift in Sources */, @@ -1903,33 +1965,53 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */, 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */, 435400311C9F744E00D5819C /* BolusSuggestionUserInfo.swift in Sources */, + 4372E49A213F7A830068E043 /* WalshInsulinModel.swift in Sources */, 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */, 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */, + 4372E498213F7A550068E043 /* InsulinModelSettings.swift in Sources */, + 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */, 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */, 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */, + 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */, 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */, 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */, + 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */, + 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */, + 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */, + 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */, 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */, + 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */, 4344628E20A7ADD100C4BE6F /* UserDefaults+CGM.swift in Sources */, - 894B91CE1FF9F45900DA65F5 /* GlucoseRangeScheduleOverrideUserInfo.swift in Sources */, + 4372E499213F7A6D0068E043 /* ExponentialInsulinModelPreset.swift in Sources */, + 4F7E8AC520E2AB9600AEA65E /* Date.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 */, - 432CF87520D8AC950066B889 /* NSUserDefaults.swift in Sources */, + 4F73F5FC20E2E7FA00E8D82C /* GlucoseStore.swift in Sources */, + 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */, + 4372E48C213CB6750068E043 /* Double.swift in Sources */, 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */, + 4372E48E213CF8AD0068E043 /* LoopSettings.swift in Sources */, + 4372E49B213F7B340068E043 /* NSBundle.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, + 4372E497213F79F90068E043 /* NSUserDefaults.swift in Sources */, + 4372E491213D05F90068E043 /* LoopSettingsUserInfo.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 */, + 4372E48D213CF8A70068E043 /* GlucoseThreshold.swift in Sources */, 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Loop/Managers/DiagnosticLogger.swift b/Loop/Managers/DiagnosticLogger.swift index a02a85b5cd..9959765e66 100644 --- a/Loop/Managers/DiagnosticLogger.swift +++ b/Loop/Managers/DiagnosticLogger.swift @@ -108,7 +108,7 @@ final class CategoryLogger { } func `default`(_ message: String) { - systemLog.info("%{public}@", message) + systemLog.default("%{public}@", message) remoteLog(.default, message: message) } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index f59f05fa28..e7c055e7dd 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -11,8 +11,7 @@ import UIKit import WatchConnectivity import LoopKit - -final class WatchDataManager: NSObject, WCSessionDelegate { +final class WatchDataManager: NSObject { unowned let deviceManager: DeviceDataManager @@ -38,8 +37,7 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } }() - private var lastActiveOverrideContext: GlucoseRangeSchedule.Override.Context? - private var lastConfiguredOverrideContexts: [GlucoseRangeSchedule.Override.Context] = [] + private var lastSentSettings: LoopSettings? @objc private func updateWatch(_ notification: Notification) { guard @@ -51,19 +49,13 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } switch updateContext { + case .glucose: + break case .tempBasal: break case .preferences: - let activeOverrideContext = deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.activeOverrideContext - let configuredOverrideContexts = deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.configuredOverrideContexts ?? [] - defer { - lastActiveOverrideContext = activeOverrideContext - lastConfiguredOverrideContexts = configuredOverrideContexts - } - - guard activeOverrideContext != lastActiveOverrideContext || configuredOverrideContexts != lastConfiguredOverrideContexts else { - return - } + sendSettingsIfNeeded() + return default: return } @@ -85,6 +77,35 @@ final class WatchDataManager: NSObject, WCSessionDelegate { private let minTrendDrift: Double = 20 private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter + private func sendSettingsIfNeeded() { + let settings = deviceManager.loopManager.settings + + guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { + return + } + + guard case .activated = session.activationState else { + session.activate() + return + } + + guard settings != lastSentSettings else { + log.default("Skipping settings transfer due to no changes") + return + } + + lastSentSettings = settings + + log.default("Transferring LoopSettingsUserInfo") + session.transferUserInfo(LoopSettingsUserInfo(settings: settings).rawValue) + + createWatchContext { (context) in + if let context = context { + self.sendWatchContext(context) + } + } + } + private func sendWatchContext(_ context: WatchContext) { if let session = watchSession, session.isPaired && session.isWatchAppInstalled { let complicationShouldUpdate: Bool @@ -123,34 +144,45 @@ 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) + let updateGroup = DispatchGroup() + let context = WatchContext(glucose: glucose, 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.deviceManager.cgmManager?.sensorState?.trendType?.rawValue context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - if let glucoseTargetRangeSchedule = manager.settings.glucoseTargetRangeSchedule { - if let override = glucoseTargetRangeSchedule.override { - context.glucoseRangeScheduleOverride = GlucoseRangeScheduleOverrideUserInfo( - context: override.context.correspondingUserInfoContext, - startDate: override.start, - endDate: override.end - ) + 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() + } - let configuredOverrideContexts = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.configuredOverrideContexts ?? [] - let configuredUserInfoOverrideContexts = configuredOverrideContexts.map { $0.correspondingUserInfoContext } - context.configuredOverrideContexts = configuredUserInfoOverrideContexts + // 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 } - if let trend = self.deviceManager.cgmManager?.sensorState?.trendType { - context.glucoseTrendRawValue = trend.rawValue + // 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) } } @@ -178,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?: @@ -197,24 +230,24 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } replyHandler([:]) - case GlucoseRangeScheduleOverrideUserInfo.name?: - 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 + 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 = deviceManager.loopManager { + manager.glucoseStore.getCachedGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (values) in + replyHandler(WatchHistoricalGlucose(with: values).rawValue) } - - replyHandler([:]) } else { - lastActiveOverrideContext = nil - deviceManager.loopManager.settings.glucoseTargetRangeSchedule?.clearOverride() replyHandler([:]) } default: @@ -231,6 +264,8 @@ final class WatchDataManager: NSObject, WCSessionDelegate { case .activated: if let error = error { log.error(error) + } else { + sendSettingsIfNeeded() } case .inactive, .notActivated: break @@ -240,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 + } } } @@ -252,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 4b149cabb1..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().lastContext?.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().lastContext?.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().lastContext, + 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().lastContext, + 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 new file mode 100644 index 0000000000..e5bfd49e68 --- /dev/null +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -0,0 +1,157 @@ +// +// ActionHUDController.swift +// Loop +// +// Created by Nathan Racklyeft on 5/29/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import WatchKit +import WatchConnectivity +import LoopKit + + +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) + + override func willActivate() { + super.willActivate() + + let userActivity = NSUserActivity.forViewLoopStatus() + if #available(watchOSApplicationExtension 5.0, *) { + update(userActivity) + } else { + updateUserActivity(userActivity.activityType, userInfo: userActivity.userInfo, webpageURL: nil) + } + } + override func update() { + super.update() + + let schedule = loopManager?.settings.glucoseTargetRangeSchedule + let activeOverrideContext: GlucoseRangeSchedule.Override.Context? + if let glucoseRangeScheduleOverride = schedule?.override, glucoseRangeScheduleOverride.isActive() + { + activeOverrideContext = glucoseRangeScheduleOverride.context + } else { + activeOverrideContext = nil + } + updateForOverrideContext(activeOverrideContext) + + for overrideContext in GlucoseRangeSchedule.Override.Context.all { + let contextButtonGroup = buttonGroup(for: overrideContext) + if schedule == nil || !(schedule!.configuredOverrideContexts.contains(overrideContext)) { + contextButtonGroup.state = .disabled + } else if contextButtonGroup.state == .disabled { + contextButtonGroup.state = .off + } + } + } + + private func updateForOverrideContext(_ context: GlucoseRangeSchedule.Override.Context?) { + switch context { + case .preMeal?: + preMealButtonGroup.state = .on + workoutButtonGroup.turnOff() + case .workout?: + preMealButtonGroup.turnOff() + workoutButtonGroup.state = .on + case nil: + preMealButtonGroup.turnOff() + workoutButtonGroup.turnOff() + } + } + + private func buttonGroup(for overrideContext: GlucoseRangeSchedule.Override.Context) -> ButtonGroup { + switch overrideContext { + case .preMeal: + return preMealButtonGroup + case .workout: + return workoutButtonGroup + } + } + + // MARK: - Menu Items + + @IBAction func togglePreMealMode() { + guard var glucoseTargetRangeSchedule = loopManager?.settings.glucoseTargetRangeSchedule else { + return + } + if preMealButtonGroup.state == .on { + glucoseTargetRangeSchedule.clearOverride() + } else { + guard glucoseTargetRangeSchedule.setOverride(.preMeal, until: Date(timeIntervalSinceNow: .hours(1))) else { + return + } + } + + sendGlucoseRangeSchedule(glucoseTargetRangeSchedule) + } + + @IBAction func toggleWorkoutMode() { + guard var glucoseTargetRangeSchedule = loopManager?.settings.glucoseTargetRangeSchedule else { + return + } + if workoutButtonGroup.state == .on { + glucoseTargetRangeSchedule.clearOverride() + } else { + guard glucoseTargetRangeSchedule.setOverride(.workout, until: .distantFuture) else { + return + } + } + + sendGlucoseRangeSchedule(glucoseTargetRangeSchedule) + } + + private var pendingMessageResponses = 0 + + private func sendGlucoseRangeSchedule(_ schedule: GlucoseRangeSchedule) { + updateForOverrideContext(schedule.override?.context) + pendingMessageResponses += 1 + do { + 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 { + ExtensionDelegate.shared().present(error) + self.updateForOverrideContext(self.loopManager?.settings.glucoseTargetRangeSchedule?.override?.context) + } + } else { + if self.pendingMessageResponses == 0 { + self.loopManager?.settings.glucoseTargetRangeSchedule = schedule + } + } + } + }) + } catch { + pendingMessageResponses -= 1 + if pendingMessageResponses == 0 { + 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"), + message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), + preferredStyle: .alert, + actions: [WKAlertAction.dismissAction()] + ) + } + } +} + + +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 new file mode 100644 index 0000000000..2545739dc7 --- /dev/null +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -0,0 +1,197 @@ +// +// 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 +import os.log + +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.visibleDuration = .hours(2) + } + @IBAction func setChartWindow2Hours() { + scene.visibleDuration = .hours(4) + } + @IBAction func setChartWindow3Hours() { + 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) { [weak self] (note) in + self?.log.default("Received GlucoseSamplesDidChange notification: %{public}@. Updating chart", String(describing: note.userInfo ?? [:])) + + DispatchQueue.main.async { + self?.updateGlucoseChart() + } + } + + glucoseScene.presentScene(scene) + } + + override func awake(withContext context: Any?) { + super.awake(withContext: context) + + if UserDefaults.standard.startOnChartPage { + 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. + // + // TODO: possibly because I'm not calling super.awake()? investigate that. + DispatchQueue.main.async { + self.didAppear() + } + } + } + + 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 willDisappear() { + super.willDisappear() + + log.default("willDisappear") + + timer = nil + } + + override func willActivate() { + super.willActivate() + + 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() { + 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: 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: 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: 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) + } + + if glucoseScene.isPaused { + log.default("update() unpausing") + glucoseScene.isPaused = false + } + + updateGlucoseChart() + } + + func updateGlucoseChart() { + guard let activeContext = loopManager?.activeContext else { + return + } + + scene.predictedGlucose = activeContext.predictedGlucose?.values + scene.correctionRange = loopManager?.settings.glucoseTargetRangeSchedule + scene.unit = activeContext.preferredGlucoseUnit + + loopManager?.glucoseStore.getCachedGlucoseSamples(start: .earliestGlucoseCutoff) { (samples) in + DispatchQueue.main.async { + self.scene.historicalGlucose = samples + self.scene.setNeedsUpdate() + } + } + } +} 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..55eb8299d7 --- /dev/null +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -0,0 +1,103 @@ +// +// 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() + + update() + + if activeContextObserver == nil { + activeContextObserver = NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] _ in + DispatchQueue.main.async { + self?.update() + } + } + } + } + + override func didDeactivate() { + super.didDeactivate() + + if let observer = activeContextObserver { + NotificationCenter.default.removeObserver(observer) + } + activeContextObserver = nil + } + + 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 + } + }()) + } + + @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/Controllers/StatusInterfaceController.swift b/WatchApp Extension/Controllers/StatusInterfaceController.swift deleted file mode 100644 index 9e56ce5ded..0000000000 --- a/WatchApp Extension/Controllers/StatusInterfaceController.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// StatusInterfaceController.swift -// Loop -// -// Created by Nathan Racklyeft on 5/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -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! - - @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? - - override func didAppear() { - super.didAppear() - } - - override func willActivate() { - super.willActivate() - - updateLoopHUD() - - let userActivity = NSUserActivity.forViewLoopStatus() - if #available(watchOSApplicationExtension 5.0, *) { - update(userActivity) - } else { - updateUserActivity(userActivity.activityType, userInfo: userActivity.userInfo, webpageURL: nil) - } - } - - private func updateLoopHUD() { - guard let date = lastContext?.loopLastRunDate else { - loopHUDImage.setLoopImage(.unknown) - 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()) - { - overrideContext = glucoseRangeScheduleOverride.context - } else { - overrideContext = nil - } - 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 - } - } - } - - // TODO: Other elements - statusLabel.setHidden(true) - } - - private func updateForOverrideContext(_ context: GlucoseRangeScheduleOverrideUserInfo.Context?) { - switch context { - case .preMeal?: - preMealButtonGroup.state = .on - workoutButtonGroup.turnOff() - case .workout?: - preMealButtonGroup.turnOff() - workoutButtonGroup.state = .on - case nil: - preMealButtonGroup.turnOff() - workoutButtonGroup.turnOff() - } - } - - private func buttonGroup(for overrideContext: GlucoseRangeScheduleOverrideUserInfo.Context) -> ButtonGroup { - switch overrideContext { - case .preMeal: - return preMealButtonGroup - case .workout: - return workoutButtonGroup - } - } - - // MARK: - Menu Items - - @IBAction func addCarbs() { - presentController(withName: AddCarbsInterfaceController.className, context: nil) - } - - @IBAction func setBolus() { - presentController(withName: BolusInterfaceController.className, context: lastContext?.bolusSuggestion) - } - - @IBAction func togglePreMealMode() { - let userInfo: GlucoseRangeScheduleOverrideUserInfo? - if preMealButtonGroup.state == .on { - userInfo = nil - } else { - userInfo = GlucoseRangeScheduleOverrideUserInfo(context: .preMeal, startDate: Date(), endDate: Date(timeIntervalSinceNow: .hours(1))) - } - - updateForOverrideContext(userInfo?.context) - sendGlucoseRangeOverride(userInfo: userInfo) - } - - @IBAction func toggleWorkoutMode() { - let userInfo: GlucoseRangeScheduleOverrideUserInfo? - if workoutButtonGroup.state == .on { - userInfo = nil - } else { - userInfo = GlucoseRangeScheduleOverrideUserInfo(context: .workout, startDate: Date(), endDate: nil) - } - - updateForOverrideContext(userInfo?.context) - sendGlucoseRangeOverride(userInfo: userInfo) - } - - private var pendingMessageResponses = 0 - - private func sendGlucoseRangeOverride(userInfo: GlucoseRangeScheduleOverrideUserInfo?) { - pendingMessageResponses += 1 - do { - try WCSession.default.sendGlucoseRangeScheduleOverrideMessage(userInfo, - replyHandler: { _ in - DispatchQueue.main.async { - self.pendingMessageResponses -= 1 - if self.pendingMessageResponses == 0 { - self.updateForOverrideContext(userInfo?.context) - } - self.lastOverrideContext = userInfo?.context - } - }, - errorHandler: { error in - DispatchQueue.main.async { - self.pendingMessageResponses -= 1 - if self.pendingMessageResponses == 0 { - self.updateForOverrideContext(self.lastOverrideContext) - } - ExtensionDelegate.shared().present(error) - } - } - ) - } catch { - pendingMessageResponses -= 1 - if pendingMessageResponses == 0 { - updateForOverrideContext(lastOverrideContext) - } - presentAlert( - withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), - preferredStyle: .alert, - actions: [WKAlertAction.dismissAction()] - ) - } - } -} diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 006ff237cd..52ea89d225 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -16,9 +16,13 @@ import UserNotifications final class ExtensionDelegate: NSObject, WKExtensionDelegate { + private(set) lazy var loopManager = LoopDataManager() private let log = OSLog(category: "ExtensionDelegate") + private var observers: [NSKeyValueObservation] = [] + private var notifications: [NSObjectProtocol] = [] + static func shared() -> ExtensionDelegate { return WKExtension.shared().extensionDelegate } @@ -32,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) } } @@ -57,7 +80,14 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } } + func applicationWillResignActive() { + UserDefaults.standard.startOnChartPage = (WKExtension.shared().visibleInterfaceController as? ChartHUDController) != nil + } + + // Presumably the main thread? func handle(_ backgroundTasks: Set) { + loopManager.requestGlucoseBackfillIfNecessary() + for task in backgroundTasks { switch task { case is WKApplicationRefreshBackgroundTask: @@ -97,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 { @@ -111,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: @@ -120,60 +151,45 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } } - // Main queue only - private(set) var lastContext: WatchContext? { - didSet { - WKExtension.shared().rootUpdatableInterfaceController?.update(with: lastContext) + 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.lastContext == nil || context.shouldReplace(self.lastContext!) { - self.lastContext = context - } - } - } - } else { - DispatchQueue.main.async { - if self.lastContext == nil || context.shouldReplace(self.lastContext!) { - self.lastContext = 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) + } } } @@ -190,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 } } } @@ -213,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()]) } } @@ -222,8 +255,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..9af6427d7c --- /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(timeIntervalSinceNow: .hours(-3)) + } + + static var staleGlucoseCutoff: Date { + return Date(timeIntervalSinceNow: .minutes(-5)) + } +} diff --git a/WatchApp Extension/Extensions/GlucoseStore.swift b/WatchApp Extension/Extensions/GlucoseStore.swift new file mode 100644 index 0000000000..f866595ea0 --- /dev/null +++ b/WatchApp Extension/Extensions/GlucoseStore.swift @@ -0,0 +1,15 @@ +// +// 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 { + +} diff --git a/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift new file mode 100644 index 0000000000..012d29b1f2 --- /dev/null +++ b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift @@ -0,0 +1,25 @@ +// +// NSUserDefaults.swift +// Naterade +// +// Created by Nathan Racklyeft on 3/29/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +extension UserDefaults { + private enum Key: String { + 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) + } + } +} diff --git a/WatchApp Extension/Extensions/NSUserDefaults.swift b/WatchApp Extension/Extensions/NSUserDefaults.swift deleted file mode 100644 index b45ba55feb..0000000000 --- a/WatchApp Extension/Extensions/NSUserDefaults.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// NSUserDefaults.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -extension UserDefaults { - private enum Key: String { - case ComplicationDataLastRefreshed = "com.loudnate.Naterade.ComplicationDataLastRefreshed" - } - - 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 11c9114131..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,18 +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, completionHandler: @escaping (WCSessionMessageResult) -> Void) { + log.default("sendGlucoseBackfillRequestMessage: since %{public}@", String(describing: userInfo.startDate)) + + // Backfill is optional so we ignore any errors + 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) { + 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: { error in + log.error("sendGlucoseBackfillRequestMessage error: %{public}@", String(describing: error)) + completionHandler(.failure(.send(error))) + } ) } } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift new file mode 100644 index 0000000000..b8ac90d1f5 --- /dev/null +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -0,0 +1,114 @@ +// +// 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 +import WatchConnectivity +import os.log + +class LoopDataManager { + 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 { + needsDidUpdateContextNotification = true + sendDidUpdateContextNotificationIfNecessary() + } + } + + 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.controllerInLocalDirectory(), + cacheLength: .hours(4) + ) + } +} + +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 new file mode 100644 index 0000000000..26264c64d7 --- /dev/null +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -0,0 +1,377 @@ +// +// 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 os.log + +private enum NodePlane: Int { + case lines = 0 + case ranges + case overrideRanges + case values + case labels + + var zPosition: CGFloat { + return CGFloat(rawValue) + } +} + +private extension SKLabelNode { + static func basic(at position: CGPoint) -> SKLabelNode { + let basic = SKLabelNode(text: "--") + basic.fontSize = 12 + basic.fontName = "HelveticaNeue" + basic.fontColor = .chartLabel + basic.alpha = 0.8 + basic.verticalAlignmentMode = .top + basic.horizontalAlignmentMode = .left + basic.position = position + basic.zPosition = NodePlane.labels.zPosition + return basic + } +} + +private extension SKSpriteNode { + func move(to rect: CGRect, animated: Bool) { + if parent == nil || animated == false { + size = rect.size + position = rect.origin + } else { + 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) + ])) + } + } +} + +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(dates.start)) * xScale, y: CGFloat(y - glucoseMin) * yScale) + } + + // By default enforce a minimum height so that the range is visible + 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) + } +} + +private extension HKUnit { + var axisIncrement: Double { + return chartableIncrement * 25 + } + + var highWatermark: Double { + if self == .milligramsPerDeciliter { + return 150 + } else { + return 8 + } + } + + var lowWatermark: Double { + if self == .milligramsPerDeciliter { + return 50.0 + } else { + return 3.0 + } + } +} + +class GlucoseChartScene: SKScene { + let log = OSLog(category: "GlucoseChartScene") + + 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 + + if let firstNewValue = predictedGlucose?.first { + if oldValue?.first == nil || oldValue?.first!.startDate != firstNewValue.startDate { + shouldAnimatePredictionPath = true + } + } + } + } + private(set) var predictedGlucoseRange: Range? + + private func chartableGlucoseRange(from start: Date, to end: Date) -> Range { + let unit = self.unit ?? .milligramsPerDeciliter + + // Defaults + var min = unit.lowWatermark + var max = unit.highWatermark + + 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)) + } + + if let override = correctionRange?.activeOverrideQuantityRange { + min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, override.upperBound.doubleValue(for: unit)) + } + + if let historicalGlucoseRange = historicalGlucoseRange { + min = Swift.min(min, historicalGlucoseRange.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, historicalGlucoseRange.upperBound.doubleValue(for: unit)) + } + + if let predictedGlucoseRange = predictedGlucoseRange { + min = Swift.min(min, predictedGlucoseRange.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, predictedGlucoseRange.upperBound.doubleValue(for: unit)) + } + + min = min.floored(to: unit.axisIncrement) + max = max.ceiled(to: unit.axisIncrement) + + let lowerBound = HKQuantity(unit: unit, doubleValue: min) + let upperBound = HKQuantity(unit: unit, doubleValue: max) + + return lowerBound.. (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 (sprite: nodes[hashValue]!, created: created) + } + + 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 = 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: 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 + + 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 = 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 { 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, 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() + 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])) + predictedPathNode?.zPosition = NodePlane.values.zPosition + addChild(predictedPathNode!) + + 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(.sequence([ + .wait(forDuration: 0.25), + .fadeIn(withDuration: 0.75) + ]), + withKey: "move" + ) + } + } + + // Any inactive nodes can be safely removed + inactiveNodes.forEach { hash, node in + node.removeFromParent() + nodes.removeValue(forKey: hash) + } + } +} 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/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 0000000000..3db2e8a0a2 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-38mm.png differ 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 0000000000..63f7579ca6 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph-42mm.png differ diff --git a/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-38mm.png new file mode 100644 index 0000000000..ab36b57710 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-38mm.png differ 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 0000000000..c8f39f9905 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/1-hour-graph.imageset/1-hour-graph-42mm.png differ 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 0000000000..6f60342c72 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-38mm.png differ 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 0000000000..a09880e901 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph-42mm.png differ 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 0000000000..f4ff6ee730 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-38mm.png differ 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 0000000000..c43ed36fab Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/2-hour-graph-42mm.png differ diff --git a/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/Contents.json b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/Contents.json new file mode 100644 index 0000000000..b2b2284ab7 --- /dev/null +++ b/WatchApp/Assets.xcassets/Graph menu icons/2-hour-graph.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "watch", + "filename" : "2-hour-graph-38mm.png", + "screen-width" : "<=145", + "scale" : "2x" + }, + { + "idiom" : "watch", + "filename" : "2-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/3-hour-graph-38mm.png b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-38mm.png new file mode 100644 index 0000000000..d76ad7aa01 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-38mm.png differ diff --git a/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-42mm.png b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-42mm.png new file mode 100644 index 0000000000..c7474e6a2d Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph-42mm.png differ 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 0000000000..6f18861f4c Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-38mm.png differ 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 0000000000..b4bd9938b5 Binary files /dev/null and b/WatchApp/Assets.xcassets/Graph menu icons/3-hour-graph.imageset/3-hour-graph-42mm.png differ 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..8da55eb2df 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,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -359,7 +443,7 @@ - +