From 1e17f098fcb00606c645c1d74f45d573c67cace1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 1 Jul 2017 14:05:20 -0500 Subject: [PATCH 1/2] dynamic carb absorption --- Loop.xcodeproj/project.pbxproj | 24 + .../Uploading.imageset/Contents.json | 12 + .../Uploading.imageset/Uploading.pdf | Bin 0 -> 6413 bytes Loop/Base.lproj/Main.storyboard | 328 ++++++++++- Loop/Extensions/NSUserDefaults.swift | 14 + Loop/Managers/DoseMath.swift | 3 +- Loop/Managers/LoopDataManager.swift | 231 ++++++-- Loop/Managers/NightscoutDataManager.swift | 46 +- .../StatusChartsManager+LoopKit.swift | 78 +++ Loop/Models/GlucoseEffectVelocity.swift | 38 ++ Loop/Models/LoopSettings.swift | 2 + .../BolusViewController+LoopDataManager.swift | 49 ++ .../CarbAbsorptionViewController.swift | 538 ++++++++++++++++++ .../SettingsTableViewController.swift | 16 +- .../StatusTableViewController.swift | 132 +++-- Loop/Views/CarbEntryTableViewCell.swift | 123 ++++ Loop/Views/CircleMaskView.swift | 18 + Loop/Views/HeaderValuesTableViewCell.swift | 19 + LoopUI/Managers/StatusChartsManager.swift | 148 ++++- 19 files changed, 1651 insertions(+), 168 deletions(-) create mode 100644 Loop/Assets.xcassets/Uploading.imageset/Contents.json create mode 100644 Loop/Assets.xcassets/Uploading.imageset/Uploading.pdf create mode 100644 Loop/Models/GlucoseEffectVelocity.swift create mode 100644 Loop/View Controllers/BolusViewController+LoopDataManager.swift create mode 100644 Loop/View Controllers/CarbAbsorptionViewController.swift create mode 100644 Loop/Views/CarbEntryTableViewCell.swift create mode 100644 Loop/Views/CircleMaskView.swift create mode 100644 Loop/Views/HeaderValuesTableViewCell.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 5c4601077b..54d4a99e5c 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 430DA5901D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58F1D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift */; }; 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */; }; 4315D28A1CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */; }; + 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */; }; 4328E01A1CFBE1DA00E199AA /* StatusInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */; }; 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */; }; 4328E01E1CFBE25F00E199AA /* AddCarbsInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */; }; @@ -78,6 +79,7 @@ 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED291E76093C00B0AED5 /* CGMManager.swift */; }; 439BED2C1E760A7A00B0AED5 /* DexCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED2B1E760A7A00B0AED5 /* DexCGMManager.swift */; }; 439BED2E1E760BC600B0AED5 /* EnliteCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED2D1E760BC600B0AED5 /* EnliteCGMManager.swift */; }; + 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */; }; 43A51E211EB6DBDD000736CC /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */; }; 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; }; 43A5676B1C96155700334FAC /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */; }; @@ -89,6 +91,7 @@ 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */; }; 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43A9438F1B926B7B0051FA24 /* Assets.xcassets */; }; 43A943941B926B7B0051FA24 /* WatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 43A943721B926B7B0051FA24 /* WatchApp.app */; }; + 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */; }; 43B371881CE597D10013C5A6 /* ShareClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43B371871CE597D10013C5A6 /* ShareClient.framework */; }; 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43BFF0B71E45C20C00FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; @@ -102,6 +105,7 @@ 43BFF0CD1E466C8400FF19A9 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */; }; 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C094491CACCC73001F6403 /* NotificationManager.swift */; }; 43C246A81D89990F0031F8D1 /* Crypto.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43C246A71D89990F0031F8D1 /* Crypto.framework */; }; + 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */; }; 43C418B51CE0575200405B6A /* ShareGlucose+GlucoseKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C418B41CE0575200405B6A /* ShareGlucose+GlucoseKit.swift */; }; 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; 43C6407C1DA051850093E25D /* InsulinKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43C6407B1DA051850093E25D /* InsulinKit.framework */; }; @@ -109,6 +113,8 @@ 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CB2B2A1D924D450079823D /* WCSession.swift */; }; 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */; }; 43CEE6E61E56AFD400CB9116 /* NightscoutUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */; }; + 43D2E8231F00425400AE5CBF /* BolusViewController+LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */; }; + 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */; }; 43D848B01E7DCBE100DADCBC /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; 43D848B21E7DF42500DADCBC /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848B11E7DF42500DADCBC /* LoopSettings.swift */; }; 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */; }; @@ -365,6 +371,7 @@ 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartContentView.swift; sourceTree = ""; }; 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryEditTableViewController.swift; sourceTree = ""; }; 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DiagnosticLogger+LoopKit.swift"; sourceTree = ""; }; + 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusInterfaceController.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 = ""; }; @@ -436,6 +443,7 @@ 439BED291E76093C00B0AED5 /* CGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMManager.swift; sourceTree = ""; }; 439BED2B1E760A7A00B0AED5 /* DexCGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DexCGMManager.swift; sourceTree = ""; }; 439BED2D1E760BC600B0AED5 /* EnliteCGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnliteCGMManager.swift; sourceTree = ""; }; + 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAbsorptionViewController.swift; sourceTree = ""; }; 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; 43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchTableViewCell.swift; sourceTree = ""; }; @@ -449,6 +457,7 @@ 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = ""; }; 43A9438F1B926B7B0051FA24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43A943911B926B7B0051FA24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryTableViewCell.swift; sourceTree = ""; }; 43B371851CE583890013C5A6 /* BasalStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalStateView.swift; sourceTree = ""; }; 43B371871CE597D10013C5A6 /* ShareClient.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ShareClient.framework; path = Carthage/Build/iOS/ShareClient.framework; sourceTree = ""; }; 43BFF0B11E45C18400FF19A9 /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; @@ -461,6 +470,7 @@ 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; 43C094491CACCC73001F6403 /* NotificationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 43C246A71D89990F0031F8D1 /* Crypto.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Crypto.framework; path = Carthage/Build/iOS/Crypto.framework; sourceTree = ""; }; + 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseEffectVelocity.swift; sourceTree = ""; }; 43C418B41CE0575200405B6A /* ShareGlucose+GlucoseKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ShareGlucose+GlucoseKit.swift"; sourceTree = ""; }; 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseRangeSchedule.swift; sourceTree = ""; }; 43C6407B1DA051850093E25D /* InsulinKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = InsulinKit.framework; path = Carthage/Build/iOS/InsulinKit.framework; sourceTree = ""; }; @@ -468,6 +478,8 @@ 43CB2B2A1D924D450079823D /* WCSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WCSession.swift; sourceTree = ""; }; 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; 43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutUploader.swift; sourceTree = ""; }; + 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BolusViewController+LoopDataManager.swift"; sourceTree = ""; }; + 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderValuesTableViewCell.swift; sourceTree = ""; }; 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "WatchApp Extension.entitlements"; sourceTree = ""; }; 43D848AF1E7DCBE100DADCBC /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; 43D848B11E7DF42500DADCBC /* LoopSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopSettings.swift; sourceTree = ""; }; @@ -672,6 +684,7 @@ 4309786D1E73DAD100BEBC82 /* CGM.swift */, 540DED961E14C75F002B2491 /* EnliteSensorDisplayable.swift */, 43E397A21D56B9E40028E321 /* Glucose.swift */, + 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, 4D5B7A4A1D457CCA00796CA9 /* GlucoseG4.swift */, C178249D1E19B62300D9D25C /* GlucoseThreshold.swift */, 436FACED1D0BA636004E2427 /* InsulinDataSource.swift */, @@ -862,6 +875,8 @@ children = ( 437CCADD1D2858FD0075D2C3 /* AuthenticationViewController.swift */, 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */, + 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */, + 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */, 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */, 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */, 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */, @@ -885,7 +900,10 @@ 434F545A1D2880D4002A9274 /* AuthenticationTableViewCell.xib */, 437CCADB1D284B830075D2C3 /* ButtonTableViewCell.swift */, 434F54581D28805E002A9274 /* ButtonTableViewCell.xib */, + 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */, 4346D1E61C77F5FE00ABAFE3 /* ChartTableViewCell.swift */, + 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, + 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, @@ -1445,6 +1463,7 @@ 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, 430DA58E1D4AEC230097D1CA /* NSBundle.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 437CCADA1D284ADF0075D2C3 /* AuthenticationTableViewCell.swift in Sources */, 439BED2E1E760BC600B0AED5 /* EnliteCGMManager.swift in Sources */, @@ -1460,6 +1479,7 @@ 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, 43D848B01E7DCBE100DADCBC /* Result.swift in Sources */, 43E397A31D56B9E40028E321 /* Glucose.swift in Sources */, + 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, 43E344A41B9E1B1C00C85C07 /* NSUserDefaults.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, @@ -1492,6 +1512,7 @@ 4FB76FBB1E8C42CF00B39636 /* UIColor.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, + 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */, 43BFF0C51E465A2D00FF19A9 /* UIColor+HIG.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 437CCAE01D285C7B0075D2C3 /* ServiceAuthentication.swift in Sources */, @@ -1506,6 +1527,7 @@ 437D9BA31D7BC977007245E8 /* PredictionTableViewController.swift in Sources */, 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, + 43D2E8231F00425400AE5CBF /* BolusViewController+LoopDataManager.swift in Sources */, 434F545F1D288345002A9274 /* ShareService.swift in Sources */, 43441AA01EDB4D390087958C /* OSLog.swift in Sources */, 43CEE6E61E56AFD400CB9116 /* NightscoutUploader.swift in Sources */, @@ -1522,6 +1544,7 @@ 434F54611D28859B002A9274 /* ServiceCredential.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* StatusExtensionDataManager.swift in Sources */, 436FACEE1D0BA636004E2427 /* InsulinDataSource.swift in Sources */, + 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsManager.swift in Sources */, 43A51E211EB6DBDD000736CC /* ChartsTableViewController.swift in Sources */, 4346D1F61C78501000ABAFE3 /* ChartPoint+Loop.swift in Sources */, @@ -1533,6 +1556,7 @@ 43DE92591C5479E4001FFDE1 /* CarbEntryUserInfo.swift in Sources */, 434F54631D28DD80002A9274 /* ValidatingIndicatorView.swift in Sources */, 43DE92611C555C26001FFDE1 /* AbsorptionTimeType+CarbKit.swift in Sources */, + 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/Assets.xcassets/Uploading.imageset/Contents.json b/Loop/Assets.xcassets/Uploading.imageset/Contents.json new file mode 100644 index 0000000000..61b11d2882 --- /dev/null +++ b/Loop/Assets.xcassets/Uploading.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Uploading.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Loop/Assets.xcassets/Uploading.imageset/Uploading.pdf b/Loop/Assets.xcassets/Uploading.imageset/Uploading.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d14e9d070d3e3fff571b850708f51d0167c86257 GIT binary patch literal 6413 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 W@8pb= - + - + + @@ -145,6 +146,7 @@ Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction. + diff --git a/Loop/Extensions/NSUserDefaults.swift b/Loop/Extensions/NSUserDefaults.swift index 8e84f5019b..5f5709574b 100644 --- a/Loop/Extensions/NSUserDefaults.swift +++ b/Loop/Extensions/NSUserDefaults.swift @@ -173,6 +173,20 @@ extension UserDefaults { } } + var insulinCounteractionEffects: [GlucoseEffectVelocity]? { + get { + guard let rawValue = array(forKey: Key.insulinCounteractionEffects.rawValue) as? [GlucoseEffectVelocity.RawValue] else { + return nil + } + return rawValue.flatMap { + GlucoseEffectVelocity(rawValue: $0) + } + } + set { + set(newValue?.map({ $0.rawValue }), forKey: Key.insulinCounteractionEffects.rawValue) + } + } + var insulinSensitivitySchedule: InsulinSensitivitySchedule? { get { if let rawValue = dictionary(forKey: Key.insulinSensitivitySchedule.rawValue) { diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index 38f8956a13..aee2fd3ce2 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -7,12 +7,13 @@ // import Foundation +import CarbKit import HealthKit import InsulinKit import LoopKit -struct DoseMath { +enum DoseMath { /// The allowed precision static let basalStrokes: Double = 40 diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index dd20aa0df6..4d60c51252 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -27,7 +27,7 @@ final class LoopDataManager { typealias TempBasalRecommendation = (recommendedDate: Date, rate: Double, duration: TimeInterval) - private typealias GlucoseChange = (start: GlucoseValue, end: GlucoseValue) + fileprivate typealias GlucoseChange = (start: GlucoseValue, end: GlucoseValue) let carbStore: CarbStore! @@ -46,11 +46,13 @@ final class LoopDataManager { basalRateSchedule: BasalRateSchedule? = UserDefaults.standard.basalRateSchedule, carbRatioSchedule: CarbRatioSchedule? = UserDefaults.standard.carbRatioSchedule, insulinActionDuration: TimeInterval? = UserDefaults.standard.insulinActionDuration, + insulinCounteractionEffects: [GlucoseEffectVelocity]? = UserDefaults.standard.insulinCounteractionEffects, insulinSensitivitySchedule: InsulinSensitivitySchedule? = UserDefaults.standard.insulinSensitivitySchedule, settings: LoopSettings = UserDefaults.standard.loopSettings ?? LoopSettings() ) { self.delegate = delegate self.logger = DiagnosticLogger.shared!.forCategory("LoopDataManager") + self.insulinCounteractionEffects = insulinCounteractionEffects ?? [] self.lastLoopCompleted = lastLoopCompleted self.lastTempBasal = lastTempBasal self.settings = settings @@ -79,7 +81,7 @@ final class LoopDataManager { ) { (note) -> Void in self.dataAccessQueue.async { self.carbEffect = nil - self.carbsOnBoardSeries = nil + self.carbsOnBoard = nil self.notify(forChange: .carbs) } } @@ -119,6 +121,11 @@ final class LoopDataManager { set { carbStore.carbRatioSchedule = newValue UserDefaults.standard.carbRatioSchedule = newValue + + // Invalidate cached effects based on this schedule + carbEffect = nil + carbsOnBoard = nil + notify(forChange: .preferences) } } @@ -160,12 +167,24 @@ final class LoopDataManager { UserDefaults.standard.insulinActionDuration = newValue + // Invalidate cached effects based on this schedule + insulinEffect = nil + if oldValue != newValue { AnalyticsManager.shared.didChangeInsulinActionDuration() } } } + /// A timeline of average velocity of glucose change counteracting predicted insulin effects + fileprivate var insulinCounteractionEffects: [GlucoseEffectVelocity] { + didSet { + UserDefaults.standard.insulinCounteractionEffects = insulinCounteractionEffects + carbEffect = nil + carbsOnBoard = nil + } + } + /// The daily schedule of insulin sensitivity (also known as ISF) /// This is measured in /Unit var insulinSensitivitySchedule: InsulinSensitivitySchedule? { @@ -178,6 +197,11 @@ final class LoopDataManager { UserDefaults.standard.insulinSensitivitySchedule = newValue + // Invalidate cached effects based on this schedule + carbEffect = nil + carbsOnBoard = nil + insulinEffect = nil + notify(forChange: .preferences) } } @@ -226,6 +250,7 @@ final class LoopDataManager { if success { self.dataAccessQueue.async { self.glucoseMomentumEffect = nil + self.lastGlucoseChange = nil self.retrospectiveGlucoseChange = nil self.notify(forChange: .glucose) } @@ -245,12 +270,12 @@ final class LoopDataManager { /// - carbEntry: The new carb value /// - completion: A closure called once upon completion /// - result: The bolus recommendation - func addCarbEntryAndRecommendBolus(_ carbEntry: CarbEntry, completion: @escaping (_ result: Result) -> Void) { - carbStore.addCarbEntry(carbEntry) { (success, _, error) in + func addCarbEntryAndRecommendBolus(_ carbEntry: CarbEntry, replacing replacingEntry: CarbEntry? = nil, completion: @escaping (_ result: Result) -> Void) { + let addCompletion: (Bool, CarbEntry?, CarbStore.CarbStoreError?) -> Void = { (success, _, error) in self.dataAccessQueue.async { if success { self.carbEffect = nil - self.carbsOnBoardSeries = nil + self.carbsOnBoard = nil defer { self.notify(forChange: .carbs) @@ -270,6 +295,12 @@ final class LoopDataManager { } } } + + if let replacingEntry = replacingEntry { + carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry, resultHandler: addCompletion) + } else { + carbStore.addCarbEntry(carbEntry, resultHandler: addCompletion) + } } /// Adds a bolus enacted by the pump, but not fully delivered. @@ -294,7 +325,6 @@ final class LoopDataManager { doseStore.addPumpEvents(events) { (error) in if error != nil { self.insulinEffect = nil - self.insulinOnBoard = nil } completion(error) @@ -317,7 +347,6 @@ final class LoopDataManager { completion(.failure(error)) } else if let newValue = newValue { self.insulinEffect = nil - self.insulinOnBoard = nil completion(.success(( newValue: newValue, @@ -395,7 +424,15 @@ final class LoopDataManager { let updateGroup = DispatchGroup() // Fetch glucose effects as far back as we want to make retroactive analysis - guard let lastGlucoseDate = glucoseStore.latestGlucose?.startDate else { + var latestGlucoseDate: Date? + updateGroup.enter() + glucoseStore.getCachedGlucoseValues(start: Date(timeIntervalSinceNow: -recencyInterval)) { (values) in + latestGlucoseDate = values.last?.startDate + updateGroup.leave() + } + _ = updateGroup.wait(timeout: .distantFuture) + + guard let lastGlucoseDate = latestGlucoseDate else { throw LoopError.missingDataError(details: "Glucose data not available", recovery: "Check your CGM data source") } @@ -409,6 +446,16 @@ final class LoopDataManager { } } + if lastGlucoseChange == nil { + updateGroup.enter() + let start = insulinCounteractionEffects.last?.endDate ?? lastGlucoseDate.addingTimeInterval(.minutes(-5.1)) + + glucoseStore.getGlucoseChange(start: start) { (change) in + self.lastGlucoseChange = change + updateGroup.leave() + } + } + if glucoseMomentumEffect == nil { updateGroup.enter() glucoseStore.getRecentMomentumEffect { (effects, error) -> Void in @@ -423,53 +470,59 @@ final class LoopDataManager { } } - if carbEffect == nil { + if insulinEffect == nil { updateGroup.enter() - carbStore.getGlucoseEffects(start: retrospectiveStart) { (result) -> Void in + doseStore.getGlucoseEffects(start: retrospectiveStart) { (result) -> Void in switch result { case .failure(let error): self.logger.error(error) - self.carbEffect = nil + self.insulinEffect = nil case .success(let effects): - self.carbEffect = effects + self.insulinEffect = effects } updateGroup.leave() } } - if carbsOnBoardSeries == nil { - updateGroup.enter() - carbStore.getCarbsOnBoardValues(start: retrospectiveStart) { (values) in - self.carbsOnBoardSeries = values - updateGroup.leave() + _ = updateGroup.wait(timeout: .distantFuture) + + if insulinCounteractionEffects.last == nil || + insulinCounteractionEffects.last!.endDate < lastGlucoseDate { + do { + try updateObservedInsulinCounteractionEffects() + } catch let error { + logger.error(error) } } - if insulinEffect == nil { + if carbEffect == nil { updateGroup.enter() - doseStore.getGlucoseEffects(start: retrospectiveStart) { (result) -> Void in + carbStore.getGlucoseEffects( + start: retrospectiveStart, + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) { (result) -> Void in switch result { case .failure(let error): self.logger.error(error) - self.insulinEffect = nil + self.carbEffect = nil case .success(let effects): - self.insulinEffect = effects + self.carbEffect = effects } updateGroup.leave() } } - if insulinOnBoard == nil { + if carbsOnBoard == nil { updateGroup.enter() - doseStore.insulinOnBoard(at: Date()) { (result) in + carbStore.carbsOnBoard(at: Date(), effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil) { (result) in switch result { - case .failure(let error): - self.logger.error(error) - self.insulinOnBoard = nil + case .failure: + // Failure is expected when there is no carb data + self.carbsOnBoard = nil case .success(let value): - self.insulinOnBoard = value + self.carbsOnBoard = value } updateGroup.leave() } @@ -598,11 +651,13 @@ final class LoopDataManager { } /// The change in glucose over the reflection time interval (default is 30 min) - private var retrospectiveGlucoseChange: GlucoseChange? { + fileprivate var retrospectiveGlucoseChange: GlucoseChange? { didSet { retrospectivePredictedGlucose = nil } } + /// The change in glucose over the last loop interval (5 min) + fileprivate var lastGlucoseChange: GlucoseChange? fileprivate var predictedGlucose: [GlucoseValue]? { didSet { @@ -616,8 +671,7 @@ final class LoopDataManager { } fileprivate var recommendedTempBasal: TempBasalRecommendation? - fileprivate var carbsOnBoardSeries: [CarbValue]? - fileprivate var insulinOnBoard: InsulinValue? + fileprivate var carbsOnBoard: CarbValue? fileprivate var lastTempBasal: DoseEntry? fileprivate var lastBolus: (units: Double, date: Date)? @@ -659,7 +713,7 @@ final class LoopDataManager { // Run a retrospective prediction over the duration of the recorded glucose change, using the current carb and insulin effects let startDate = change.start.startDate - let endDate = change.end.endDate.addingTimeInterval(TimeInterval(minutes: 5)) + let endDate = change.end.endDate let retrospectivePrediction = LoopMath.predictGlucose(change.start, effects: carbEffect.filterDateRange(startDate, endDate), insulinEffect.filterDateRange(startDate, endDate) @@ -679,6 +733,44 @@ final class LoopDataManager { self.retrospectiveGlucoseEffect = LoopMath.decayEffect(from: glucose, atRate: velocity, for: effectDuration) } + /// Measure the effects counteracting insulin observed in the CGM glucose. + /// + /// If you assume insulin is "right", this allows for some validation of carb algorithm settings. + /// + /// - Throws: LoopError.missingDataError if effect data isn't available + private func updateObservedInsulinCounteractionEffects() throws { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + guard + let insulinEffect = self.insulinEffect + else { + throw LoopError.missingDataError(details: "Cannot calculate insulin counteraction due to missing input data", recovery: nil) + } + + guard let change = lastGlucoseChange else { + return // Expected case for calibrations + } + + // Predict glucose change using only insulin effects over the last loop interval + let startDate = change.start.startDate + let endDate = change.end.endDate.addingTimeInterval(TimeInterval(minutes: 5)) + let prediction = LoopMath.predictGlucose(change.start, effects: + insulinEffect.filterDateRange(startDate, endDate) + ) + + // Compare that retrospective, insulin-driven prediction to the actual glucose change to + // calculate the effect of all insulin counteraction + guard let lastGlucose = prediction.last else { return } + let glucoseUnit = HKUnit.milligramsPerDeciliter() + let velocityUnit = glucoseUnit.unitDivided(by: HKUnit.second()) + let discrepancy = change.end.quantity.doubleValue(for: glucoseUnit) - lastGlucose.quantity.doubleValue(for: glucoseUnit) // mg/dL + let averageVelocity = HKQuantity(unit: velocityUnit, doubleValue: discrepancy / change.end.endDate.timeIntervalSince(change.start.endDate)) + let effect = GlucoseEffectVelocity(startDate: change.start.startDate, endDate: change.end.startDate, quantity: averageVelocity) + + insulinCounteractionEffects.append(effect) + // For now, only keep the last 24 hours of values + insulinCounteractionEffects = insulinCounteractionEffects.filterDateRange(Date(timeIntervalSinceNow: .hours(-24)), nil) + } /// Runs the glucose prediction on the latest effect data. /// @@ -832,9 +924,15 @@ final class LoopDataManager { /// Describes a view into the loop state protocol LoopState { + /// The last-calculated carbs on board + var carbsOnBoard: CarbValue? { get } + /// An error in the current state of the loop, or one that happened during the last attempt to loop. var error: Error? { get } + /// A timeline of average velocity of glucose change counteracting predicted insulin effects + var insulinCounteractionEffects: [GlucoseEffectVelocity] { get } + /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) var lastLoopCompleted: Date? { get } @@ -867,14 +965,6 @@ protocol LoopState { /// - LoopError.glucoseTooOld /// - LoopError.missingDataError func recommendBolus() throws -> BolusRecommendation - - // TODO: These values are duplicative and don't need to be cached in LoopDataManager - - /// Current carbs on board - var carbsOnBoard: CarbValue? { get } - - /// Current insulin on board - var insulinOnBoard: InsulinValue? { get } } @@ -888,11 +978,21 @@ extension LoopDataManager { self.updateError = updateError } + var carbsOnBoard: CarbValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.carbsOnBoard + } + var error: Error? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) return updateError ?? loopDataManager.lastLoopError } + var insulinCounteractionEffects: [GlucoseEffectVelocity] { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.insulinCounteractionEffects + } + var lastLoopCompleted: Date? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) return loopDataManager.lastLoopCompleted @@ -918,16 +1018,6 @@ extension LoopDataManager { return loopDataManager.retrospectivePredictedGlucose } - var carbsOnBoard: CarbValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.carbsOnBoardSeries?.closestPriorToDate(Date()) - } - - var insulinOnBoard: InsulinValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinOnBoard - } - func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { return try loopDataManager.predictGlucose(using: inputs) } @@ -968,33 +1058,56 @@ extension LoopDataManager { /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { getLoopState { (manager, state) in + var entries = [ "## LoopDataManager", "settings: \(String(reflecting: manager.settings))", + "insulinCounteractionEffects: \(String(reflecting: manager.insulinCounteractionEffects))", "predictedGlucose: \(state.predictedGlucose ?? [])", "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", "recommendedTempBasal: \(String(describing: state.recommendedTempBasal))", - "lastTempBasal: \(String(describing: state.lastTempBasal))", "lastBolus: \(String(describing: manager.lastBolus))", + "lastGlucoseChange: \(String(describing: manager.lastGlucoseChange))", + "retrospectiveGlucoseChange: \(String(describing: manager.retrospectiveGlucoseChange))", "lastLoopCompleted: \(String(describing: state.lastLoopCompleted))", - "insulinOnBoard: \(String(describing: state.insulinOnBoard))", - "carbsOnBoard: \(String(describing: state.carbsOnBoard))", - "error: \(String(describing: state.error))" + "lastTempBasal: \(String(describing: state.lastTempBasal))", + "carbsOnBoard: \(String(describing: state.carbsOnBoard))" ] + var loopError = state.error + + // TODO: this should be moved to doseStore.generateDiagnosticReport + self.doseStore.insulinOnBoard(at: Date()) { (result) in - self.glucoseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") + let insulinOnBoard: InsulinValue? + + switch result { + case .success(let value): + insulinOnBoard = value + case .failure(let error): + insulinOnBoard = nil + + if loopError == nil { + loopError = error + } + } + + entries.append("insulinOnBoard: \(String(describing: insulinOnBoard))") + entries.append("error: \(String(describing: loopError))") - self.carbStore.generateDiagnosticReport { (report) in + self.glucoseStore.generateDiagnosticReport { (report) in entries.append(report) entries.append("") - self.doseStore.generateDiagnosticReport { (report) in + self.carbStore.generateDiagnosticReport { (report) in entries.append(report) entries.append("") - completion(entries.joined(separator: "\n")) + self.doseStore.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + completion(entries.joined(separator: "\n")) + } } } } diff --git a/Loop/Managers/NightscoutDataManager.swift b/Loop/Managers/NightscoutDataManager.swift index ebc0e5e9d3..db3714ae0a 100644 --- a/Loop/Managers/NightscoutDataManager.swift +++ b/Loop/Managers/NightscoutDataManager.swift @@ -37,29 +37,49 @@ final class NightscoutDataManager { return } - deviceDataManager.loopManager.getLoopState { (_, state) in + deviceDataManager.loopManager.getLoopState { (manager, state) in var loopError = state.error - let recommendation: Double? + let recommendedBolus: Double? do { - recommendation = try state.recommendBolus().amount + recommendedBolus = try state.recommendBolus().amount } catch let error { - recommendation = nil + recommendedBolus = nil if loopError == nil { loopError = error } } - self.uploadLoopStatus( - insulinOnBoard: state.insulinOnBoard, - carbsOnBoard: state.carbsOnBoard, - predictedGlucose: state.predictedGlucose, - recommendedTempBasal: state.recommendedTempBasal, - recommendedBolus: recommendation, - lastTempBasal: state.lastTempBasal, - loopError: loopError - ) + let carbsOnBoard = state.carbsOnBoard + let predictedGlucose = state.predictedGlucose + let recommendedTempBasal = state.recommendedTempBasal + let lastTempBasal = state.lastTempBasal + + manager.doseStore.insulinOnBoard(at: Date()) { (result) in + let insulinOnBoard: InsulinValue? + + switch result { + case .success(let value): + insulinOnBoard = value + case .failure(let error): + insulinOnBoard = nil + + if loopError == nil { + loopError = error + } + } + + self.uploadLoopStatus( + insulinOnBoard: insulinOnBoard, + carbsOnBoard: carbsOnBoard, + predictedGlucose: predictedGlucose, + recommendedTempBasal: recommendedTempBasal, + recommendedBolus: recommendedBolus, + lastTempBasal: lastTempBasal, + loopError: loopError + ) + } } } diff --git a/Loop/Managers/StatusChartsManager+LoopKit.swift b/Loop/Managers/StatusChartsManager+LoopKit.swift index 314142a2b8..ecdc49b387 100644 --- a/Loop/Managers/StatusChartsManager+LoopKit.swift +++ b/Loop/Managers/StatusChartsManager+LoopKit.swift @@ -145,4 +145,82 @@ extension StatusChartsManager { ) } } + + /// Convert an array of GlucoseEffects (as glucose values) into glucose effect velocity (glucose/min) for charting + /// + /// - Parameter effects: A timeline of glucose values representing glucose change + func setCarbEffects(_ effects: [GlucoseEffect]) { + let dateFormatter = self.dateFormatter + let decimalFormatter = self.doseFormatter + let unit = glucoseUnit.unitDivided(by: .minute()) + let unitString = unit.unitString + + var lastDate = effects.first?.endDate + var lastValue = effects.first?.quantity.doubleValue(for: glucoseUnit) + let minuteInterval = 5.0 + + var carbEffectPoints = [ChartPoint]() + + let zero = ChartAxisValueInt(0) + + for effect in effects.dropFirst() { + let value = effect.quantity.doubleValue(for: glucoseUnit) + let valuePerMinute = (value - lastValue!) / minuteInterval + lastValue = value + + let startX = ChartAxisValueDate(date: lastDate!, formatter: dateFormatter) + let endX = ChartAxisValueDate(date: effect.endDate, formatter: dateFormatter) + lastDate = effect.endDate + + let valueY = ChartAxisValueDoubleUnit(valuePerMinute, unitString: unitString, formatter: decimalFormatter) + + carbEffectPoints += [ + ChartPoint(x: startX, y: zero), + ChartPoint(x: startX, y: valueY), + ChartPoint(x: endX, y: valueY), + ChartPoint(x: endX, y: zero) + ] + } + + self.carbEffectPoints = carbEffectPoints + } + + /// Charts glucose effect velocity + /// + /// - Parameter effects: A timeline of glucose velocity values + func setInsulinCounteractionEffects(_ effects: [GlucoseEffectVelocity]) { + let dateFormatter = self.dateFormatter + let decimalFormatter = self.doseFormatter + let unit = glucoseUnit.unitDivided(by: .minute()) + let unitString = unit.unitString + + var insulinCounteractionEffectPoints: [ChartPoint] = [] + var allCarbEffectPoints: [ChartPoint] = [] + + let zero = ChartAxisValueInt(0) + + for effect in effects { + let startX = ChartAxisValueDate(date: effect.startDate, formatter: dateFormatter) + let endX = ChartAxisValueDate(date: effect.endDate, formatter: dateFormatter) + let value = ChartAxisValueDoubleUnit(effect.quantity.doubleValue(for: unit), unitString: unitString, formatter: decimalFormatter) + + guard value.scalar != 0 else { + continue + } + + let valuePoint = ChartPoint(x: endX, y: value) + + insulinCounteractionEffectPoints += [ + ChartPoint(x: startX, y: zero), + ChartPoint(x: startX, y: value), + valuePoint, + ChartPoint(x: endX, y: zero) + ] + + allCarbEffectPoints.append(valuePoint) + } + + self.insulinCounteractionEffectPoints = insulinCounteractionEffectPoints + self.allCarbEffectPoints = allCarbEffectPoints + } } diff --git a/Loop/Models/GlucoseEffectVelocity.swift b/Loop/Models/GlucoseEffectVelocity.swift new file mode 100644 index 0000000000..35b7ba4b0f --- /dev/null +++ b/Loop/Models/GlucoseEffectVelocity.swift @@ -0,0 +1,38 @@ +// +// GlucoseEffectVelocity.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit + + +extension GlucoseEffectVelocity: RawRepresentable { + public typealias RawValue = [String: Any] + + static let unit = HKUnit.milligramsPerDeciliter().unitDivided(by: .minute()) + + public init?(rawValue: RawValue) { + guard let startDate = rawValue["startDate"] as? Date, + let doubleValue = rawValue["doubleValue"] as? Double + else { + return nil + } + + self.init( + startDate: startDate, + endDate: rawValue["endDate"] as? Date ?? startDate, + quantity: HKQuantity(unit: type(of: self).unit, doubleValue: doubleValue) + ) + } + + public var rawValue: RawValue { + return [ + "startDate": startDate, + "endDate": endDate, + "doubleValue": quantity.doubleValue(for: type(of: self).unit) + ] + } +} diff --git a/Loop/Models/LoopSettings.swift b/Loop/Models/LoopSettings.swift index be8490fe51..002942d01c 100644 --- a/Loop/Models/LoopSettings.swift +++ b/Loop/Models/LoopSettings.swift @@ -11,6 +11,8 @@ import LoopKit struct LoopSettings { var dosingEnabled = false + let dynamicCarbAbsorptionEnabled = true + var glucoseTargetRangeSchedule: GlucoseRangeSchedule? var maximumBasalRatePerHour: Double? diff --git a/Loop/View Controllers/BolusViewController+LoopDataManager.swift b/Loop/View Controllers/BolusViewController+LoopDataManager.swift new file mode 100644 index 0000000000..280d333530 --- /dev/null +++ b/Loop/View Controllers/BolusViewController+LoopDataManager.swift @@ -0,0 +1,49 @@ +// +// BolusViewController+LoopDataManager.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit + + +extension BolusViewController { + func configureWithLoopManager(_ manager: LoopDataManager, recommendation: BolusRecommendation?, glucoseUnit: HKUnit) { + manager.getLoopState { (manager, state) in + let maximumBolus = manager.settings.maximumBolus + + let activeCarbohydrates = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) + let bolusRecommendation: BolusRecommendation? + + if let recommendation = recommendation { + bolusRecommendation = recommendation + } else { + bolusRecommendation = try? state.recommendBolus() + } + + manager.doseStore.insulinOnBoard(at: Date()) { (result) in + let activeInsulin: Double? + + switch result { + case .success(let value): + activeInsulin = value.value + case .failure: + activeInsulin = nil + } + + DispatchQueue.main.async { + if let maxBolus = maximumBolus { + self.maxBolus = maxBolus + } + + self.glucoseUnit = glucoseUnit + self.activeInsulin = activeInsulin + self.activeCarbohydrates = activeCarbohydrates + self.bolusRecommendation = bolusRecommendation + } + } + } + } +} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift new file mode 100644 index 0000000000..b8765d4883 --- /dev/null +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -0,0 +1,538 @@ +// +// CarbAbsorptionViewController.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit + +import CarbKit +import LoopKit +import LoopUI + + +private extension RefreshContext { + static let all: RefreshContext = [.glucose, .carbs, .targets, .status] +} + + +final class CarbAbsorptionViewController: ChartsTableViewController, IdentifiableClass { + + override func viewDidLoad() { + super.viewDidLoad() + + charts.glucoseDisplayRange = ( + min: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 100), + max: HKQuantity(unit: HKUnit.milligramsPerDeciliter(), doubleValue: 175) + ) + + let notificationCenter = NotificationCenter.default + + notificationObservers += [ + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [unowned self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + DispatchQueue.main.async { + switch LoopDataManager.LoopUpdateContext(rawValue: context) { + case .preferences?: + self.refreshContext.update(with: .targets) + case .carbs?: + self.refreshContext.update(with: [.carbs, .glucose]) + case .glucose?: + self.refreshContext.update(with: .glucose) + default: + break + } + + self.refreshContext.update(with: .status) + self.reloadData(animated: true) + } + } + ] + + if let gestureRecognizer = charts.gestureRecognizer { + tableView.addGestureRecognizer(gestureRecognizer) + } + + navigationItem.rightBarButtonItems?.append(editButtonItem) + + reloadData(animated: false) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if !visible { + refreshContext = .all + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + refreshContext.update(with: .status) + + super.viewWillTransition(to: size, with: coordinator) + } + + // MARK: - State + + private var refreshContext: RefreshContext = .all + + private var chartStartDate: Date { + get { + return charts.startDate + } + set { + if newValue != chartStartDate { + refreshContext = .all + } + + charts.startDate = newValue + } + } + + private var carbStatuses: [CarbStatus] = [] + + private var carbsOnBoard: CarbValue? + + private var carbTotal: CarbValue? + + // MARK: - Data loading + + override func reloadData(animated: Bool, to size: CGSize? = nil) { + guard active && !self.refreshContext.isEmpty else { return } + + // How far back should we show data? Use the screen size as a guide. + let minimumSegmentWidth: CGFloat = 75 + let availableWidth = (size ?? self.tableView.bounds.size).width - self.charts.fixedHorizontalMargin + let totalHours = floor(Double(availableWidth / minimumSegmentWidth)) + + var components = DateComponents() + components.minute = 0 + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: max(1, totalHours))) + chartStartDate = Calendar.current.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date + + let midnight = Calendar.current.startOfDay(for: Date()) + let listStart = min(midnight, chartStartDate) + + let reloadGroup = DispatchGroup() + let shouldUpdateGlucose = self.refreshContext.remove(.glucose) != nil + let shouldUpdateCarbs = self.refreshContext.remove(.carbs) != nil + + var refreshContext = self.refreshContext + var carbEffects: [GlucoseEffect]? + var carbStatuses: [CarbStatus]? + var carbsOnBoard: CarbValue? + var carbTotal: CarbValue? + + reloadGroup.enter() + deviceManager.loopManager.glucoseStore.preferredUnit { (unit, error) in + if let unit = unit { + self.charts.glucoseUnit = unit + } + + _ = refreshContext.remove(.status) + reloadGroup.enter() + self.deviceManager.loopManager.getLoopState { (manager, state) in + if shouldUpdateGlucose || shouldUpdateCarbs { + let insulinCounteractionEffects = state.insulinCounteractionEffects + self.charts.setInsulinCounteractionEffects(state.insulinCounteractionEffects.filterDateRange(self.chartStartDate, nil)) + + reloadGroup.enter() + manager.carbStore.getCarbStatus(start: listStart, effectVelocities: manager.settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil) { (result) in + switch result { + case .success(let status): + carbStatuses = status + case .failure(let error): + self.deviceManager.logger.addError(error, fromSource: "CarbStore") + refreshContext.update(with: .carbs) + } + + reloadGroup.leave() + } + + reloadGroup.enter() + manager.carbStore.getGlucoseEffects(start: self.chartStartDate, effectVelocities: manager.settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil) { (result) in + switch result { + case .success(let effects): + carbEffects = effects + case .failure(let error): + carbEffects = [] + self.deviceManager.logger.addError(error, fromSource: "CarbStore") + refreshContext.update(with: .carbs) + } + reloadGroup.leave() + } + } + + carbsOnBoard = state.carbsOnBoard + + if refreshContext.remove(.targets) != nil { + if let schedule = manager.settings.glucoseTargetRangeSchedule { + self.charts.targetPointsCalculator = GlucoseRangeScheduleCalculator(schedule) + } else { + self.charts.targetPointsCalculator = nil + } + } + + reloadGroup.leave() + } + + if shouldUpdateCarbs { + reloadGroup.enter() + self.deviceManager.loopManager.carbStore.getTotalCarbs(since: midnight) { (result) in + switch result { + case .success(let total): + carbTotal = total + case .failure(let error): + self.deviceManager.logger.addError(error, fromSource: "CarbStore") + refreshContext.update(with: .carbs) + } + + reloadGroup.leave() + } + } + + reloadGroup.leave() + } + + reloadGroup.notify(queue: .main) { + self.refreshContext = refreshContext + if let carbEffects = carbEffects { + self.charts.setCarbEffects(carbEffects) + } + + self.charts.prerender() + + for case let cell as ChartTableViewCell in self.tableView.visibleCells { + cell.reloadChart() + } + + if shouldUpdateCarbs || shouldUpdateGlucose { + // Change to descending order for display + self.carbStatuses = carbStatuses?.reversed() ?? [] + + if shouldUpdateCarbs { + self.carbTotal = carbTotal + } + + self.tableView.reloadSections(IndexSet(integer: Section.entries.rawValue), with: .fade) + } + + self.carbsOnBoard = carbsOnBoard + + if let cell = self.tableView.cellForRow(at: IndexPath(row: 0, section: Section.totals.rawValue)) as? HeaderValuesTableViewCell { + self.updateCell(cell) + } + } + } + + // MARK: - UITableViewDataSource + + private enum Section: Int { + case charts + case totals + case entries + + static let count = 3 + } + + private enum ChartRow: Int { + case carbEffect + + static let count = 1 + } + + private lazy var carbFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + return formatter + }() + + private lazy var absorptionFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.collapsesLargestUnit = true + formatter.unitsStyle = .abbreviated + formatter.allowsFractionalUnits = true + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + override func numberOfSections(in tableView: UITableView) -> Int { + return Section.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section(rawValue: section)! { + case .charts: + return ChartRow.count + case .totals: + return 1 + case .entries: + return carbStatuses.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Section(rawValue: indexPath.section)! { + case .charts: + let cell = tableView.dequeueReusableCell(withIdentifier: ChartTableViewCell.className, for: indexPath) as! ChartTableViewCell + + switch ChartRow(rawValue: indexPath.row)! { + case .carbEffect: + cell.chartContentView.chartGenerator = { [unowned self] (frame) in + return self.charts.carbEffectChartWithFrame(frame)?.view + } + } + + let alpha: CGFloat = charts.gestureRecognizer?.state == .possible ? 1 : 0 + cell.titleLabel?.alpha = alpha + cell.subtitleLabel?.alpha = alpha + + cell.subtitleLabel?.textColor = UIColor.secondaryLabelColor + + return cell + case .totals: + let cell = tableView.dequeueReusableCell(withIdentifier: HeaderValuesTableViewCell.className, for: indexPath) as! HeaderValuesTableViewCell + updateCell(cell) + + return cell + case .entries: + let unit = HKUnit.gram() + let cell = tableView.dequeueReusableCell(withIdentifier: CarbEntryTableViewCell.className, for: indexPath) as! CarbEntryTableViewCell + + // Entry value + let status = carbStatuses[indexPath.row] + let carbText = carbFormatter.string(from: status.entry.quantity.doubleValue(for: unit), unit: unit.unitString) + + if let carbText = carbText, let foodType = status.entry.foodType { + cell.valueLabel?.text = String( + format: NSLocalizedString("%1$@: %2$@", comment: "Formats (1: carb value) and (2: food type)"), + carbText, foodType + ) + } else { + cell.valueLabel?.text = carbText + } + + // Entry time + let startTime = timeFormatter.string(from: status.entry.startDate) + if let absorptionTime = status.entry.absorptionTime, + let duration = absorptionFormatter.string(from: absorptionTime) + { + cell.dateLabel?.text = String( + format: NSLocalizedString("%1$@ + %2$@", comment: "Formats (1: carb start time) and (2: carb absorption duration)"), + startTime, duration + ) + } else { + cell.dateLabel?.text = startTime + } + + if let absorption = status.absorption { + // Absorbed value + let observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) + let observedCarbs = max(0, absorption.observed.doubleValue(for: unit)) + + if let observedCarbsText = carbFormatter.string(from: observedCarbs, unit: unit.unitString) { + cell.observedValueText = String( + format: NSLocalizedString("%@ absorbed", comment: "Formats absorbed carb value"), + observedCarbsText + ) + + if absorption.isActive { + cell.observedValueTextColor = UIColor.COBTintColor + } else if 0.9 <= observedProgress && observedProgress <= 1.1 { + cell.observedValueTextColor = UIColor.HIGGrayColor() + } else { + cell.observedValueTextColor = UIColor.agingColor + } + } + + cell.observedProgress = observedProgress + cell.clampedProgress = Float(absorption.clampedProgress.doubleValue(for: .percent())) + cell.observedDateText = absorptionFormatter.string(from: absorption.estimatedDate.duration) + + // Absorbed time + if absorption.isActive { + cell.observedDateTextColor = UIColor.COBTintColor + } else { + cell.observedDateTextColor = UIColor.HIGGrayColor() + + if let absorptionTime = status.entry.absorptionTime { + let durationProgress = absorption.estimatedDate.duration / absorptionTime + if 0.9 > durationProgress || durationProgress > 1.1 { + cell.observedDateTextColor = UIColor.agingColor + } + } + } + } + + cell.isUploading = !status.entry.isUploaded && (deviceManager.loopManager.carbStore.syncDelegate != nil) + return cell + } + } + + private func updateCell(_ cell: HeaderValuesTableViewCell) { + let unit = HKUnit.gram() + + if let carbsOnBoard = carbsOnBoard { + cell.COBDateLabel.text = String( + format: NSLocalizedString("at %@", comment: "Format fragment for a specific time"), + timeFormatter.string(from: carbsOnBoard.startDate) + ) + cell.COBValueLabel.text = carbFormatter.string(from: NSNumber(value: carbsOnBoard.quantity.doubleValue(for: unit))) + } else { + cell.COBDateLabel.text = nil + cell.COBValueLabel.text = carbFormatter.string(from: NSNumber(value: 0)) + } + + if let carbTotal = carbTotal { + cell.totalDateLabel.text = String( + format: NSLocalizedString("since %@", comment: "Format fragment for a start time"), + timeFormatter.string(from: carbTotal.startDate) + ) + cell.totalValueLabel.text = carbFormatter.string(from: NSNumber(value: carbTotal.quantity.doubleValue(for: unit))) + } else { + cell.totalDateLabel.text = nil + cell.totalValueLabel.text = carbFormatter.string(from: NSNumber(value: 0)) + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + switch Section(rawValue: indexPath.section)! { + case .charts, .totals: + return false + case .entries: + return carbStatuses[indexPath.row].entry.createdByCurrentApp + } + } + + public override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + let status = carbStatuses.remove(at: indexPath.row) + tableView.deleteRows(at: [indexPath], with: .automatic) + + deviceManager.loopManager.carbStore.deleteCarbEntry(status.entry) { (success, error) -> Void in + DispatchQueue.main.async { + if success { + // TODO: CarbStore doesn't automatically post this for deletes + NotificationCenter.default.post(name: .CarbEntriesDidUpdate, object: self) + } else if let error = error { + self.refreshContext.update(with: .carbs) + self.presentAlertController(with: error) + } + } + } + } + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + switch Section(rawValue: indexPath.section)! { + case .charts, .totals: + return self.tableView(tableView, heightForRowAt: indexPath) + case .entries: + return 66 + } + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + switch Section(rawValue: indexPath.section)! { + case .charts: + // 20: Status bar + // 44: Toolbar + let availableSize = max(tableView.bounds.width, tableView.bounds.height) - 20 - (tableView.tableHeaderView?.frame.height ?? 0) - 44 + + switch ChartRow(rawValue: indexPath.row)! { + case .carbEffect: + return max(100, 0.40 * availableSize) + } + case .totals, .entries: + return UITableViewAutomaticDimension + } + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + switch Section(rawValue: indexPath.section)! { + case .charts: + return indexPath + case .totals: + return nil + case .entries: + return carbStatuses[indexPath.row].entry.createdByCurrentApp ? indexPath : nil + } + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + var targetViewController = segue.destination + + if let navVC = targetViewController as? UINavigationController, let topViewController = navVC.topViewController { + targetViewController = topViewController + } + + switch targetViewController { + case let vc as BolusViewController: + vc.configureWithLoopManager(self.deviceManager.loopManager, + recommendation: sender as? BolusRecommendation, + glucoseUnit: self.charts.glucoseUnit + ) + case let vc as CarbEntryEditViewController: + if let selectedCell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: selectedCell), indexPath.row < carbStatuses.count { + vc.originalCarbEntry = carbStatuses[indexPath.row].entry + } + + vc.defaultAbsorptionTimes = deviceManager.loopManager.carbStore.defaultAbsorptionTimes + vc.preferredUnit = deviceManager.loopManager.carbStore.preferredUnit + default: + break + } + } + + /// Unwind segue action from the CarbEntryEditViewController + /// + /// - parameter segue: The unwind segue + @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { + guard let editVC = segue.source as? CarbEntryEditViewController, + let updatedEntry = editVC.updatedCarbEntry + else { + return + } + + deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry, replacing: editVC.originalCarbEntry) { (result) in + DispatchQueue.main.async { + switch result { + case .success(let recommendation): + if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { + self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) + } + case .failure(let error): + // Ignore bolus wizard errors + if error is CarbStore.CarbStoreError { + self.presentAlertController(with: error) + } + } + } + } + } + + @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { + if let bolusViewController = segue.source as? BolusViewController { + if let bolus = bolusViewController.bolus, bolus > 0 { + deviceManager.enactBolus(units: bolus) { (_) in + } + } + } + } + +} diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index 93d6d4a849..630c64c1d1 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -483,25 +483,15 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu scheduleVC.delegate = self scheduleVC.title = NSLocalizedString("Carb Ratios", comment: "The title of the carb ratios schedule screen") + scheduleVC.unit = .gram() if let schedule = dataManager.loopManager.carbRatioSchedule { scheduleVC.timeZone = schedule.timeZone scheduleVC.scheduleItems = schedule.items scheduleVC.unit = schedule.unit - - show(scheduleVC, sender: sender) - } else { - dataManager.loopManager.carbStore.preferredUnit { (unit, error) -> Void in - DispatchQueue.main.async { - if let error = error { - self.presentAlertController(with: error) - } else if let unit = unit { - scheduleVC.unit = unit - self.show(scheduleVC, sender: sender) - } - } - } } + + show(scheduleVC, sender: sender) case .insulinSensitivity: let scheduleVC = DailyQuantityScheduleTableViewController() diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index fac86e04aa..682966ee95 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -55,7 +55,7 @@ final class StatusTableViewController: ChartsTableViewController { case .carbs?: self.refreshContext.update(with: .carbs) case .glucose?: - self.refreshContext.update(with: .glucose) + self.refreshContext.update(with: [.glucose, .carbs]) case .tempBasal?: self.refreshContext.update(with: .insulin) } @@ -162,8 +162,9 @@ final class StatusTableViewController: ChartsTableViewController { chartStartDate = Calendar.current.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date let reloadGroup = DispatchGroup() - var newLastLoopCompleted: Date? - var newLastTempBasal: DoseEntry? + var lastLoopCompleted: Date? + var lastReservoirValue: ReservoirValue? + var lastTempBasal: DoseEntry? var newRecommendedTempBasal: LoopDataManager.TempBasalRecommendation? reloadGroup.enter() @@ -206,8 +207,8 @@ final class StatusTableViewController: ChartsTableViewController { newRecommendedTempBasal = state.recommendedTempBasal } - newLastTempBasal = state.lastTempBasal - newLastLoopCompleted = state.lastLoopCompleted + lastTempBasal = state.lastTempBasal + lastLoopCompleted = state.lastLoopCompleted if let lastPoint = self.charts.predictedGlucosePoints.last?.y { self.eventualGlucoseDescription = String(describing: lastPoint) @@ -223,6 +224,14 @@ final class StatusTableViewController: ChartsTableViewController { } } + if self.refreshContext.remove(.carbs) != nil { + reloadGroup.enter() + manager.carbStore.getCarbsOnBoardValues(start: self.chartStartDate, effectVelocities: manager.settings.dynamicCarbAbsorptionEnabled ? state.insulinCounteractionEffects : nil) { (values) in + self.charts.setCOBValues(values) + reloadGroup.leave() + } + } + reloadGroup.leave() } @@ -268,22 +277,18 @@ final class StatusTableViewController: ChartsTableViewController { reloadGroup.leave() } - } - if refreshContext.remove(.carbs) != nil { reloadGroup.enter() - deviceManager.loopManager.carbStore.getCarbsOnBoardValues(start: chartStartDate) { (values) in - self.charts.setCOBValues(values) - reloadGroup.leave() - } - } + deviceManager.loopManager.doseStore.getReservoirValues(since: Date(timeIntervalSinceNow: .minutes(-30))) { (result) in + switch result { + case .success(let values): + lastReservoirValue = values.first + case .failure: + self.refreshContext.update(with: .insulin) + } - if let reservoir = deviceManager.loopManager.doseStore.lastReservoirValue { - if let capacity = deviceManager.pumpState?.pumpModel?.reservoirCapacity { - hudView.reservoirVolumeHUD.reservoirLevel = min(1, max(0, Double(reservoir.unitVolume / Double(capacity)))) + reloadGroup.leave() } - - hudView.reservoirVolumeHUD.setReservoirVolume(volume: reservoir.unitVolume, at: reservoir.startDate) } if let level = deviceManager.pumpBatteryChargeRemaining { @@ -303,16 +308,24 @@ final class StatusTableViewController: ChartsTableViewController { ) } + if let reservoir = lastReservoirValue { + if let capacity = self.deviceManager.pumpState?.pumpModel?.reservoirCapacity { + self.hudView.reservoirVolumeHUD.reservoirLevel = min(1, max(0, Double(reservoir.unitVolume / Double(capacity)))) + } + + self.hudView.reservoirVolumeHUD.setReservoirVolume(volume: reservoir.unitVolume, at: reservoir.startDate) + } + // Loop completion HUD - self.hudView.loopCompletionHUD.lastLoopCompleted = newLastLoopCompleted + self.hudView.loopCompletionHUD.lastLoopCompleted = lastLoopCompleted // Net basal rate HUD - let date = newLastTempBasal?.startDate ?? Date() + let date = lastTempBasal?.startDate ?? Date() if let scheduledBasal = self.deviceManager.loopManager.basalRateSchedule?.between(start: date, end: date).first { let netBasal = NetBasal( - lastTempBasal: newLastTempBasal, + lastTempBasal: lastTempBasal, maxBasal: self.deviceManager.loopManager.settings.maximumBasalRatePerHour, scheduledBasal: scheduledBasal ) @@ -582,7 +595,7 @@ final class StatusTableViewController: ChartsTableViewController { case .iob, .dose: performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) case .cob: - performSegue(withIdentifier: CarbEntryTableViewController.className, sender: indexPath) + performSegue(withIdentifier: CarbAbsorptionViewController.className, sender: indexPath) } case .status: switch StatusRow(rawValue: indexPath.row)! { @@ -612,15 +625,18 @@ final class StatusTableViewController: ChartsTableViewController { // MARK: - Actions override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { - if identifier == CarbEntryEditViewController.className { + switch identifier { + case CarbEntryEditViewController.className, CarbAbsorptionViewController.className: if deviceManager.loopManager.carbStore.authorizationRequired { deviceManager.loopManager.carbStore.authorize { (success, error) in if success { - self.performSegue(withIdentifier: CarbEntryEditViewController.className, sender: sender) + self.performSegue(withIdentifier: identifier, sender: sender) } } return false } + default: + break } return true @@ -636,6 +652,9 @@ final class StatusTableViewController: ChartsTableViewController { } switch targetViewController { + case let vc as CarbAbsorptionViewController: + vc.deviceManager = deviceManager + vc.hidesBottomBarWhenPushed = true case let vc as CarbEntryTableViewController: vc.carbStore = deviceManager.loopManager.carbStore vc.hidesBottomBarWhenPushed = true @@ -646,35 +665,10 @@ final class StatusTableViewController: ChartsTableViewController { vc.doseStore = deviceManager.loopManager.doseStore vc.hidesBottomBarWhenPushed = true case let vc as BolusViewController: - self.deviceManager.loopManager.getLoopState { (manager, state) in - let maximumBolus = manager.settings.maximumBolus - - let activeInsulin = state.insulinOnBoard?.value - let activeCarbohydrates = state.carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) - let bolusRecommendation: BolusRecommendation? - - if let recommendation = sender as? BolusRecommendation { - bolusRecommendation = recommendation - } else { - do { - bolusRecommendation = try state.recommendBolus() - } catch let error { - bolusRecommendation = nil - self.deviceManager.logger.addError(error, fromSource: "Bolus") - } - } - - DispatchQueue.main.async { - if let maxBolus = maximumBolus { - vc.maxBolus = maxBolus - } - - vc.glucoseUnit = self.charts.glucoseUnit - vc.activeInsulin = activeInsulin - vc.activeCarbohydrates = activeCarbohydrates - vc.bolusRecommendation = bolusRecommendation - } - } + vc.configureWithLoopManager(self.deviceManager.loopManager, + recommendation: sender as? BolusRecommendation, + glucoseUnit: self.charts.glucoseUnit + ) case let vc as PredictionTableViewController: vc.deviceManager = deviceManager case let vc as SettingsTableViewController: @@ -688,22 +682,24 @@ final class StatusTableViewController: ChartsTableViewController { /// /// - parameter segue: The unwind segue @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { - if let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry { - deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .success(let recommendation): - if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { - self.bolusState = .recommended - self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) - } - case .failure(let error): - // Ignore bolus wizard errors - if error is CarbStore.CarbStoreError { - self.presentAlertController(with: error) - } else { - self.deviceManager.logger.addError(error, fromSource: "Bolus") - } + guard let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry else { + return + } + + deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (result) -> Void in + DispatchQueue.main.async { + switch result { + case .success(let recommendation): + if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { + self.bolusState = .recommended + self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) + } + case .failure(let error): + // Ignore bolus wizard errors + if error is CarbStore.CarbStoreError { + self.presentAlertController(with: error) + } else { + self.deviceManager.logger.addError(error, fromSource: "Bolus") } } } diff --git a/Loop/Views/CarbEntryTableViewCell.swift b/Loop/Views/CarbEntryTableViewCell.swift new file mode 100644 index 0000000000..86112e212c --- /dev/null +++ b/Loop/Views/CarbEntryTableViewCell.swift @@ -0,0 +1,123 @@ +// +// CarbEntryTableViewCell.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class CarbEntryTableViewCell: UITableViewCell { + + @IBOutlet private weak var clampedProgressView: UIProgressView! + + @IBOutlet private weak var observedProgressView: UIProgressView! + + @IBOutlet weak var valueLabel: UILabel! + + @IBOutlet weak var dateLabel: UILabel! + + @IBOutlet private weak var observedValueLabel: UILabel! + + @IBOutlet private weak var observedDateLabel: UILabel! + + @IBOutlet private weak var uploadingIndicator: UIImageView! + + var clampedProgress: Float { + get { + return clampedProgressView.progress + } + set { + clampedProgressView.progress = newValue + clampedProgressView.isHidden = clampedProgress <= 0 + } + } + + var observedProgress: Float { + get { + return observedProgressView.progress + } + set { + observedProgressView.progress = newValue + observedProgressView.isHidden = observedProgress <= 0 + } + } + + var observedValueText: String? { + get { + return observedValueLabel.text + } + set { + observedValueLabel.text = newValue + if newValue != nil { + observedValueLabel.superview?.isHidden = false + } + } + } + + var observedDateText: String? { + get { + return observedDateLabel.text + } + set { + observedDateLabel.text = newValue + if newValue != nil { + observedDateLabel.superview?.isHidden = false + } + } + } + + var observedValueTextColor: UIColor { + get { + return observedValueLabel.textColor + } + set { + observedValueLabel.textColor = newValue + } + } + + var observedDateTextColor: UIColor { + get { + return observedDateLabel.textColor + } + set { + observedDateLabel.textColor = newValue + } + } + + var isUploading = false { + didSet { + uploadingIndicator.isHidden = !isUploading + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layoutMargins.left = separatorInset.left + contentView.layoutMargins.right = separatorInset.left + } + + override func awakeFromNib() { + super.awakeFromNib() + + resetViews() + } + + override func prepareForReuse() { + super.prepareForReuse() + + resetViews() + } + + private func resetViews() { + observedProgress = 0 + clampedProgress = 0 + valueLabel.text = nil + dateLabel.text = nil + observedValueText = nil + observedDateText = nil + observedValueLabel.superview?.isHidden = true + uploadingIndicator.isHidden = true + } +} diff --git a/Loop/Views/CircleMaskView.swift b/Loop/Views/CircleMaskView.swift new file mode 100644 index 0000000000..3b4c8898ef --- /dev/null +++ b/Loop/Views/CircleMaskView.swift @@ -0,0 +1,18 @@ +// +// CircleMaskView.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class CircleMaskView: UIView { + + override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.frame.height / 2 + } + +} diff --git a/Loop/Views/HeaderValuesTableViewCell.swift b/Loop/Views/HeaderValuesTableViewCell.swift new file mode 100644 index 0000000000..1b4c974dce --- /dev/null +++ b/Loop/Views/HeaderValuesTableViewCell.swift @@ -0,0 +1,19 @@ +// +// HeaderValuesTableViewCell.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class HeaderValuesTableViewCell: UITableViewCell { + + @IBOutlet weak var COBValueLabel: UILabel! + + @IBOutlet weak var COBDateLabel: UILabel! + + @IBOutlet weak var totalValueLabel: UILabel! + + @IBOutlet weak var totalDateLabel: UILabel! +} diff --git a/LoopUI/Managers/StatusChartsManager.swift b/LoopUI/Managers/StatusChartsManager.swift index 3f2325b9c6..d05e43306c 100644 --- a/LoopUI/Managers/StatusChartsManager.swift +++ b/LoopUI/Managers/StatusChartsManager.swift @@ -68,11 +68,15 @@ public final class StatusChartsManager { basalDosePoints = [] bolusDosePoints = [] allDosePoints = [] + carbEffectPoints = [] + insulinCounteractionEffectPoints = [] + allCarbEffectPoints = [] glucoseChartCache = nil iobChartCache = nil cobChartCache = nil doseChartCache = nil + carbEffectChartCache = nil } // MARK: - Data @@ -83,7 +87,8 @@ public final class StatusChartsManager { if startDate != oldValue { xAxisValues = nil - updateEndDate(startDate.addingTimeInterval(TimeInterval(hours: 4))) + // Set a new minimum end date + endDate = startDate.addingTimeInterval(.hours(3)) } } } @@ -643,6 +648,147 @@ public final class StatusChartsManager { return Chart(frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.flatMap { $0 }) } + // MARK: - Carb Effect + + /// The chart points for expected carb effect velocity + public var carbEffectPoints: [ChartPoint] = [] { + didSet { + carbEffectChart = nil + // don't extend the end date for carb effects + } + } + + /// The chart points for observed insulin counteraction effect velocity + public var insulinCounteractionEffectPoints: [ChartPoint] = [] { + didSet { + carbEffectChart = nil + + // Extend 1 hour past the seen effect to ensure some future prediction is displayed + if let lastDate = insulinCounteractionEffectPoints.last?.x as? ChartAxisValueDate { + updateEndDate(lastDate.date.addingTimeInterval(.hours(1))) + } + } + } + + /// The chart points used for selection in the carb effect chart + public var allCarbEffectPoints: [ChartPoint] = [] { + didSet { + carbEffectChart = nil + } + } + + /// The minimum range to display for COB values. + private var carbEffectDisplayRangePoints: [ChartPoint] = [0, 1].map { + return ChartPoint( + x: ChartAxisValue(scalar: 0), + y: ChartAxisValueInt($0) + ) + } + + private var carbEffectChart: Chart? + + private var carbEffectChartCache: ChartPointsTouchHighlightLayerViewCache? + + public func carbEffectChartWithFrame(_ frame: CGRect) -> Chart? { + if let chart = carbEffectChart, chart.frame != frame { + self.carbEffectChart = nil + } + + if carbEffectChart == nil { + carbEffectChart = generateCarbEffectChartWithFrame(frame) + } + + return carbEffectChart + } + + private func generateCarbEffectChartWithFrame(_ frame: CGRect) -> Chart? { + guard let xAxisModel = xAxisModel, let xAxisValues = xAxisValues else { + return nil + } + + let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(carbEffectPoints + allCarbEffectPoints + carbEffectDisplayRangePoints, + minSegmentCount: 2, + maxSegmentCount: 4, + multiple: glucoseUnit.glucoseUnitYAxisSegmentSize / 50, + axisValueGenerator: { + ChartAxisValueDouble($0, labelSettings: self.axisLabelSettings) + }, + addPaddingSegmentIfEdge: false + ) + + let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY)) + + let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel) + + let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame) + + // Carb effect + let generateAreaLayer = { (points: [ChartPoint], color: UIColor) -> ChartPointsAreaLayer? in + var containerPoints = points + + // Create a container line at 0 + if let first = points.first { + containerPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) + } + + if let last = points.last { + containerPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) + } + + if containerPoints.count > 1 { + return ChartPointsAreaLayer( + xAxis: xAxisLayer.axis, + yAxis: yAxisLayer.axis, + chartPoints: containerPoints, + areaColor: color, + animDuration: 0, + animDelay: 0, + addContainerPoints: false + ) + } else { + return nil + } + } + + // Grid lines + let gridLayer = ChartGuideLinesForValuesLayer( + xAxis: xAxisLayer.axis, + yAxis: yAxisLayer.axis, + settings: guideLinesLayerSettings, + axisValuesX: Array(xAxisValues.dropFirst().dropLast()), + axisValuesY: yAxisValues + ) + + if gestureRecognizer != nil { + carbEffectChartCache = ChartPointsTouchHighlightLayerViewCache( + xAxisLayer: xAxisLayer, + yAxisLayer: yAxisLayer, + axisLabelSettings: self.axisLabelSettings, + chartPoints: allCarbEffectPoints, + tintColor: UIColor.COBTintColor, + gestureRecognizer: gestureRecognizer + ) + } + + let layers: [ChartLayer?] = [ + gridLayer, + xAxisLayer, + yAxisLayer, + carbEffectChartCache?.highlightLayer, + generateAreaLayer(carbEffectPoints, UIColor.secondaryLabelColor.withAlphaComponent(0.6)), + generateAreaLayer(insulinCounteractionEffectPoints, UIColor.COBTintColor.withAlphaComponent(0.7)) + ] + + return Chart( + frame: frame, + innerFrame: innerFrame, + settings: chartSettings, + layers: layers.flatMap { $0 } + ) + } + + // MARK: - Shared Axis + private func generateXAxisValues() { let timeFormatter = DateFormatter() timeFormatter.dateFormat = "h a" From 5ee0f2f0472f382c6aaabd4b6ea979f75cf27cf1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 17 Jul 2017 23:26:46 -0500 Subject: [PATCH 2/2] Carb chart contrast --- .../Base.lproj/MainInterface.storyboard | 6 +- .../StatusViewController.swift | 2 +- Loop.xcodeproj/project.pbxproj | 16 +- Loop/Base.lproj/Main.storyboard | 22 +-- Loop/Views/ChartTableViewCell.swift | 2 +- LoopUI/Managers/StatusChartsManager.swift | 149 +++++++----------- ...entView.swift => ChartContainerView.swift} | 4 +- .../Views/ChartPointsContextFillLayer.swift | 121 ++++++++++++++ 8 files changed, 205 insertions(+), 117 deletions(-) rename LoopUI/Views/{ChartContentView.swift => ChartContainerView.swift} (94%) create mode 100644 LoopUI/Views/ChartPointsContextFillLayer.swift diff --git a/Loop Status Extension/Base.lproj/MainInterface.storyboard b/Loop Status Extension/Base.lproj/MainInterface.storyboard index 181f25d3cd..91b420eed3 100644 --- a/Loop Status Extension/Base.lproj/MainInterface.storyboard +++ b/Loop Status Extension/Base.lproj/MainInterface.storyboard @@ -1,11 +1,11 @@ - + - + @@ -50,7 +50,7 @@ - + diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift index c39369ba15..a5de9e7563 100644 --- a/Loop Status Extension/StatusViewController.swift +++ b/Loop Status Extension/StatusViewController.swift @@ -26,7 +26,7 @@ class StatusViewController: UIViewController, NCWidgetProviding { } } @IBOutlet weak var subtitleLabel: UILabel! - @IBOutlet weak var glucoseChartContentView: LoopUI.ChartContentView! + @IBOutlet weak var glucoseChartContentView: LoopUI.ChartContainerView! private lazy var charts: StatusChartsManager = { let charts = StatusChartsManager( diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 54d4a99e5c..21b0555a98 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 435400321C9F745500D5819C /* BolusSuggestionUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */; }; 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; + 436961911F19D11E00447E89 /* ChartPointsContextFillLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4369618F1F19C86400447E89 /* ChartPointsContextFillLayer.swift */; }; 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0DA41D236A2A00104B24 /* LoopError.swift */; }; 436FACEE1D0BA636004E2427 /* InsulinDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436FACED1D0BA636004E2427 /* InsulinDataSource.swift */; }; 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43776F8F1B8022E90074EA36 /* AppDelegate.swift */; }; @@ -168,7 +169,7 @@ 4F08DEA11E81D90F006741EA /* GlucoseRangeScheduleCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DEA01E81D90F006741EA /* GlucoseRangeScheduleCalculator.swift */; }; 4F08DEA31E81E12D006741EA /* DatedRangedContextCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DEA21E81E12D006741EA /* DatedRangedContextCalculator.swift */; }; 4F20AE621E6B879C00D07A06 /* ReservoirVolumeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEC71CD84CBB003C8C80 /* ReservoirVolumeHUDView.swift */; }; - 4F20AE631E6B87B100D07A06 /* ChartContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */; }; + 4F20AE631E6B87B100D07A06 /* ChartContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4313EDDF1D8A6BF90060FA79 /* ChartContainerView.swift */; }; 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F2C15751E0209FA00E160D4 /* GlucoseTrend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EA285E1D50ED3D001BC233 /* GlucoseTrend.swift */; }; 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */; }; @@ -368,7 +369,7 @@ 430C1ABC1E5568A80067F1AE /* StatusChartsManager+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StatusChartsManager+LoopKit.swift"; sourceTree = ""; }; 430DA58D1D4AEC230097D1CA /* NSBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSBundle.swift; sourceTree = ""; }; 430DA58F1D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MySentryPumpStatusMessageBody.swift; sourceTree = ""; }; - 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartContentView.swift; sourceTree = ""; }; + 4313EDDF1D8A6BF90060FA79 /* ChartContainerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ChartContainerView.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryEditTableViewController.swift; sourceTree = ""; }; 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DiagnosticLogger+LoopKit.swift"; sourceTree = ""; }; 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; @@ -390,7 +391,7 @@ 4341F4EA1EDB92AC001C936B /* LogglyService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogglyService.swift; sourceTree = ""; }; 43441A9B1EDB34810087958C /* StatusExtensionContext+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StatusExtensionContext+LoopKit.swift"; sourceTree = ""; }; 43441A9F1EDB4D390087958C /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; - 4346D1E61C77F5FE00ABAFE3 /* ChartTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartTableViewCell.swift; sourceTree = ""; }; + 4346D1E61C77F5FE00ABAFE3 /* ChartTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ChartTableViewCell.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 4346D1EF1C781BEA00ABAFE3 /* SwiftCharts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftCharts.framework; path = Carthage/Build/iOS/SwiftCharts.framework; sourceTree = ""; }; 4346D1F51C78501000ABAFE3 /* ChartPoint+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ChartPoint+Loop.swift"; sourceTree = ""; }; 434AB0B11CBB4C3300422F4A /* RileyLinkBLEKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RileyLinkBLEKit.framework; path = Carthage/Build/iOS/RileyLinkBLEKit.framework; sourceTree = ""; }; @@ -407,6 +408,7 @@ 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusSuggestionUserInfo.swift; sourceTree = ""; }; 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetBolusUserInfo.swift; sourceTree = ""; }; 43649A621C7A347F00523D7F /* CollectionType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionType.swift; sourceTree = ""; }; + 4369618F1F19C86400447E89 /* ChartPointsContextFillLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartPointsContextFillLayer.swift; sourceTree = ""; }; 436A0DA41D236A2A00104B24 /* LoopError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopError.swift; sourceTree = ""; }; 436FACED1D0BA636004E2427 /* InsulinDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsulinDataSource.swift; sourceTree = ""; }; 43776F8C1B8022E90074EA36 /* Loop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Loop.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -546,7 +548,7 @@ 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ChartColorPalette+Loop.swift"; sourceTree = ""; }; 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Status Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; - 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewController.swift; sourceTree = ""; }; + 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = StatusViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 4F70C1E31DE8DCA7006380B7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 4F70C1E51DE8DCA7006380B7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Status Extension.entitlements"; sourceTree = ""; }; @@ -980,7 +982,8 @@ 43B371851CE583890013C5A6 /* BasalStateView.swift */, 437CEEBB1CD6DE6A003C8C80 /* BaseHUDView.swift */, 437CEEC91CD84DB7003C8C80 /* BatteryLevelHUDView.swift */, - 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */, + 4313EDDF1D8A6BF90060FA79 /* ChartContainerView.swift */, + 4369618F1F19C86400447E89 /* ChartPointsContextFillLayer.swift */, 4F08DE831E7BB70B006741EA /* ChartPointsScatterDownTrianglesLayer.swift */, 4F08DE841E7BB70B006741EA /* ChartPointsTouchHighlightLayerViewCache.swift */, 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */, @@ -1639,6 +1642,7 @@ 4F20AE621E6B879C00D07A06 /* ReservoirVolumeHUDView.swift in Sources */, 4FB76FB91E8C42B000B39636 /* CollectionType.swift in Sources */, 4FF4D0F91E17268800846527 /* IdentifiableClass.swift in Sources */, + 436961911F19D11E00447E89 /* ChartPointsContextFillLayer.swift in Sources */, 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */, 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */, 4F7528A91DFE212600C322D6 /* GlucoseTrend.swift in Sources */, @@ -1652,7 +1656,7 @@ 4FB76FB41E8C3F7C00B39636 /* ChartAxisValueDoubleUnit.swift in Sources */, 4FB76FB31E8C3EE400B39636 /* ChartAxisValueDoubleLog.swift in Sources */, 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */, - 4F20AE631E6B87B100D07A06 /* ChartContentView.swift in Sources */, + 4F20AE631E6B87B100D07A06 /* ChartContainerView.swift in Sources */, 4F7528A21DFE200B00C322D6 /* LevelMaskView.swift in Sources */, 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */, 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */, diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index 7edcc53afb..24f48a7c71 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,11 +1,11 @@ - + - + @@ -135,7 +135,7 @@ - + @@ -290,7 +290,7 @@ - + @@ -306,7 +306,7 @@ - + @@ -327,7 +327,7 @@ - + @@ -471,13 +471,13 @@ - - + + - - + + @@ -663,7 +663,7 @@ - + diff --git a/Loop/Views/ChartTableViewCell.swift b/Loop/Views/ChartTableViewCell.swift index 049023f2a9..a283c9fc60 100644 --- a/Loop/Views/ChartTableViewCell.swift +++ b/Loop/Views/ChartTableViewCell.swift @@ -12,7 +12,7 @@ import LoopUI final class ChartTableViewCell: UITableViewCell { - @IBOutlet weak var chartContentView: ChartContentView! + @IBOutlet weak var chartContentView: ChartContainerView! @IBOutlet weak var titleLabel: UILabel? diff --git a/LoopUI/Managers/StatusChartsManager.swift b/LoopUI/Managers/StatusChartsManager.swift index d05e43306c..67fe3b78c4 100644 --- a/LoopUI/Managers/StatusChartsManager.swift +++ b/LoopUI/Managers/StatusChartsManager.swift @@ -323,25 +323,27 @@ public final class StatusChartsManager { let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame) // The glucose targets - var targetLayer: ChartPointsAreaLayer? = nil - - if targetGlucosePoints.count > 1 { - let alpha: CGFloat = targetOverridePoints.count > 1 ? 0.15 : 0.3 - - targetLayer = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: targetGlucosePoints, areaColor: colors.glucoseTint.withAlphaComponent(alpha), animDuration: 0, animDelay: 0, addContainerPoints: false) - } - - var targetOverrideLayer: ChartPointsAreaLayer? = nil - - if targetOverridePoints.count > 1 { - targetOverrideLayer = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: targetOverridePoints, areaColor: colors.glucoseTint.withAlphaComponent(0.3), animDuration: 0, animDelay: 0, addContainerPoints: false) - } - - var targetOverrideDurationLayer: ChartPointsAreaLayer? = nil - - if targetOverrideDurationPoints.count > 1 { - targetOverrideDurationLayer = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: targetOverrideDurationPoints, areaColor: colors.glucoseTint.withAlphaComponent(0.3), animDuration: 0, animDelay: 0, addContainerPoints: false) - } + let targetsLayer = ChartPointsFillsLayer( + xAxis: xAxisLayer.axis, + yAxis: yAxisLayer.axis, + fills: [ + ChartPointsFill( + chartPoints: targetGlucosePoints, + fillColor: colors.glucoseTint.withAlphaComponent(targetOverridePoints.count > 1 ? 0.15 : 0.3), + createContainerPoints: false + ), + ChartPointsFill( + chartPoints: targetOverridePoints, + fillColor: colors.glucoseTint.withAlphaComponent(0.3), + createContainerPoints: false + ), + ChartPointsFill( + chartPoints: targetOverrideDurationPoints, + fillColor: colors.glucoseTint.withAlphaComponent(0.3), + createContainerPoints: false + ) + ] + ) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) @@ -388,9 +390,7 @@ public final class StatusChartsManager { let layers: [ChartLayer?] = [ gridLayer, - targetLayer, - targetOverrideLayer, - targetOverrideDurationLayer, + targetsLayer, xAxisLayer, yAxisLayer, glucoseChartCache?.highlightLayer, @@ -419,17 +419,6 @@ public final class StatusChartsManager { return nil } - var containerPoints = iobPoints - - // Create a container line at 0 - if let first = iobPoints.first { - containerPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) - } - - if let last = iobPoints.last { - containerPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) - } - let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(iobPoints + iobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: 0.5, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: self.axisLabelSettings) }, addPaddingSegmentIfEdge: false) let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY)) @@ -442,13 +431,7 @@ public final class StatusChartsManager { let lineModel = ChartLineModel(chartPoints: iobPoints, lineColor: UIColor.IOBTintColor, lineWidth: 2, animDuration: 0, animDelay: 0) let iobLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) - let iobArea: ChartPointsAreaLayer? - - if containerPoints.count > 1 { - iobArea = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: containerPoints, areaColor: UIColor.IOBTintColor.withAlphaComponent(0.5), animDuration: 0, animDelay: 0, addContainerPoints: false) - } else { - iobArea = nil - } + let iobArea = ChartPointsFillsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ChartPointsFill(chartPoints: iobPoints, fillColor: UIColor.IOBTintColor.withAlphaComponent(0.5))]) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) @@ -505,17 +488,6 @@ public final class StatusChartsManager { return nil } - var containerPoints = cobPoints - - // Create a container line at 0 - if let first = cobPoints.first { - containerPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) - } - - if let last = cobPoints.last { - containerPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) - } - let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(cobPoints + cobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: 10, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: self.axisLabelSettings) }, addPaddingSegmentIfEdge: false) let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY)) @@ -528,13 +500,7 @@ public final class StatusChartsManager { let lineModel = ChartLineModel(chartPoints: cobPoints, lineColor: UIColor.COBTintColor, lineWidth: 2, animDuration: 0, animDelay: 0) let cobLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) - let cobArea: ChartPointsAreaLayer? - - if containerPoints.count > 0 { - cobArea = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: containerPoints, areaColor: UIColor.COBTintColor.withAlphaComponent(0.5), animDuration: 0, animDelay: 0, addContainerPoints: false) - } else { - cobArea = nil - } + let cobArea = ChartPointsFillsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ChartPointsFill(chartPoints: cobPoints, fillColor: UIColor.COBTintColor.withAlphaComponent(0.5))]) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: guideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) @@ -593,13 +559,15 @@ public final class StatusChartsManager { let lineModel = ChartLineModel(chartPoints: basalDosePoints, lineColor: colors.doseTint, lineWidth: 2, animDuration: 0, animDelay: 0) let doseLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) - let doseArea: ChartPointsAreaLayer? - - if basalDosePoints.count > 1 { - doseArea = ChartPointsAreaLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: basalDosePoints, areaColor: colors.doseTint.withAlphaComponent(0.5), animDuration: 0, animDelay: 0, addContainerPoints: false) - } else { - doseArea = nil - } + let doseArea = ChartPointsFillsLayer( + xAxis: xAxisLayer.axis, + yAxis: yAxisLayer.axis, + fills: [ChartPointsFill( + chartPoints: basalDosePoints, + fillColor: colors.doseTint.withAlphaComponent(0.5), + createContainerPoints: false + )] + ) let bolusLayer: ChartPointsScatterDownTrianglesLayer? @@ -722,33 +690,17 @@ public final class StatusChartsManager { let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame) - // Carb effect - let generateAreaLayer = { (points: [ChartPoint], color: UIColor) -> ChartPointsAreaLayer? in - var containerPoints = points - - // Create a container line at 0 - if let first = points.first { - containerPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) - } + let carbFillColor = UIColor.COBTintColor.withAlphaComponent(0.8) - if let last = points.last { - containerPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) - } - - if containerPoints.count > 1 { - return ChartPointsAreaLayer( - xAxis: xAxisLayer.axis, - yAxis: yAxisLayer.axis, - chartPoints: containerPoints, - areaColor: color, - animDuration: 0, - animDelay: 0, - addContainerPoints: false - ) - } else { - return nil - } - } + // Carb effect + let effectsLayer = ChartPointsFillsLayer( + xAxis: xAxisLayer.axis, + yAxis: yAxisLayer.axis, + fills: [ + ChartPointsFill(chartPoints: carbEffectPoints, fillColor: UIColor.secondaryLabelColor.withAlphaComponent(0.5)), + ChartPointsFill(chartPoints: insulinCounteractionEffectPoints, fillColor: carbFillColor, blendMode: .colorBurn) + ] + ) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer( @@ -759,6 +711,17 @@ public final class StatusChartsManager { axisValuesY: yAxisValues ) + // 0-line + let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0)) + let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in + let width: CGFloat = 1 + let viewFrame = CGRect(x: chart.contentView.bounds.minX, y: chartPointModel.screenLoc.y - width / 2, width: chart.contentView.bounds.size.width, height: width) + + let v = UIView(frame: viewFrame) + v.backgroundColor = carbFillColor + return v + }) + if gestureRecognizer != nil { carbEffectChartCache = ChartPointsTouchHighlightLayerViewCache( xAxisLayer: xAxisLayer, @@ -774,9 +737,9 @@ public final class StatusChartsManager { gridLayer, xAxisLayer, yAxisLayer, + zeroGuidelineLayer, carbEffectChartCache?.highlightLayer, - generateAreaLayer(carbEffectPoints, UIColor.secondaryLabelColor.withAlphaComponent(0.6)), - generateAreaLayer(insulinCounteractionEffectPoints, UIColor.COBTintColor.withAlphaComponent(0.7)) + effectsLayer ] return Chart( diff --git a/LoopUI/Views/ChartContentView.swift b/LoopUI/Views/ChartContainerView.swift similarity index 94% rename from LoopUI/Views/ChartContentView.swift rename to LoopUI/Views/ChartContainerView.swift index dcc3d7d3ec..a0b322b7eb 100644 --- a/LoopUI/Views/ChartContentView.swift +++ b/LoopUI/Views/ChartContainerView.swift @@ -1,5 +1,5 @@ // -// ChartContentView.swift +// ChartContainerView.swift // Loop // // Created by Nate Racklyeft on 9/14/16. @@ -8,7 +8,7 @@ import UIKit -public class ChartContentView: UIView { +public class ChartContainerView: UIView { override public func layoutSubviews() { super.layoutSubviews() diff --git a/LoopUI/Views/ChartPointsContextFillLayer.swift b/LoopUI/Views/ChartPointsContextFillLayer.swift new file mode 100644 index 0000000000..a2824336ce --- /dev/null +++ b/LoopUI/Views/ChartPointsContextFillLayer.swift @@ -0,0 +1,121 @@ +// +// ChartPointsContextFillLayer.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import SwiftCharts + + +struct ChartPointsFill { + let chartPoints: [ChartPoint] + let fillColor: UIColor + let createContainerPoints: Bool + let blendMode: CGBlendMode + fileprivate var screenPoints: [CGPoint] = [] + + init?(chartPoints: [ChartPoint], fillColor: UIColor, createContainerPoints: Bool = true, blendMode: CGBlendMode = .normal) { + guard chartPoints.count > 1 else { + return nil; + } + + var chartPoints = chartPoints + + if createContainerPoints { + // Create a container line at value position 0 + if let first = chartPoints.first { + chartPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) + } + + if let last = chartPoints.last { + chartPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) + } + } + + self.chartPoints = chartPoints + self.fillColor = fillColor + self.createContainerPoints = createContainerPoints + self.blendMode = blendMode + } + + var areaPath: UIBezierPath { + let path = UIBezierPath() + + if let point = screenPoints.first { + path.move(to: point) + } + + for point in screenPoints.dropFirst() { + path.addLine(to: point) + } + + return path + } +} + + +final class ChartPointsFillsLayer: ChartCoordsSpaceLayer { + let fills: [ChartPointsFill] + + init?(xAxis: ChartAxis, yAxis: ChartAxis, fills: [ChartPointsFill?]) { + self.fills = fills.flatMap({ $0 }) + + guard fills.count > 0 else { + return nil + } + + super.init(xAxis: xAxis, yAxis: yAxis) + } + + override func chartInitialized(chart: Chart) { + super.chartInitialized(chart: chart) + + let view = ChartPointsFillsView( + frame: chart.bounds, + chartPointsFills: fills.map { (fill) -> ChartPointsFill in + var fill = fill + + fill.screenPoints = fill.chartPoints.map { (point) -> CGPoint in + return modelLocToScreenLoc(x: point.x.scalar, y: point.y.scalar) + } + + return fill + } + ) + + chart.addSubview(view) + } +} + + +class ChartPointsFillsView: UIView { + let chartPointsFills: [ChartPointsFill] + var allowsAntialiasing = false + + init(frame: CGRect, chartPointsFills: [ChartPointsFill]) { + self.chartPointsFills = chartPointsFills + + super.init(frame: frame) + + backgroundColor = .clear + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + + context.saveGState() + context.setAllowsAntialiasing(allowsAntialiasing) + + for fill in chartPointsFills { + context.setFillColor(fill.fillColor.cgColor) + fill.areaPath.fill(with: fill.blendMode, alpha: 1) + } + + context.restoreGState() + } +}