From ae68b7bb40879d1ec4a8795ebf73bf765c4dd1b5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 7 Jan 2019 09:19:29 -0600 Subject: [PATCH 1/8] Fix a case where the watch chart doesn't update on appear (#858) --- Loop/Managers/DeviceDataManager.swift | 9 ++---- .../Controllers/ChartHUDController.swift | 13 +++++++-- .../Scenes/GlucoseChartScene.swift | 29 +++++++++++++++---- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 8f7b774343..dd578c6873 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -247,22 +247,19 @@ extension DeviceDataManager: PumpManagerDelegate { return } - var didSendLowNotification = false let warningThresholds: [Double] = [10, 20, 30] for threshold in warningThresholds { if newValue.unitVolume <= threshold && previousVolume > threshold { NotificationManager.sendPumpReservoirLowNotificationForAmount(newValue.unitVolume, andTimeRemaining: nil) - didSendLowNotification = true + break } } - if !didSendLowNotification { - NotificationManager.clearPumpReservoirNotification() - } - if newValue.unitVolume > previousVolume + 1 { AnalyticsManager.shared.reservoirWasRewound() + + NotificationManager.clearPumpReservoirNotification() } } } diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index 11770e69fa..561053dffa 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -54,13 +54,20 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { override func didAppear() { super.didAppear() - log.default("didAppear") + if glucoseScene.isPaused { + log.default("didAppear() unpausing") + glucoseScene.isPaused = false + } else { + log.default("didAppear() not paused") + glucoseScene.isPaused = false + } // Force an update when our pixels need to move let pixelsWide = scene.size.width * WKInterfaceDevice.current().screenScale let pixelInterval = scene.visibleDuration / TimeInterval(pixelsWide) timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in + self?.log.default("Timer fired, triggering update") self?.scene.setNeedsUpdate() } @@ -85,7 +92,7 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { log.default("willActivate() unpausing") glucoseScene.isPaused = false } else { - log.default("willActivate() unpausing") + log.default("willActivate()") } if !hasInitialActivation && UserDefaults.standard.startOnChartPage { @@ -174,7 +181,7 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { updateGlucoseChart() } - func updateGlucoseChart() { + private func updateGlucoseChart() { guard let activeContext = loopManager.activeContext else { return } diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 2745459773..16b22d241b 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -13,6 +13,11 @@ import LoopKit import WatchKit import os.log +private extension TimeInterval { + static let moveAnimationDuration: TimeInterval = 0.25 + static let fadeAnimationDuration: TimeInterval = 0.75 +} + private enum NodePlane: Int { case lines = 0 case ranges @@ -42,14 +47,26 @@ private extension SKLabelNode { private extension SKSpriteNode { func move(to rect: CGRect, animated: Bool) { - if parent == nil || animated == false || (size.equalTo(rect.size) && position.equalTo(rect.origin)) { + guard !size.equalTo(rect.size) || !position.equalTo(rect.origin) else { + return + } + + if parent == nil || !animated { size = rect.size position = rect.origin + + if parent != nil { + alpha = 0 + run(.sequence([ + .wait(forDuration: .moveAnimationDuration), + .fadeIn(withDuration: .fadeAnimationDuration) + ])) + } } else { run(.group([ - .move(to: rect.origin, duration: 0.25), - .resize(toWidth: rect.size.width, duration: 0.25), - .resize(toHeight: rect.size.height, duration: 0.25) + .move(to: rect.origin, duration: .moveAnimationDuration), + .resize(toWidth: rect.size.width, duration: .moveAnimationDuration), + .resize(toHeight: rect.size.height, duration: .moveAnimationDuration) ])) } } @@ -386,8 +403,8 @@ class GlucoseChartScene: SKScene { // SKShapeNode paths cannot be easily animated. Make it vanish, then fade in at the new location. predictedPathNode!.alpha = 0 predictedPathNode!.run(.sequence([ - .wait(forDuration: 0.25), - .fadeIn(withDuration: 0.75) + .wait(forDuration: .moveAnimationDuration), + .fadeIn(withDuration: .fadeAnimationDuration) ]), withKey: "move" ) From 1746a7504a0900d291dc91940922c9bc3dbea0ca Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 7 Jan 2019 09:19:45 -0600 Subject: [PATCH 2/8] All pump event ids must be marked as processed in upload callback (#856) --- Loop/Extensions/NightscoutUploader.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/Extensions/NightscoutUploader.swift b/Loop/Extensions/NightscoutUploader.swift index ab81c2f85f..b4bad0158a 100644 --- a/Loop/Extensions/NightscoutUploader.swift +++ b/Loop/Extensions/NightscoutUploader.swift @@ -75,11 +75,13 @@ extension NightscoutUploader { var treatments = [NightscoutTreatment]() for event in events { + + objectIDURLs.append(event.objectIDURL) + guard let treatment = event.treatment(enteredBy: source) else { continue } - objectIDURLs.append(event.objectIDURL) treatments.append(treatment) } From f9b6d422930d8f2fff683a719aa518bd410b8591 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 7 Jan 2019 10:21:16 -0600 Subject: [PATCH 3/8] Display longer boluses as points on delivery graph (#844) --- Loop/Managers/StatusChartsManager+LoopKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/StatusChartsManager+LoopKit.swift b/Loop/Managers/StatusChartsManager+LoopKit.swift index 2f54ca31ac..46b4cd0fe6 100644 --- a/Loop/Managers/StatusChartsManager+LoopKit.swift +++ b/Loop/Managers/StatusChartsManager+LoopKit.swift @@ -83,7 +83,7 @@ extension StatusChartsManager { for entry in doseEntries { let time = entry.endDate.timeIntervalSince(entry.startDate) - if entry.type == .bolus && entry.netBasalUnits > 0 && time < .minutes(5) { + if entry.type == .bolus && entry.netBasalUnits > 0 && time < .minutes(10) { let x = ChartAxisValueDate(date: entry.startDate, formatter: dateFormatter) let y = ChartAxisValueDoubleLog(actualDouble: entry.units, unitString: "U", formatter: doseFormatter) From 812bbd76ab0de69c8dba33e918443e505bf10f35 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 7 Jan 2019 14:43:37 -0600 Subject: [PATCH 4/8] Update dependencies (#860) * Update dependencies * DEVELOPMENT_TEAM should be none --- Cartfile | 4 ++-- Cartfile.resolved | 10 +++++----- Loop.xcodeproj/project.pbxproj | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cartfile b/Cartfile index 19c5b4d835..422b481a17 100644 --- a/Cartfile +++ b/Cartfile @@ -2,7 +2,7 @@ github "LoopKit/LoopKit" "dev" github "LoopKit/CGMBLEKit" "dev" github "i-schuetz/SwiftCharts" == 0.6.2 -github "LoopKit/dexcom-share-client-swift" "carthage-recursive" -github "LoopKit/G4ShareSpy" "fix-cartfile" +github "LoopKit/dexcom-share-client-swift" "dev" +github "LoopKit/G4ShareSpy" "dev" github "ps2/rileylink_ios" "dev" github "LoopKit/Amplitude-iOS" "decreepify" diff --git a/Cartfile.resolved b/Cartfile.resolved index 46a30a96dc..f32f060e27 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,7 +1,7 @@ github "LoopKit/Amplitude-iOS" "2137d5fd44bf630ed33e1e72d7af6d8f8612f270" -github "LoopKit/CGMBLEKit" "bc7c6bb2aaea48e501ecdbb379cd3a533f5df42e" -github "LoopKit/G4ShareSpy" "67208c66abfbb3bff813f047b6c40612338a335b" -github "LoopKit/LoopKit" "1c23bb5fd54c0b0aaa81dbabcb35f2d4c9c2e8cf" -github "LoopKit/dexcom-share-client-swift" "27fd38c28dcb16093ddf660c2b6b84ae34733352" +github "LoopKit/CGMBLEKit" "f868f1366c8a464763fb86ca077e768282a9b197" +github "LoopKit/G4ShareSpy" "3c7040cc93f28b778b6d265e3224974b4ea31483" +github "LoopKit/LoopKit" "1dc7b3ec4b7c6bb9225545f5d8fe599bfe783a5c" +github "LoopKit/dexcom-share-client-swift" "13e0f5cfd98dd4bfdf5eff413ba3ed36617cbb08" github "i-schuetz/SwiftCharts" "0.6.2" -github "ps2/rileylink_ios" "5df08f32f11ac2cb6097ba1411349f05b9171e72" +github "ps2/rileylink_ios" "f3597c78ae00da2f35ef98269056126af4ce070f" diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index ca508159ab..f38db71858 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -2615,7 +2615,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -2632,7 +2632,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; @@ -2648,7 +2648,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/watchOS"; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -2668,7 +2668,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/watchOS"; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -2688,7 +2688,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2709,7 +2709,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2788,7 +2788,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; @@ -2807,7 +2807,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; From 7797ddaf611822a8cf962741d236aa3a7eca00c6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Jan 2019 09:30:32 -0600 Subject: [PATCH 5/8] Add default-level logging throughout lifecycle (#861) --- Loop/Managers/DeviceDataManager.swift | 31 ++++++++++++++++++++++++++- Loop/Managers/LoopDataManager.swift | 2 ++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index dd578c6873..b4d83d3e35 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -132,6 +132,8 @@ extension DeviceDataManager: CGMManagerDelegate { /// TODO: Isolate to queue switch result { case .newData(let values): + log.default("CGMManager:\(type(of: manager)) did update with new data") + loopManager.addGlucose(values) { result in if manager.shouldSyncToRemoteService { switch result { @@ -145,8 +147,12 @@ extension DeviceDataManager: CGMManagerDelegate { self.pumpManager?.assertCurrentPumpData() } case .noData: + log.default("CGMManager:\(type(of: manager)) did update with no data") + pumpManager?.assertCurrentPumpData() case .error(let error): + log.default("CGMManager:\(type(of: manager)) did update with error: \(error)") + self.setLastError(error: error) pumpManager?.assertCurrentPumpData() } @@ -162,10 +168,14 @@ extension DeviceDataManager: CGMManagerDelegate { extension DeviceDataManager: PumpManagerDelegate { func pumpManager(_ pumpManager: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { + log.default("PumpManager:\(type(of: pumpManager)) did adjust pump block by \(adjustment)s") + AnalyticsManager.shared.pumpTimeDidDrift(adjustment) } func pumpManagerDidUpdatePumpBatteryChargeRemaining(_ pumpManager: PumpManager, oldValue: Double?) { + log.default("PumpManager:\(type(of: pumpManager)) did update pump battery from \(String(describing: oldValue))") + if let newValue = pumpManager.pumpBatteryChargeRemaining { if newValue == 0 { NotificationManager.sendPumpBatteryLowNotification() @@ -180,10 +190,14 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerDidUpdateState(_ pumpManager: PumpManager) { + log.default("PumpManager:\(type(of: pumpManager)) did update state") + UserDefaults.appGroup.pumpManager = pumpManager } func pumpManagerBLEHeartbeatDidFire(_ pumpManager: PumpManager) { + log.default("PumpManager:\(type(of: pumpManager)) did fire BLE heartbeat") + cgmManager?.fetchNewDataIfNeeded { (result) in if case .newData = result { AnalyticsManager.shared.didFetchNewCGMData() @@ -201,6 +215,8 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManager(_ pumpManager: PumpManager, didUpdateStatus status: PumpManagerStatus) { + log.default("PumpManager:\(type(of: pumpManager)) did update status") + loopManager.doseStore.device = status.device // Update the pump-schedule based settings loopManager.setScheduleTimeZone(status.timeZone) @@ -208,20 +224,28 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { + log.default("PumpManager:\(type(of: pumpManager)) will deactivate") + loopManager.doseStore.resetPumpData() self.pumpManager = nil } func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { + log.default("PumpManager:\(type(of: pumpManager)) did update pumpRecordsBasalProfileStartEvents to \(pumpRecordsBasalProfileStartEvents)") + loopManager.doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { + log.error("PumpManager:\(type(of: pumpManager)) did error: \(error)") + setLastError(error: error) nightscoutDataManager.uploadLoopStatus(loopError: error) } func pumpManager(_ pumpManager: PumpManager, didReadPumpEvents events: [NewPumpEvent], completion: @escaping (_ error: Error?) -> Void) { + log.default("PumpManager:\(type(of: pumpManager)) did read pump events") + loopManager.addPumpEvents(events) { (error) in if let error = error { self.log.error("Failed to addPumpEvents to DoseStore: \(error)") @@ -232,10 +256,12 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManager(_ pumpManager: PumpManager, didReadReservoirValue units: Double, at date: Date, completion: @escaping (_ result: PumpManagerResult<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool)>) -> Void) { + log.default("PumpManager:\(type(of: pumpManager)) did read reservoir value") + loopManager.addReservoirValue(units, at: date) { (result) in switch result { case .failure(let error): - self.logger.addError(error, fromSource: "Bolus") + self.log.error("Failed to addReservoirValue: \(error)") completion(.failure(error)) case .success(let (newValue, lastValue, areStoredValuesContinuous)): completion(.success((newValue: newValue, lastValue: lastValue, areStoredValuesContinuous: areStoredValuesContinuous))) @@ -267,6 +293,7 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerRecommendsLoop(_ pumpManager: PumpManager) { + log.default("PumpManager:\(type(of: pumpManager)) recommends loop") loopManager.loop() } @@ -338,6 +365,8 @@ extension DeviceDataManager: LoopDataManagerDelegate { return } + log.default("LoopManager did recommend basal change") + pumpManager.enactTempBasal( unitsPerHour: basal.recommendation.unitsPerHour, for: basal.recommendation.duration, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 32a455df17..010b1ac993 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -554,6 +554,7 @@ extension LoopDataManager { /// temporary basal rate. func loop() { self.dataAccessQueue.async { + self.logger.default("Loop running") NotificationCenter.default.post(name: .LoopRunning, object: self) self.lastLoopError = nil @@ -582,6 +583,7 @@ extension LoopDataManager { self.lastLoopError = error } + self.logger.default("Loop ended") self.notify(forChange: .tempBasal) } } From 550f4d9d066b16d22a4dd6913dfae299db80c8d6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Jan 2019 09:30:49 -0600 Subject: [PATCH 6/8] Resolve warnings about complication image size (#862) --- .../Graphic Corner.imageset/Contents.json | 4 ++-- .../Icon-AppleWatch-32x32@2x.png | Bin 4534 -> 0 bytes .../Icon-AppleWatch-36x36@2x.png | Bin 5290 -> 0 bytes .../Icon-Complication-20x20@2x.png | Bin 0 -> 1182 bytes .../Icon-Complication-22x22@2x.png | Bin 0 -> 1290 bytes 5 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-AppleWatch-32x32@2x.png delete mode 100644 WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-AppleWatch-36x36@2x.png create mode 100644 WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-20x20@2x.png create mode 100644 WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-22x22@2x.png diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json index faf72e9052..637251bc8a 100644 --- a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json @@ -7,7 +7,7 @@ }, { "idiom" : "watch", - "filename" : "Icon-AppleWatch-32x32@2x.png", + "filename" : "Icon-Complication-20x20@2x.png", "screen-width" : ">161", "scale" : "2x" }, @@ -18,7 +18,7 @@ }, { "idiom" : "watch", - "filename" : "Icon-AppleWatch-36x36@2x.png", + "filename" : "Icon-Complication-22x22@2x.png", "screen-width" : ">183", "scale" : "2x" } diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-AppleWatch-32x32@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-AppleWatch-32x32@2x.png deleted file mode 100644 index e8eefb523caf5098d9f196e298ad72efb06b4137..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4534 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 UXeX`4s{jB107*qoM6N<$g7aI`(*OVf diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-AppleWatch-36x36@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-AppleWatch-36x36@2x.png deleted file mode 100644 index b3abdc0592957ba8e17ef6e994ee1f19391fe36f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5290 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 diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-20x20@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3e64959cdc312015b9409936a896cc89a5c2b912 GIT binary patch literal 1182 zcmV;P1Y!G$P)Px(SV=@dR9Fe^SkG@%RTRGG&U9v)Dx`@tP(dSPiixnX=)#3;aK}J3=>iBCptJ_U zh94VUm=M_2h0&n2g@8XulO_t&zd#oR#VtnKG_W8OtP5;wVW#ce>-ip?WVrXdH*aPn zxaeE-&He5@-}&CTzs{*rE6+*md^9955M@dStg=0d)H~K%Es7vz*}+Xx`dYdr`OVmC=O2jUBEY%??tgHqZDw}% zgtg|hRqDkEUa4zdD?ONQPY#ZCoc~7}AYriu?wT6dX3FMAp33zSQCy5277{A?{ox?w5Hv?#BPBS>pD+=?)ifv<)tBdiH{EPara3&yazIh4Pyc-UwD&TjISZeGZ->wmh#G>go}11%rHtK+i{HcR1Og7`a?RjVs$V>T zFhmXE5^%dM$V!BcdZ6OM;#qLHHX%O!n*@d^aQNk?Kb$ggR&v-SkK5ikF>sKx8OeTr z*{4S(Mi40y3LI}V08W{Zm56;2AjFY17o;J4ynGf`@rmgcNP4G_zEFI)aEEt`c(d?9 zdl?sht@{~k4$~SEIbKexkZDhC4y*J+1wv%cosZr|fx~+`gSPu1E8!0q#od~0p<9Vp zcTD3D#yN*$DcJc>HiWn40xqIbLeci?T|>XP+?95uKn&V7d1Rk4_PdHf)SL6gGWx|s zSTNK&u|J=^5XmjB!D_C2cKCZ#UpyDZYFzpaVX?$<4y`-Zdjw89cse_!g=!()1~R)m#Oy<2}#2^$a#dTyfxz9l>fLK~aSo0%`jH w>7Pf{A}hax|8zaWf4^1yth`qu@Vp}MFCqJ+mNMsJxBvhE07*qoM6N<$g0-taS^xk5 literal 0 HcmV?d00001 diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-22x22@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-22x22@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1593033216c90bcf748aac43ada05acc4b570aa1 GIT binary patch literal 1290 zcmV+l1@-!gP)Px(#7RU!R9Fe^SxtyrRTRGGz0}F5Ad2I{R#3;u__K5rS2M0{U&MuiRb0(vC^!p| zQvaYuu$FG5f?{XdmExw0fn+RoAy^UoL0nn1iOyJr6crWiz)VQ;uHSjdE6Ka(zW0(h z+D+c(<({AKeDB=9b8nOyJ);baGGJujh(3}l>C!Gku>%6iis98ux2jFOPb;;Tl%53M z?Ml&vR+>oB?~vCa+aRK|IdaOY_q_L8mY72mE>;(3we}Ay(sNoXHw_7p_Lb6~Yu9~! zZSv$NX?&tK0~3C{ws^m<{o`8ey-~11+6a6u=jIL~Ahks zi5d<$^W~|NAH-Poshqx+r2&HE)k<%m>p$EvvK&k6G2d^$?_HdKlVe!I_lZ*(1!R5y zl|Ijy24-6u@iE_YF2<}+1tP~P)2CkU6AFz)!aNK(vx4tc5mtlCn0#k2nabmid*OjQ zZwpp}Z#Vv$aN3Rg+J3u;{5^$??!M|I{2+3ksT7tIVhN6+s5`;Fu?+)!?G@!XNAvj` zPMo>pt*y{cdN0+M#(vh@OVGUryqhE&MQR%>$xLNx<$|b(FgC+pbzu$Q;%b0y|1qbo zE3Ova{oV#H$&2d;ru}BCjNwv{^dS6ErLeM3(y`1+*sCqfXrASzBk|%Aqyc+NX6L5wSno`J@mve8!$W@o58%D-hLD?xd+c=1X z`E&~h92g@H>e+6I7*Pz@yyQ2Ymuh5=P40L_LN#V#XoSN3Jhn)qo&eb2IafWrI=NB} zZHIY3@zX+~)mC3iwK0~t&nksea}utYVK(HWxa7bAnk`k~5>e8pk?p^9;O16KmnE|7 zZ;Q=3D`CDjK>E+Ik@@HX*R$S6>0VP8FX7By*=~M;RbX1QB>Fh+&caHV!T8<)HETlz zM)9qi`YROrhmh^WV?R%@;>FU7!7#9sZMZHS6ytS;)-fD96>E#n@=-i&OYfjW+=)Y| zL(k!)kq}E_5RnD<21>mY5nG#F8w`5PM^7GKE36!{f_4?LLn3s3u(0PT=}ho z>{;G!%XgpnHHvY~+yCWX2G{)KMg&F~$e4kD0S_)OR#sB~S07*qoM6N<$f(u`7 AssI20 literal 0 HcmV?d00001 From 472e739436f44b93400671e08f5145dd66761838 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Jan 2019 14:18:22 -0600 Subject: [PATCH 7/8] Bharat watch hybrid2 (#863) * Move data to a 3rd page * Clean up sizes and strings. Size is only verified on 40mm * Second pass at getting chart size correct * Use platter visual effect for data page * Fix chart sizes for all watch variants * Fix issues in the code that saves the start page * Remember and restore the graph visible duration * Switch back to 2 pages and put the data under the graph * First attempt at a hybrid mode. It's a little cluttered * Show Active Insulin at the bottom of the graph, get rid of custom scrolling code * Fix dangling outlet; adjust the size of graph on 42mm * Remove IOB & COB text from labels * Use screen bound ranges to identify watch models; Add more top margin to the graph * minor cleanups * Make the spacing from the top of the graph to the baseline of the glucose text roughly equivalent to the spacing between the cap line of the glucose text and the baseline of the title. * Layout and alignment tweaks --- .../Base.lproj/Localizable.strings | 3 +- .../Controllers/ChartHUDController.swift | 87 +++--- .../Controllers/HUDInterfaceController.swift | 6 - .../Extensions/NSUserDefaults+WatchApp.swift | 13 + .../Extensions/WatchContext+WatchApp.swift | 27 +- .../Scenes/GlucoseChartScene.swift | 33 ++- WatchApp/Base.lproj/Interface.storyboard | 274 ++++++++++-------- 7 files changed, 253 insertions(+), 190 deletions(-) diff --git a/WatchApp Extension/Base.lproj/Localizable.strings b/WatchApp Extension/Base.lproj/Localizable.strings index b26c9f6d64..a6fae0a4f3 100644 --- a/WatchApp Extension/Base.lproj/Localizable.strings +++ b/WatchApp Extension/Base.lproj/Localizable.strings @@ -37,4 +37,5 @@ /* The short unit display string for international units of insulin */ "U" = "U"; - +/* The short unit display string for international units of insulin delivery per hour */ +"U/hr" = "U/hr"; diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index 561053dffa..c85841e19a 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -10,21 +10,23 @@ import WatchKit import WatchConnectivity import CGMBLEKit import LoopKit +import HealthKit import SpriteKit import os.log final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { - @IBOutlet weak var basalLabel: WKInterfaceLabel! - @IBOutlet weak var iobLabel: WKInterfaceLabel! - @IBOutlet weak var cobLabel: WKInterfaceLabel! - @IBOutlet weak var glucoseScene: WKInterfaceSKScene! - @IBAction func setChartWindow1Hour() { + @IBOutlet private weak var tableGroup: WKInterfaceGroup! + @IBOutlet private weak var basalLabel: WKInterfaceLabel! + @IBOutlet private weak var iobLabel: WKInterfaceLabel! + @IBOutlet private weak var cobLabel: WKInterfaceLabel! + @IBOutlet private weak var glucoseScene: WKInterfaceSKScene! + @IBAction private func setChartWindow1Hour() { scene.visibleDuration = .hours(2) } - @IBAction func setChartWindow2Hours() { + @IBAction private func setChartWindow2Hours() { scene.visibleDuration = .hours(4) } - @IBAction func setChartWindow3Hours() { + @IBAction private func setChartWindow3Hours() { scene.visibleDuration = .hours(6) } private let scene = GlucoseChartScene() @@ -74,6 +76,7 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { if #available(watchOSApplicationExtension 5.0, *) { scene.textInsets.left = max(scene.textInsets.left, systemMinimumLayoutMargins.leading) scene.textInsets.right = max(scene.textInsets.right, systemMinimumLayoutMargins.trailing) + tableGroup.setContentInset(UIEdgeInsets(top: 0, left: systemMinimumLayoutMargins.leading, bottom: 0, right: systemMinimumLayoutMargins.trailing)) } } @@ -119,58 +122,44 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { return } - let insulinFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = 1 - numberFormatter.maximumFractionDigits = 1 - - return numberFormatter - }() - - iobLabel.setHidden(true) - if let activeInsulin = activeContext.iob, let valueStr = insulinFormatter.string(from: activeInsulin) { - iobLabel.setText(String(format: NSLocalizedString( - "IOB %1$@ U", - comment: "The subtitle format describing units of active insulin. (1: localized insulin value description)" - ), - valueStr - )) - iobLabel.setHidden(false) + if let activeInsulin = activeContext.activeInsulin { + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter() + insulinFormatter.numberFormatter.minimumFractionDigits = 1 + insulinFormatter.numberFormatter.maximumFractionDigits = 1 + + return insulinFormatter + }() + + iobLabel.setText(insulinFormatter.string(from: activeInsulin, for: .internationalUnit())) + } else { + iobLabel.setText("—") } - cobLabel.setHidden(true) - if let carbsOnBoard = activeContext.cob { - let carbFormatter = NumberFormatter() - carbFormatter.numberStyle = .decimal - carbFormatter.maximumFractionDigits = 0 - let valueStr = carbFormatter.string(from: carbsOnBoard) - - cobLabel.setText(String(format: NSLocalizedString( - "COB %1$@ g", - comment: "The subtitle format describing grams of active carbs. (1: localized carb value description)" - ), - valueStr! - )) - cobLabel.setHidden(false) + if let carbsOnBoard = activeContext.activeCarbohydrates { + let carbFormatter = QuantityFormatter() + carbFormatter.numberFormatter.maximumFractionDigits = 0 + + cobLabel.setText(carbFormatter.string(from: carbsOnBoard, for: .gram())) + } else { + cobLabel.setText("—") } - basalLabel.setHidden(true) if let tempBasal = activeContext.lastNetTempBasalDose { let basalFormatter = NumberFormatter() basalFormatter.numberStyle = .decimal basalFormatter.minimumFractionDigits = 1 basalFormatter.maximumFractionDigits = 3 basalFormatter.positivePrefix = basalFormatter.plusSign - let valueStr = basalFormatter.string(from: tempBasal) - - let basalLabelText = String(format: NSLocalizedString( - "%1$@ U/hr", - comment: "The subtitle format describing the current temp basal rate. (1: localized basal rate description)"), - valueStr!) - basalLabel.setText(basalLabelText) - basalLabel.setHidden(false) + + let unit = NSLocalizedString( + "U/hr", + comment: "The short unit display string for international units of insulin delivery per hour" + ) + + basalLabel.setText(basalFormatter.string(from: tempBasal, unit: unit)) + } else { + basalLabel.setText("—") } if glucoseScene.isPaused { diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index efb35dc50c..ab1b62a381 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -12,7 +12,6 @@ class HUDInterfaceController: WKInterfaceController { private var activeContextObserver: NSObjectProtocol? @IBOutlet weak var loopHUDImage: WKInterfaceImage! - @IBOutlet weak var loopTimer: WKInterfaceTimer! @IBOutlet weak var glucoseLabel: WKInterfaceLabel! @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! @@ -46,14 +45,9 @@ class HUDInterfaceController: WKInterfaceController { let date = activeContext.loopLastRunDate else { loopHUDImage.setLoopImage(.unknown) - loopTimer.setHidden(true) return } - loopTimer.setDate(date) - loopTimer.setHidden(false) - loopTimer.start() - glucoseLabel.setHidden(true) eventualGlucoseLabel.setHidden(true) if let glucose = activeContext.glucose, let unit = activeContext.preferredGlucoseUnit { diff --git a/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift index 012d29b1f2..301a4964da 100644 --- a/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift +++ b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift @@ -12,6 +12,7 @@ import Foundation extension UserDefaults { private enum Key: String { case StartOnChartPage = "com.loudnate.Naterade.StartOnChartPage" + case VisibleDuration = "com.loudnate.Naterade.VisibleDuration" } var startOnChartPage: Bool { @@ -22,4 +23,16 @@ extension UserDefaults { set(newValue, forKey: Key.StartOnChartPage.rawValue) } } + + var visibleDuration: TimeInterval { + get { + if let value = object(forKey: Key.VisibleDuration.rawValue) as? TimeInterval { + return value + } + return TimeInterval (hours: 6) + } + set { + set(newValue.rawValue, forKey: Key.VisibleDuration.rawValue) + } + } } diff --git a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift index 9212c5068e..bc38ac52c7 100644 --- a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift +++ b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift @@ -7,16 +7,31 @@ // import Foundation +import HealthKit import LoopKit extension WatchContext { var glucoseTrend: GlucoseTrend? { - get { - if let glucoseTrendRawValue = glucoseTrendRawValue { - return GlucoseTrend(rawValue: glucoseTrendRawValue) - } else { - return nil - } + if let glucoseTrendRawValue = glucoseTrendRawValue { + return GlucoseTrend(rawValue: glucoseTrendRawValue) + } else { + return nil } } + + var activeInsulin: HKQuantity? { + guard let value = iob else { + return nil + } + + return HKQuantity(unit: .internationalUnit(), doubleValue: value) + } + + var activeCarbohydrates: HKQuantity? { + guard let value = cob else { + return nil + } + + return HKQuantity(unit: .gram(), doubleValue: value) + } } diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 16b22d241b..f84fb5065e 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -43,6 +43,14 @@ private extension SKLabelNode { basic.zPosition = NodePlane.labels.zPosition return basic } + + func move(to position: CGPoint) { + guard !self.position.equalTo(position) else { + return + } + + self.position = position + } } private extension SKSpriteNode { @@ -140,7 +148,11 @@ private extension HKUnit { class GlucoseChartScene: SKScene { let log = OSLog(category: "GlucoseChartScene") - var textInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + var textInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) { + didSet { + setNeedsUpdate() + } + } var unit: HKUnit? var correctionRange: GlucoseRangeSchedule? @@ -190,7 +202,9 @@ class GlucoseChartScene: SKScene { max = Swift.max(max, predictedGlucoseRange.upperBound.doubleValue(for: unit)) } - min = min.floored(to: unit.axisIncrement) + // Predicted glucose values can be below a concentration of 0, + // but we want to let those fall off the graph since it's technically impossible + min = Swift.max(0, min.floored(to: unit.axisIncrement)) max = max.ceiled(to: unit.axisIncrement) let lowerBound = HKQuantity(unit: unit, doubleValue: min) @@ -199,9 +213,10 @@ class GlucoseChartScene: SKScene { return lowerBound.. 180: // 44mm - height = 106 + height = 111 default: - height = 86 + height = 90 } super.init(size: CGSize(width: screen.width, height: height)) @@ -341,8 +355,11 @@ class GlucoseChartScene: SKScene { let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) minBGLabel.text = numberFormatter.string(from: yRange.lowerBound.doubleValue(for: unit)) + minBGLabel.move(to: CGPoint(x: size.width - textInsets.right, y: textInsets.bottom)) maxBGLabel.text = numberFormatter.string(from: yRange.upperBound.doubleValue(for: unit)) + maxBGLabel.move(to: CGPoint(x: size.width - textInsets.right, y: size.height - textInsets.top)) hoursLabel.text = dateFormatter.string(from: visibleDuration) + hoursLabel.move(to: CGPoint(x: textInsets.left, y: size.height - textInsets.top)) // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end var inactiveNodes = nodes diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard index 23d08d8cf7..c5473c1cde 100644 --- a/WatchApp/Base.lproj/Interface.storyboard +++ b/WatchApp/Base.lproj/Interface.storyboard @@ -1,12 +1,12 @@ - + - - + + @@ -130,17 +130,13 @@ - + - @@ -157,113 +153,119 @@ - - + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - + - - + + + + + + + + + + + + + + - @@ -352,17 +354,13 @@ - + - @@ -379,33 +377,68 @@ - - + - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + @@ -427,19 +460,20 @@ + - - + + - + - + - + From 7a936d3637c9e472aeb9e58f6c681cec41a69303 Mon Sep 17 00:00:00 2001 From: mpangburn Date: Tue, 8 Jan 2019 17:29:38 -0800 Subject: [PATCH 8/8] Series 4 complication support --- Loop.xcodeproj/project.pbxproj | 41 +++- .../Base.lproj/ckcomplication.strings | 6 + .../ComplicationController.swift | 177 ++++++++++++++--- .../Controllers/ChartHUDController.swift | 14 +- WatchApp Extension/Extensions/CGRect.swift | 23 +++ .../Extensions/CLKComplicationTemplate.swift | 115 +++++------ .../Extensions/CLKTextProvider+Compound.h | 20 ++ .../Extensions/CLKTextProvider+Compound.m | 33 ++++ .../WatchApp Extension-Bridging-Header.h | 5 + WatchApp Extension/Info.plist | 1 + .../Managers/ComplicationChartManager.swift | 130 +++++++++++++ .../Managers/LoopDataManager.swift | 19 ++ .../Models/GlucoseChartData.swift | 101 ++++++++++ .../Models/GlucoseChartScaler.swift | 95 +++++++++ .../Scenes/GlucoseChartScene.swift | 182 +++--------------- 15 files changed, 703 insertions(+), 259 deletions(-) create mode 100644 WatchApp Extension/Extensions/CGRect.swift create mode 100644 WatchApp Extension/Extensions/CLKTextProvider+Compound.h create mode 100644 WatchApp Extension/Extensions/CLKTextProvider+Compound.m create mode 100644 WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h create mode 100644 WatchApp Extension/Managers/ComplicationChartManager.swift create mode 100644 WatchApp Extension/Models/GlucoseChartData.swift create mode 100644 WatchApp Extension/Models/GlucoseChartScaler.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f38db71858..70e97baa70 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -290,6 +290,11 @@ 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7D7076681FE0702F004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D70766A1FE0702F004AC8EA /* InfoPlist.strings */; }; 894F71E21FFEC4D8007D365C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 894F71E11FFEC4D8007D365C /* Assets.xcassets */; }; + 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */; }; + 898ECA61218ABD17001E9D35 /* GlucoseChartData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */; }; + 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */; }; + 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA64218ABD9A001E9D35 /* CGRect.swift */; }; + 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */; }; C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */; }; C10B28461EA9BA5E006EA1FC /* far_future_high_bg_forecast.json in Resources */ = {isa = PBXBuildFile; fileRef = C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */; }; C11C87DE1E21EAAD00BB71D3 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; @@ -823,6 +828,13 @@ 7DD382781F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainInterface.strings; sourceTree = ""; }; 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 894F71E11FFEC4D8007D365C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartScaler.swift; sourceTree = ""; }; + 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartData.swift; sourceTree = ""; }; + 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComplicationChartManager.swift; sourceTree = ""; }; + 898ECA64218ABD9A001E9D35 /* CGRect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGRect.swift; sourceTree = ""; }; + 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WatchApp Extension-Bridging-Header.h"; sourceTree = ""; }; + 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CLKTextProvider+Compound.m"; sourceTree = ""; }; + 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CLKTextProvider+Compound.h"; sourceTree = ""; }; C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NightscoutUploadKit.framework; path = Carthage/Build/iOS/NightscoutUploadKit.framework; sourceTree = SOURCE_ROOT; }; C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = far_future_high_bg_forecast.json; sourceTree = ""; }; C12F21A61DFA79CB00748193 /* recommend_temp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_very_low_end_in_range.json; sourceTree = ""; }; @@ -933,7 +945,10 @@ isa = PBXGroup; children = ( 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, + 898ECA64218ABD9A001E9D35 /* CGRect.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, + 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, + 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, 4F7E8AC420E2AB9600AEA65E /* Date.swift */, 4F73F5FB20E2E7FA00E8D82C /* GlucoseStore.swift */, 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */, @@ -944,6 +959,7 @@ 43CB2B2A1D924D450079823D /* WCSession.swift */, 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */, 4328E02E1CFBF81800E199AA /* WKInterfaceImage.swift */, + 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */, ); path = Extensions; sourceTree = ""; @@ -1065,6 +1081,7 @@ 4328E01F1CFBE2B100E199AA /* Extensions */, 4FE3475F20D5D7FA00A86D03 /* Managers */, 4F75F0052100146B00B5570E /* Scenes */, + 898ECA5D218ABD17001E9D35 /* Models */, 43A943831B926B7B0051FA24 /* Supporting Files */, ); path = "WatchApp Extension"; @@ -1304,6 +1321,7 @@ isa = PBXGroup; children = ( 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */, + 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */, ); path = Managers; sourceTree = ""; @@ -1370,6 +1388,15 @@ path = Common; sourceTree = ""; }; + 898ECA5D218ABD17001E9D35 /* Models */ = { + isa = PBXGroup; + children = ( + 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */, + 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */, + ); + path = Models; + sourceTree = ""; + }; 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1598,7 +1625,7 @@ }; 43A9437D1B926B7B0051FA24 = { CreatedOnToolsVersion = 7.0; - LastSwiftMigration = 0800; + LastSwiftMigration = 1000; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 0; @@ -1966,6 +1993,7 @@ 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */, 435400311C9F744E00D5819C /* BolusSuggestionUserInfo.swift in Sources */, 4372E49A213F7A830068E043 /* WalshInsulinModel.swift in Sources */, + 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */, 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */, 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */, @@ -1974,10 +2002,12 @@ 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */, 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */, 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */, + 898ECA61218ABD17001E9D35 /* GlucoseChartData.swift in Sources */, 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */, 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */, 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */, 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, + 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */, 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */, 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */, 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */, @@ -1994,9 +2024,11 @@ 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */, + 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */, 4372E48C213CB6750068E043 /* Double.swift in Sources */, 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */, 4372E48E213CF8AD0068E043 /* LoopSettings.swift in Sources */, + 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */, 4372E49B213F7B340068E043 /* NSBundle.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, 4372E497213F79F90068E043 /* NSUserDefaults.swift in Sources */, @@ -2645,6 +2677,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; @@ -2657,6 +2690,9 @@ PROVISIONING_PROFILE = ""; SDKROOT = watchos; SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = 4; }; name = Debug; @@ -2665,6 +2701,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; @@ -2677,6 +2714,8 @@ PROVISIONING_PROFILE = ""; SDKROOT = watchos; SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = 4; }; name = Release; diff --git a/WatchApp Extension/Base.lproj/ckcomplication.strings b/WatchApp Extension/Base.lproj/ckcomplication.strings index a859782678..f4d1b4d52c 100644 --- a/WatchApp Extension/Base.lproj/ckcomplication.strings +++ b/WatchApp Extension/Base.lproj/ckcomplication.strings @@ -6,5 +6,11 @@ Copyright © 2016 Nathan Racklyeft. All rights reserved. */ +/* The complication template example glucose and trend string */ +"120↘︎" = "120↘︎"; + +/* The complication template example glucose string */ +"120" = "120"; + /* Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time) */ "UtilitarianLargeFlat" = "%@%@ %@"; diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index fc9aeeb9ce..ca41ba2b33 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -40,25 +40,61 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Population - private lazy var formatter = NumberFormatter() + private let chartManager = ComplicationChartManager() - func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { - let entry: CLKComplicationTimelineEntry? - - if let context = ExtensionDelegate.shared().loopManager.activeContext, - let glucoseDate = context.glucoseDate, - glucoseDate.timeIntervalSinceNow.minutes >= -15, - let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context) - { - template.tintColor = UIColor.tintColor - entry = CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template) - } else if let image = CLKComplicationTemplate.imageTemplate(for: complication.family) { - entry = CLKComplicationTimelineEntry(date: Date(), complicationTemplate: image) - } else { - entry = nil + private func updateChartManagerIfNeeded(completion: @escaping () -> Void) { + if #available(watchOSApplicationExtension 5.0, *) { + guard + let activeComplications = CLKComplicationServer.sharedInstance().activeComplications, + activeComplications.contains(where: { $0.family == .graphicRectangular }) + else { + completion() + return + } + + ExtensionDelegate.shared().loopManager.generateChartData { chartData in + self.chartManager.data = chartData + completion() + } } + } - handler(entry) + var makeChart: () -> UIImage? { + // c.f. https://developer.apple.com/design/human-interface-guidelines/watchos/icons-and-images/complication-images/ + let size: CGSize = { + let scaleFactor = 1 / WKInterfaceDevice.current().screenScale + switch WKInterfaceDevice.current().screenBounds.width { + case 162: // 40mm + return CGSize(width: 150.0 * scaleFactor, height: 47.0 * scaleFactor) + default /* case 184 */: // 44mm + return CGSize(width: 171.0 * scaleFactor, height: 54.0 * scaleFactor) + } + }() + return { [chartManager] in chartManager.renderChartImage(size: size) } + } + + func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { + updateChartManagerIfNeeded { + let entry: CLKComplicationTimelineEntry? + + if let context = ExtensionDelegate.shared().loopManager.activeContext, + let glucoseDate = context.glucoseDate, + glucoseDate.timeIntervalSinceNow.minutes >= -15, + let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, chartGenerator: self.makeChart) + { + switch complication.family { + case .graphicRectangular: + break + default: + template.tintColor = .tintColor + } + entry = CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template) + } else { + entry = nil + } + + handler(entry) + } } func getTimelineEntries(for complication: CLKComplication, before date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { @@ -67,28 +103,107 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { - // Call the handler with the timeline entries after to the given date - let entries: [CLKComplicationTimelineEntry]? - - if let context = ExtensionDelegate.shared().loopManager.activeContext, - let glucoseDate = context.glucoseDate - { - if glucoseDate.timeIntervalSince(date) > 0, - let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context) + updateChartManagerIfNeeded { + let entries: [CLKComplicationTimelineEntry]? + + if let context = ExtensionDelegate.shared().loopManager.activeContext, + let glucoseDate = context.glucoseDate, + glucoseDate.timeIntervalSince(date) > 0, + let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, chartGenerator: self.makeChart) { template.tintColor = UIColor.tintColor entries = [CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template)] } else { - entries = [] + entries = nil } - if let image = CLKComplicationTemplate.imageTemplate(for: complication.family) { - entries?.append(CLKComplicationTimelineEntry(date: glucoseDate.addingTimeInterval(.hours(1)), complicationTemplate: image)) - } - } else { - entries = nil + handler(entries) } + } + + // MARK: - Placeholder Templates - handler(entries) + func getLocalizableSampleTemplate(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void) { + let template = getLocalizableSampleTemplate(for: complication.family) + handler(template) + } + + func getLocalizableSampleTemplate(for family: CLKComplicationFamily) -> CLKComplicationTemplate? { + let glucoseAndTrendText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "120↘︎") + let glucoseText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "120") + let timeText = CLKRelativeDateTextProvider(date: Date(), style: .natural, units: .minute) + + switch family { + case .modularSmall: + let template = CLKComplicationTemplateModularSmallStackText() + template.line1TextProvider = glucoseAndTrendText + template.line2TextProvider = timeText + return template + case .modularLarge: + let template = CLKComplicationTemplateModularLargeTallBody() + template.bodyTextProvider = glucoseAndTrendText + template.headerTextProvider = timeText + return template + case .circularSmall: + let template = CLKComplicationTemplateCircularSmallSimpleText() + template.textProvider = glucoseAndTrendText + return template + case .extraLarge: + let template = CLKComplicationTemplateExtraLargeStackText() + template.line1TextProvider = glucoseAndTrendText + template.line2TextProvider = timeText + return template + case .utilitarianSmall, .utilitarianSmallFlat: + let template = CLKComplicationTemplateUtilitarianSmallFlat() + template.textProvider = glucoseAndTrendText + return template + case .utilitarianLarge: + let template = CLKComplicationTemplateUtilitarianLargeFlat() + let eventualGlucoseText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "75") + template.textProvider = CLKSimpleTextProvider.localizableTextProvider(withStringsFileFormatKey: "UtilitarianLargeFlat", textProviders: [glucoseAndTrendText, eventualGlucoseText, CLKTimeTextProvider(date: Date())]) + return template + case .graphicCorner: + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicCornerStackText() + timeText.tintColor = .tintColor + template.innerTextProvider = timeText + template.outerTextProvider = glucoseAndTrendText + return template + } else { + return nil + } + case .graphicCircular: + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText() + template.centerTextProvider = glucoseText + template.bottomTextProvider = CLKSimpleTextProvider(text: "↘︎") + template.gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .tintColor, fillFraction: 1) + return template + } else { + return nil + } + case .graphicBezel: + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicBezelCircularText() + guard let circularTemplate = getLocalizableSampleTemplate(for: .graphicCircular) as? CLKComplicationTemplateGraphicCircular else { + fatalError("\(#function) invoked with .graphicCircular must return a subclass of CLKComplicationTemplateGraphicCircular") + } + template.circularTemplate = circularTemplate + template.textProvider = timeText + return template + } else { + return nil + } + case .graphicRectangular: + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicRectangularLargeImage() + // TODO: Better placeholder image here + template.imageProvider = CLKFullColorImageProvider(fullColorImage: UIImage()) + template.textProvider = glucoseAndTrendText + return template + } else { + return nil + } + } } } diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index c85841e19a..b182073ed5 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -170,18 +170,10 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { updateGlucoseChart() } - private func updateGlucoseChart() { - guard let activeContext = loopManager.activeContext else { - return - } - - scene.predictedGlucose = activeContext.predictedGlucose?.values - scene.correctionRange = loopManager.settings.glucoseTargetRangeSchedule - scene.unit = activeContext.preferredGlucoseUnit - - loopManager.glucoseStore.getCachedGlucoseSamples(start: .earliestGlucoseCutoff) { (samples) in + func updateGlucoseChart() { + loopManager.generateChartData { chartData in DispatchQueue.main.async { - self.scene.historicalGlucose = samples + self.scene.data = chartData self.scene.setNeedsUpdate() } } diff --git a/WatchApp Extension/Extensions/CGRect.swift b/WatchApp Extension/Extensions/CGRect.swift new file mode 100644 index 0000000000..56d6a82fba --- /dev/null +++ b/WatchApp Extension/Extensions/CGRect.swift @@ -0,0 +1,23 @@ +// +// CGRect.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import CoreGraphics + + +extension CGRect { + func alignedToScreenScale(_ screenScale: CGFloat) -> CGRect { + let factor = 1 / screenScale + + return CGRect( + x: origin.x.floored(to: factor), + y: origin.y.floored(to: factor), + width: size.width.ceiled(to: factor), + height: size.height.ceiled(to: factor) + ) + } +} diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index c8f636d34e..debd12c2c4 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -14,15 +14,23 @@ import Foundation extension CLKComplicationTemplate { - static func templateForFamily(_ family: CLKComplicationFamily, from context: WatchContext) -> CLKComplicationTemplate? { + static func templateForFamily(_ family: CLKComplicationFamily, from context: WatchContext, chartGenerator makeChart: () -> UIImage?) -> CLKComplicationTemplate? { guard let glucose = context.glucose, let unit = context.preferredGlucoseUnit else { return nil } - return templateForFamily(family, glucose: glucose, unit: unit, date: context.glucoseDate, trend: context.glucoseTrend, eventualGlucose: context.eventualGlucose) + return templateForFamily(family, glucose: glucose, unit: unit, date: context.glucoseDate, trend: context.glucoseTrend, eventualGlucose: context.eventualGlucose, chartGenerator: makeChart) } - static func templateForFamily(_ family: CLKComplicationFamily, glucose: HKQuantity, unit: HKUnit, date: Date?, trend: GlucoseTrend?, eventualGlucose: HKQuantity?) -> CLKComplicationTemplate? { + static func templateForFamily( + _ family: CLKComplicationFamily, + glucose: HKQuantity, + unit: HKUnit, + date: Date?, + trend: GlucoseTrend?, + eventualGlucose: HKQuantity?, + chartGenerator makeChart: () -> UIImage? + ) -> CLKComplicationTemplate? { let formatter = NumberFormatter.glucoseFormatter(for: unit) @@ -32,7 +40,8 @@ extension CLKComplicationTemplate { return nil } - let glucoseAndTrend = "\(glucoseString)\(trend?.symbol ?? " ")" + let trendString = trend?.symbol ?? " " + let glucoseAndTrend = "\(glucoseString)\(trendString)" var accessibilityStrings = [glucoseString] if let trend = trend { @@ -47,8 +56,6 @@ extension CLKComplicationTemplate { timeFormatter.timeStyle = .short switch family { - case .graphicCorner, .graphicCircular, .graphicRectangular, .graphicBezel: - return nil case .modularSmall: let template = CLKComplicationTemplateModularSmallStackText() template.line1TextProvider = glucoseAndTrendText @@ -71,7 +78,6 @@ extension CLKComplicationTemplate { case .utilitarianSmall, .utilitarianSmallFlat: let template = CLKComplicationTemplateUtilitarianSmallFlat() template.textProvider = CLKSimpleTextProvider(text: glucoseString) - return template case .utilitarianLarge: var eventualGlucoseText = "" @@ -91,65 +97,50 @@ extension CLKComplicationTemplate { ] )) return template - } - } - - static func imageTemplate(for family: CLKComplicationFamily) -> CLKComplicationTemplate? { - switch family { - case .modularSmall: - guard let image = UIImage(named: "Modular") else { return nil } - - let template = CLKComplicationTemplateModularSmallSimpleImage() - template.imageProvider = CLKImageProvider(onePieceImage: image) - return template - case .modularLarge: - return nil - case .utilitarianSmall: - guard let image = UIImage(named: "Utility") else { return nil } - - let template = CLKComplicationTemplateUtilitarianSmallSquare() - template.imageProvider = CLKImageProvider(onePieceImage: image) - return template - case .utilitarianSmallFlat: - return nil - case .utilitarianLarge: - return nil - case .circularSmall: - guard let image = UIImage(named: "Circular") else { return nil } - - let template = CLKComplicationTemplateCircularSmallSimpleImage() - template.imageProvider = CLKImageProvider(onePieceImage: image) - return template - case .extraLarge: - guard let image = UIImage(named: "Extra Large") else { return nil } - - let template = CLKComplicationTemplateExtraLargeSimpleImage() - template.imageProvider = CLKImageProvider(onePieceImage: image) - return template case .graphicCorner: - guard #available(watchOSApplicationExtension 5.0, *), let image = UIImage(named: "Graphic Corner") else { return nil } - - let template = CLKComplicationTemplateGraphicCornerCircularImage() - template.imageProvider = CLKFullColorImageProvider(fullColorImage: image) - return template - case .graphicBezel: - guard #available(watchOSApplicationExtension 5.0, *), let image = UIImage(named: "Graphic Bezel") else { return nil } - - let template = CLKComplicationTemplateGraphicBezelCircularText() - template.circularTemplate = { - let template = CLKComplicationTemplateGraphicCircularImage() - template.imageProvider = CLKFullColorImageProvider(fullColorImage: image) + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicCornerStackText() + timeText.tintColor = .tintColor + template.innerTextProvider = timeText + template.outerTextProvider = glucoseAndTrendText return template - }() - return template + } else { + return nil + } case .graphicCircular: - guard #available(watchOSApplicationExtension 5.0, *), let image = UIImage(named: "Graphic Circular") else { return nil } - - let template = CLKComplicationTemplateGraphicCircularImage() - template.imageProvider = CLKFullColorImageProvider(fullColorImage: image) - return template + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText() + template.centerTextProvider = CLKSimpleTextProvider(text: glucoseString) + template.bottomTextProvider = CLKSimpleTextProvider(text: trendString) + template.gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .tintColor, fillFraction: 1) + return template + } else { + return nil + } + case .graphicBezel: + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicBezelCircularText() + guard + let circularTemplate = templateForFamily(.graphicCircular, glucose: glucose, unit: unit, date: date, trend: trend, eventualGlucose: eventualGlucose, chartGenerator: makeChart) as? CLKComplicationTemplateGraphicCircular + else { + fatalError("\(#function) invoked with .graphicCircular must return a subclass of CLKComplicationTemplateGraphicCircular") + } + template.circularTemplate = circularTemplate + template.textProvider = timeText + return template + } else { + return nil + } case .graphicRectangular: - return nil + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicRectangularLargeImage() + template.imageProvider = CLKFullColorImageProvider(fullColorImage: makeChart() ?? UIImage()) + timeText.tintColor = .tintColor + template.textProvider = CLKTextProvider(byJoining: [glucoseAndTrendText, timeText], separator: " ") + return template + } else { + return nil + } } } } diff --git a/WatchApp Extension/Extensions/CLKTextProvider+Compound.h b/WatchApp Extension/Extensions/CLKTextProvider+Compound.h new file mode 100644 index 0000000000..8da6380afd --- /dev/null +++ b/WatchApp Extension/Extensions/CLKTextProvider+Compound.h @@ -0,0 +1,20 @@ +// +// CLKTextProvider+Compound.h +// Loop +// +// Created by Michael Pangburn on 10/27/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +#ifndef CLKTextProvider_Compound_h +#define CLKTextProvider_Compound_h + +#import + +@interface CLKTextProvider (Compound) + ++ (CLKTextProvider *)textProviderByJoiningTextProviders: (nonnull NSArray *)textProviders separator:(nullable NSString *) separator; + +@end + +#endif /* CLKTextProvider_Compound_h */ diff --git a/WatchApp Extension/Extensions/CLKTextProvider+Compound.m b/WatchApp Extension/Extensions/CLKTextProvider+Compound.m new file mode 100644 index 0000000000..37d147361f --- /dev/null +++ b/WatchApp Extension/Extensions/CLKTextProvider+Compound.m @@ -0,0 +1,33 @@ +// +// CLKTextProvider+Compound.m +// WatchApp Extension +// +// Created by Michael Pangburn on 10/27/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +#import "CLKTextProvider+Compound.h" + +// CLKTextProvider.textProviderWithFormat (compound text provider creation) is unavailable in Swift. +// c.f. https://crunchybagel.com/using-multicolour-clktextprovider-in-swift-in-watchos-5/ +@implementation CLKTextProvider (Compound) + ++ (CLKTextProvider *)textProviderByJoiningTextProviders: (nonnull NSArray *)textProviders separator:(nullable NSString *) separator { + + NSString *formatString = @"%@%@"; + + if (separator.length > 0) { + formatString = [NSString stringWithFormat:@"%@%@%@", @"%@", separator, @"%@"]; + } + + CLKTextProvider *firstItem = textProviders.firstObject; + + for (int index = 1; index < textProviders.count; index++) { + CLKTextProvider *secondItem = [textProviders objectAtIndex: index]; + firstItem = [CLKTextProvider textProviderWithFormat:formatString, firstItem, secondItem]; + } + + return firstItem; +} + +@end diff --git a/WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h b/WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h new file mode 100644 index 0000000000..35a90b80f3 --- /dev/null +++ b/WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h @@ -0,0 +1,5 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +#import "CLKTextProvider+Compound.h" diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist index ece73b3ce7..143b68353c 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -31,6 +31,7 @@ CLKComplicationFamilyGraphicBezel CLKComplicationFamilyGraphicCircular CLKComplicationFamilyGraphicCorner + CLKComplicationFamilyGraphicRectangular CLKComplicationFamilyModularLarge CLKComplicationFamilyModularSmall CLKComplicationFamilyUtilitarianLarge diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift new file mode 100644 index 0000000000..54619f6bb9 --- /dev/null +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -0,0 +1,130 @@ +// +// ComplicationChartManager.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import UIKit +import HealthKit +import WatchKit + + +private enum ComplicationChartConstants { + static let textInsets = UIEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) + static let glucoseSize = CGSize(width: 1, height: 1) + static let glucoseLabelAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont(name: "HelveticaNeue", size: 5)!, + .foregroundColor: UIColor.chartLabel + ] +} + +private enum GlucoseLabelPosition { + case high + case low +} + +final class ComplicationChartManager { + var data: GlucoseChartData? + var lastRenderDate: Date? + var renderedChartImage: UIImage? + var visibleInterval: TimeInterval = .hours(4) + + private var unit: HKUnit { + return data?.unit ?? .milligramsPerDeciliter + } + + func renderChartImage(size: CGSize) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + defer { UIGraphicsEndImageContext() } + + let context = UIGraphicsGetCurrentContext()! + drawChart(in: context, size: size) + let image = context.makeImage().map(UIImage.init(cgImage:)) + renderedChartImage = image + return image + } + + private func drawChart(in context: CGContext, size: CGSize) { + guard let data = data else { + // TODO: handle empty data case + return + } + + let now = Date() + lastRenderDate = now + let spannedInterval = DateInterval(start: now - visibleInterval / 2, duration: visibleInterval) + let glucoseRange = data.chartableGlucoseRange(from: spannedInterval) + let scaler = GlucoseChartScaler(size: size, dateInterval: spannedInterval, glucoseRange: glucoseRange, unit: unit) + + let drawingSteps = [drawTargetRange, drawOverridesIfNeeded, drawHistoricalGlucose, drawPredictedGlucose, drawGlucoseLabels] + drawingSteps.forEach { drawIn in drawIn(context, scaler) } + } + + private func drawGlucoseLabels(in context: CGContext, using scaler: GlucoseChartScaler) { + let formatter = NumberFormatter.glucoseFormatter(for: unit) + drawGlucoseLabelText(formatter.string(from: scaler.glucoseMax)!, position: .high, scaler: scaler) + drawGlucoseLabelText(formatter.string(from: scaler.glucoseMin)!, position: .low, scaler: scaler) + } + + private func drawGlucoseLabelText(_ text: String, position: GlucoseLabelPosition, scaler: GlucoseChartScaler) { + let attributedText = NSAttributedString(string: text, attributes: ComplicationChartConstants.glucoseLabelAttributes) + let size = attributedText.size() + let x = scaler.xCoordinate(for: scaler.dates.end) - size.width - ComplicationChartConstants.textInsets.right + let y: CGFloat = { + switch position { + case .high: + return scaler.yCoordinate(for: scaler.glucoseMax) + ComplicationChartConstants.textInsets.top + case .low: + return scaler.yCoordinate(for: scaler.glucoseMin) - size.height - ComplicationChartConstants.textInsets.bottom + } + }() + let rect = CGRect(origin: CGPoint(x: x, y: y), size: size).alignedToScreenScale(WKInterfaceDevice.current().screenScale) + attributedText.draw(with: rect, options: .usesLineFragmentOrigin, context: nil) + } + + private func drawTargetRange(in context: CGContext, using scaler: GlucoseChartScaler) { + let activeOverride = data?.correctionRange?.activeOverride + let targetRangeAlpha: CGFloat = activeOverride != nil ? 0.2 : 0.3 + context.setFillColor(UIColor.glucose.withAlphaComponent(targetRangeAlpha).cgColor) + data?.correctionRange?.quantityBetween(start: scaler.dates.start, end: scaler.dates.end).forEach { range in + let rangeRect = scaler.rect(for: range, unit: unit) + context.fill(rangeRect) + } + } + + private func drawOverridesIfNeeded(in context: CGContext, using scaler: GlucoseChartScaler) { + guard let override = data?.correctionRange?.activeOverride else { + return + } + context.setFillColor(UIColor.glucose.withAlphaComponent(0.4).cgColor) + let overrideRect = scaler.rect(for: override, unit: unit) + context.fill(overrideRect) + } + + private func drawHistoricalGlucose(in context: CGContext, using scaler: GlucoseChartScaler) { + context.setFillColor(UIColor.glucose.cgColor) + data?.historicalGlucose?.lazy + .filter { scaler.dates.contains($0.startDate) } + .forEach { glucose in + let origin = scaler.point(for: glucose, unit: unit) + let glucoseRect = CGRect(origin: origin, size: ComplicationChartConstants.glucoseSize).alignedToScreenScale(WKInterfaceDevice.current().screenScale) + context.fill(glucoseRect) + } + } + + private func drawPredictedGlucose(in context: CGContext, using scaler: GlucoseChartScaler) { + guard let predictedGlucose = data?.predictedGlucose, predictedGlucose.count > 2 else { + return + } + let predictedPath = CGMutablePath() + let glucosePoints = predictedGlucose.map { scaler.point(for: $0, unit: unit) } + predictedPath.addLines(between: glucosePoints) + let dashedPath = predictedPath.copy(dashingWithPhase: 6.5, lengths: [2.5, 1.5]) + context.setStrokeColor(UIColor.white.cgColor) + context.addPath(dashedPath) + context.strokePath() + } +} diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 7f2b5f9e70..4191ced26f 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -124,3 +124,22 @@ extension LoopDataManager { return true } } + +extension LoopDataManager { + func generateChartData(completion: @escaping (GlucoseChartData?) -> Void) { + guard let activeContext = activeContext else { + completion(nil) + return + } + + glucoseStore.getCachedGlucoseSamples(start: .earliestGlucoseCutoff) { samples in + let chartData = GlucoseChartData( + unit: activeContext.preferredGlucoseUnit, + correctionRange: self.settings.glucoseTargetRangeSchedule, + historicalGlucose: samples, + predictedGlucose: activeContext.predictedGlucose?.values + ) + completion(chartData) + } + } +} diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift new file mode 100644 index 0000000000..6bf47c3670 --- /dev/null +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -0,0 +1,101 @@ +// +// GlucoseChartData.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + + +struct GlucoseChartData { + var unit: HKUnit? + + var correctionRange: GlucoseRangeSchedule? + + var historicalGlucose: [SampleValue]? { + didSet { + historicalGlucoseRange = historicalGlucose?.quantityRange + } + } + + private(set) var historicalGlucoseRange: Range? + + var predictedGlucose: [SampleValue]? { + didSet { + predictedGlucoseRange = predictedGlucose?.quantityRange + } + } + + private(set) var predictedGlucoseRange: Range? + + init(unit: HKUnit?, correctionRange: GlucoseRangeSchedule?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { + self.unit = unit + self.correctionRange = correctionRange + self.historicalGlucose = historicalGlucose + self.historicalGlucoseRange = historicalGlucose?.quantityRange + self.predictedGlucose = predictedGlucose + self.predictedGlucoseRange = predictedGlucose?.quantityRange + } + + func chartableGlucoseRange(from interval: DateInterval) -> Range { + let unit = self.unit ?? .milligramsPerDeciliter + + // Defaults + var min = unit.lowWatermark + var max = unit.highWatermark + + for correction in correctionRange?.quantityBetween(start: interval.start, end: interval.end) ?? [] { + min = Swift.min(min, correction.value.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, correction.value.upperBound.doubleValue(for: unit)) + } + + if let override = correctionRange?.activeOverrideQuantityRange { + min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, override.upperBound.doubleValue(for: unit)) + } + + if let historicalGlucoseRange = historicalGlucoseRange { + min = Swift.min(min, historicalGlucoseRange.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, historicalGlucoseRange.upperBound.doubleValue(for: unit)) + } + + if let predictedGlucoseRange = predictedGlucoseRange { + min = Swift.min(min, predictedGlucoseRange.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, predictedGlucoseRange.upperBound.doubleValue(for: unit)) + } + + min = min.floored(to: unit.axisIncrement) + max = max.ceiled(to: unit.axisIncrement) + + let lowerBound = HKQuantity(unit: unit, doubleValue: min) + let upperBound = HKQuantity(unit: unit, doubleValue: max) + + return lowerBound.. CGFloat { + return CGFloat(date.timeIntervalSince(dates.start)) * xScale + } + + func yCoordinate(for glucose: Double) -> CGFloat { + switch coordinateSystem { + case .standard: + return CGFloat(glucoseMax - glucose) * yScale + case .inverted: + return CGFloat(glucose - glucoseMin) * yScale + } + } + + func point(_ date: Date, _ glucose: Double) -> CGPoint { + return CGPoint(x: xCoordinate(for: date), y: yCoordinate(for: glucose)) + } + + func point(for glucose: SampleValue, unit: HKUnit) -> CGPoint { + return point(glucose.startDate, glucose.quantity.doubleValue(for: unit)) + } + + // By default enforce a minimum height so that the range is visible + func rect( + for range: GlucoseChartValueHashable, + unit: HKUnit, + minHeight: CGFloat = 2, + alignedToScreenScale screenScale: CGFloat = WKInterfaceDevice.current().screenScale + ) -> CGRect { + let minY: Double + let maxY: Double + + if unit != .milligramsPerDeciliter { + minY = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: range.min).doubleValue(for: unit) + maxY = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: range.max).doubleValue(for: unit) + } else { + minY = range.min + maxY = range.max + } + + let bottomLeft = point(max(dates.start, range.start), minY) + let topRight = point(min(dates.end, range.end), maxY) + let size = CGSize(width: topRight.x - bottomLeft.x, height: max(topRight.y - bottomLeft.y, minHeight)) + return CGRect(origin: bottomLeft, size: size).alignedToScreenScale(screenScale) + } +} + +extension GlucoseChartScaler { + init(size: CGSize, dateInterval: DateInterval, glucoseRange: Range, unit: HKUnit, coordinateSystem: CoordinateSystem = .standard) { + self.dates = dateInterval + self.glucoseMin = glucoseRange.lowerBound.doubleValue(for: unit) + self.glucoseMax = glucoseRange.upperBound.doubleValue(for: unit) + self.xScale = size.width / CGFloat(dateInterval.duration) + self.yScale = size.height / CGFloat(glucoseRange.span(with: unit)) + self.coordinateSystem = coordinateSystem + } +} + +extension Range where Bound == HKQuantity { + func span(with unit: HKUnit) -> Double { + return upperBound.doubleValue(for: unit) - lowerBound.doubleValue(for: unit) + } +} diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index f84fb5065e..b233401623 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -80,71 +80,6 @@ private extension SKSpriteNode { } } -extension CGRect { - fileprivate func alignedToScreenScale(_ screenScale: CGFloat) -> CGRect { - let factor = 1 / screenScale - - return CGRect( - x: origin.x.floored(to: factor), - y: origin.y.floored(to: factor), - width: size.width.ceiled(to: factor), - height: size.height.ceiled(to: factor) - ) - } -} - -private struct Scaler { - let dates: DateInterval - let glucoseMin: Double - let xScale: CGFloat - let yScale: CGFloat - - func point(_ x: Date, _ y: Double) -> CGPoint { - return CGPoint(x: CGFloat(x.timeIntervalSince(dates.start)) * xScale, y: CGFloat(y - glucoseMin) * yScale) - } - - // By default enforce a minimum height so that the range is visible - func rect(for range: GlucoseChartValueHashable, unit: HKUnit, minHeight: CGFloat = 2) -> CGRect { - let minY: Double - let maxY: Double - - if unit != .milligramsPerDeciliter { - minY = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: range.min).doubleValue(for: unit) - maxY = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: range.max).doubleValue(for: unit) - } else { - minY = range.min - maxY = range.max - } - - let a = point(max(dates.start, range.start), minY) - let b = point(min(dates.end, range.end), maxY) - let size = CGSize(width: b.x - a.x, height: max(b.y - a.y, minHeight)) - return CGRect(origin: CGPoint(x: a.x + size.width / 2, y: a.y + size.height / 2), size: size).alignedToScreenScale(WKInterfaceDevice.current().screenScale) - } -} - -private extension HKUnit { - var axisIncrement: Double { - return chartableIncrement * 25 - } - - var highWatermark: Double { - if self == .milligramsPerDeciliter { - return 150 - } else { - return 8 - } - } - - var lowWatermark: Double { - if self == .milligramsPerDeciliter { - return 50.0 - } else { - return 3.0 - } - } -} - class GlucoseChartScene: SKScene { let log = OSLog(category: "GlucoseChartScene") @@ -154,64 +89,15 @@ class GlucoseChartScene: SKScene { } } - var unit: HKUnit? - var correctionRange: GlucoseRangeSchedule? - var historicalGlucose: [SampleValue]? { + var data: GlucoseChartData? { didSet { - historicalGlucoseRange = historicalGlucose?.quantityRange - } - } - private(set) var historicalGlucoseRange: Range? - var predictedGlucose: [SampleValue]? { - didSet { - predictedGlucoseRange = predictedGlucose?.quantityRange - - if let firstNewValue = predictedGlucose?.first { - if oldValue?.first == nil || oldValue?.first!.startDate != firstNewValue.startDate { + if let firstNewValue = data?.predictedGlucose?.first { + if oldValue?.predictedGlucose?.first == nil || oldValue?.predictedGlucose?.first!.startDate != firstNewValue.startDate { shouldAnimatePredictionPath = true } } } } - private(set) var predictedGlucoseRange: Range? - - private func chartableGlucoseRange(from start: Date, to end: Date) -> Range { - let unit = self.unit ?? .milligramsPerDeciliter - - // Defaults - var min = unit.lowWatermark - var max = unit.highWatermark - - for correction in correctionRange?.quantityBetween(start: start, end: end) ?? [] { - min = Swift.min(min, correction.value.lowerBound.doubleValue(for: unit)) - max = Swift.max(max, correction.value.upperBound.doubleValue(for: unit)) - } - - if let override = correctionRange?.activeOverrideQuantityRange { - min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) - max = Swift.max(max, override.upperBound.doubleValue(for: unit)) - } - - if let historicalGlucoseRange = historicalGlucoseRange { - min = Swift.min(min, historicalGlucoseRange.lowerBound.doubleValue(for: unit)) - max = Swift.max(max, historicalGlucoseRange.upperBound.doubleValue(for: unit)) - } - - if let predictedGlucoseRange = predictedGlucoseRange { - min = Swift.min(min, predictedGlucoseRange.lowerBound.doubleValue(for: unit)) - max = Swift.max(max, predictedGlucoseRange.upperBound.doubleValue(for: unit)) - } - - // Predicted glucose values can be below a concentration of 0, - // but we want to let those fall off the graph since it's technically impossible - min = Swift.max(0, min.floored(to: unit.axisIncrement)) - max = max.ceiled(to: unit.axisIncrement) - - let lowerBound = HKQuantity(unit: unit, doubleValue: min) - let upperBound = HKQuantity(unit: unit, doubleValue: max) - - return lowerBound.. (sprite: SKSpriteNode, created: Bool) { - var created = false - if nodes[hashValue] == nil { - nodes[hashValue] = SKSpriteNode(color: .clear, size: CGSize(width: 0, height: 0)) - addChild(nodes[hashValue]!) - created = true + if let existingNode = nodes[hashValue] { + return (sprite: existingNode, created: false) + } else { + let newNode = SKSpriteNode(color: .clear, size: CGSize(width: 0, height: 0)) + newNode.anchorPoint = CGPoint(x: 0, y: 0) + nodes[hashValue] = newNode + addChild(newNode) + return (sprite: newNode, created: true) } - return (sprite: nodes[hashValue]!, created: created) } func setNeedsUpdate() { @@ -338,25 +218,18 @@ class GlucoseChartScene: SKScene { } private func performUpdate(animated: Bool) { - guard let unit = unit else { + guard let data = data, let unit = data.unit else { return } - let window = visibleDuration / 2 - let start = Date(timeIntervalSinceNow: -window) - let end = start.addingTimeInterval(visibleDuration) - let yRange = chartableGlucoseRange(from: start, to: end) - let scaler = Scaler( - dates: DateInterval(start: start, end: end), - glucoseMin: yRange.lowerBound.doubleValue(for: unit), - xScale: size.width / CGFloat(window * 2), - yScale: size.height / CGFloat(yRange.upperBound.doubleValue(for: unit) - yRange.lowerBound.doubleValue(for: unit)) - ) + let spannedInterval = DateInterval(start: Date() - visibleDuration / 2, duration: visibleDuration) + let glucoseRange = data.chartableGlucoseRange(from: spannedInterval) + let scaler = GlucoseChartScaler(size: size, dateInterval: spannedInterval, glucoseRange: glucoseRange, unit: unit, coordinateSystem: .inverted) let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) - minBGLabel.text = numberFormatter.string(from: yRange.lowerBound.doubleValue(for: unit)) + minBGLabel.text = numberFormatter.string(from: glucoseRange.lowerBound.doubleValue(for: unit)) minBGLabel.move(to: CGPoint(x: size.width - textInsets.right, y: textInsets.bottom)) - maxBGLabel.text = numberFormatter.string(from: yRange.upperBound.doubleValue(for: unit)) + maxBGLabel.text = numberFormatter.string(from: glucoseRange.upperBound.doubleValue(for: unit)) maxBGLabel.move(to: CGPoint(x: size.width - textInsets.right, y: size.height - textInsets.top)) hoursLabel.text = dateFormatter.string(from: visibleDuration) hoursLabel.move(to: CGPoint(x: textInsets.left, y: size.height - textInsets.top)) @@ -364,15 +237,15 @@ class GlucoseChartScene: SKScene { // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end var inactiveNodes = nodes - let activeOverride = correctionRange?.activeOverride + let activeOverride = data.correctionRange?.activeOverride - correctionRange?.quantityBetween(start: start, end: end).forEach({ (range) in + data.correctionRange?.quantityBetween(start: spannedInterval.start, end: spannedInterval.end).forEach { range in let (sprite, created) = getSprite(forHash: range.chartHashValue) sprite.color = UIColor.glucose.withAlphaComponent(activeOverride != nil ? 0.2 : 0.3) sprite.zPosition = NodePlane.ranges.zPosition sprite.move(to: scaler.rect(for: range, unit: unit), animated: !created) inactiveNodes.removeValue(forKey: range.chartHashValue) - }) + } // Make temporary overrides visually match what we do in the Loop app. This means that we have // one darker box which represents the duration of the override, but we have a second lighter box which @@ -384,8 +257,8 @@ class GlucoseChartScene: SKScene { sprite1.move(to: scaler.rect(for: range, unit: unit), animated: !created) inactiveNodes.removeValue(forKey: range.chartHashValue) - if range.end < end { - let extendedRange = GlucoseRangeSchedule.Override(context: range.context, start: range.start, end: end, value: range.value) + if range.end < spannedInterval.end { + let extendedRange = GlucoseRangeSchedule.Override(context: range.context, start: range.start, end: spannedInterval.end, value: range.value) let (sprite2, created) = getSprite(forHash: extendedRange.chartHashValue) sprite2.color = UIColor.glucose.withAlphaComponent(0.25) sprite2.zPosition = NodePlane.overrideRanges.zPosition @@ -394,9 +267,10 @@ class GlucoseChartScene: SKScene { } } - historicalGlucose?.filter { scaler.dates.contains($0.startDate) }.forEach { - let origin = scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) + data.historicalGlucose?.filter { scaler.dates.contains($0.startDate) }.forEach { + let center = scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) let size = CGSize(width: 2, height: 2) + let origin = CGPoint(x: center.x - size.width / 2, y: center.y - size.height / 2) let (sprite, created) = getSprite(forHash: $0.chartHashValue) sprite.color = .glucose sprite.zPosition = NodePlane.values.zPosition @@ -405,7 +279,7 @@ class GlucoseChartScene: SKScene { } predictedPathNode?.removeFromParent() - if let predictedGlucose = predictedGlucose, predictedGlucose.count > 2 { + if let predictedGlucose = data.predictedGlucose, predictedGlucose.count > 2 { let predictedPath = CGMutablePath() predictedPath.addLines(between: predictedGlucose.map { scaler.point($0.startDate, $0.quantity.doubleValue(for: unit))