From 47c90bf39547d5515bdc3a5edb7596969bb054ab Mon Sep 17 00:00:00 2001 From: Michael Pangburn Date: Sat, 3 Nov 2018 16:13:06 -0700 Subject: [PATCH 1/6] Series 4 complications draft --- Loop.xcodeproj/project.pbxproj | 57 +++++- .../Base.lproj/ckcomplication.strings | 6 + .../ComplicationController.swift | 177 ++++++++++++++--- .../Controllers/ChartHUDController.swift | 34 ++-- 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 | 3 +- .../Managers/ComplicationChartManager.swift | 130 +++++++++++++ .../Managers/LoopDataManager.swift | 19 ++ .../Models/GlucoseChartData.swift | 101 ++++++++++ .../Models/GlucoseChartScaler.swift | 95 +++++++++ .../Scenes/GlucoseChartScene.swift | 182 +++--------------- 15 files changed, 729 insertions(+), 271 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 ca508159ab..76c5aa72e9 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 */, @@ -2615,7 +2647,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -2632,7 +2664,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; @@ -2645,10 +2677,11 @@ 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"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; 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"; @@ -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,10 +2701,11 @@ 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"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; 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"; @@ -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; @@ -2688,7 +2727,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2709,7 +2748,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = UY678SP37Q; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2788,7 +2827,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 = 5Q5Q8W9ATZ; 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 +2846,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 = 5Q5Q8W9ATZ; 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"; 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 11770e69fa..1b86733c00 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -51,6 +51,23 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { glucoseScene.presentScene(scene) } + override func awake(withContext context: Any?) { + super.awake(withContext: context) + + if UserDefaults.standard.startOnChartPage { + log.default("Switching to startOnChartPage") + becomeCurrentPage() + + // For some reason, .didAppear() does not get called when we do this. It gets called *twice* the next + // time this view appears. Force it by hand now, until we figure out the root cause. + // + // TODO: possibly because I'm not calling super.awake()? investigate that. + DispatchQueue.main.async { + self.didAppear() + } + } + } + override func didAppear() { super.didAppear() @@ -63,11 +80,6 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in self?.scene.setNeedsUpdate() } - - if #available(watchOSApplicationExtension 5.0, *) { - scene.textInsets.left = max(scene.textInsets.left, systemMinimumLayoutMargins.leading) - scene.textInsets.right = max(scene.textInsets.right, systemMinimumLayoutMargins.trailing) - } } override func willDisappear() { @@ -175,17 +187,9 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { } 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 + 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..926303a823 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.9.4 dev + 1.9.2 CFBundleSignature ???? CFBundleVersion @@ -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 2745459773..32eb582763 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -28,7 +28,7 @@ private enum NodePlane: Int { private extension SKLabelNode { static func basic(at position: CGPoint) -> SKLabelNode { let basic = SKLabelNode(text: "--") - basic.fontSize = UIFont.preferredFont(forTextStyle: .caption2).pointSize + basic.fontSize = 12 basic.fontName = "HelveticaNeue" basic.fontColor = .chartLabel basic.alpha = 0.8 @@ -55,138 +55,27 @@ 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") var textInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) - 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)) - } - - min = min.floored(to: unit.axisIncrement) - max = max.ceiled(to: unit.axisIncrement) - - let lowerBound = HKQuantity(unit: unit, doubleValue: min) - let upperBound = HKQuantity(unit: unit, doubleValue: max) - - return lowerBound.. (sprite: SKSpriteNode, created: Bool) { - var created = false - if nodes[hashValue] == nil { - nodes[hashValue] = SKSpriteNode(color: .clear, size: CGSize(width: 0, height: 0)) - addChild(nodes[hashValue]!) - created = true + 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() { @@ -307,38 +189,31 @@ 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)) - maxBGLabel.text = numberFormatter.string(from: yRange.upperBound.doubleValue(for: unit)) + minBGLabel.text = numberFormatter.string(from: glucoseRange.lowerBound.doubleValue(for: unit)) + maxBGLabel.text = numberFormatter.string(from: glucoseRange.upperBound.doubleValue(for: unit)) hoursLabel.text = dateFormatter.string(from: visibleDuration) // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end var inactiveNodes = nodes - let activeOverride = correctionRange?.activeOverride + 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 @@ -350,8 +225,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 @@ -360,9 +235,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 @@ -371,7 +247,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)) From 1df728ab126f8237df397a26251c5442a1d6d586 Mon Sep 17 00:00:00 2001 From: Michael Pangburn Date: Sun, 4 Nov 2018 09:33:12 -0800 Subject: [PATCH 2/6] Remove development team changes --- Loop.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 76c5aa72e9..70e97baa70 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -2647,7 +2647,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -2664,7 +2664,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; @@ -2681,7 +2681,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + 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"; @@ -2705,7 +2705,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + 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"; @@ -2727,7 +2727,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2748,7 +2748,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2827,7 +2827,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 = 5Q5Q8W9ATZ; + 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"; @@ -2846,7 +2846,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 = 5Q5Q8W9ATZ; + 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 d9aebc0696d6d5bf2649385e055b9570969e10ff Mon Sep 17 00:00:00 2001 From: Michael Pangburn Date: Sun, 4 Nov 2018 09:38:49 -0800 Subject: [PATCH 3/6] Fix accidental reversions --- .../Controllers/ChartHUDController.swift | 22 +++++-------------- WatchApp Extension/Info.plist | 2 +- .../Scenes/GlucoseChartScene.swift | 2 +- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index 1b86733c00..e6d25cb4f2 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -51,23 +51,6 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { glucoseScene.presentScene(scene) } - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - if UserDefaults.standard.startOnChartPage { - log.default("Switching to startOnChartPage") - becomeCurrentPage() - - // For some reason, .didAppear() does not get called when we do this. It gets called *twice* the next - // time this view appears. Force it by hand now, until we figure out the root cause. - // - // TODO: possibly because I'm not calling super.awake()? investigate that. - DispatchQueue.main.async { - self.didAppear() - } - } - } - override func didAppear() { super.didAppear() @@ -80,6 +63,11 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in self?.scene.setNeedsUpdate() } + + if #available(watchOSApplicationExtension 5.0, *) { + scene.textInsets.left = max(scene.textInsets.left, systemMinimumLayoutMargins.leading) + scene.textInsets.right = max(scene.textInsets.right, systemMinimumLayoutMargins.trailing) + } } override func willDisappear() { diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist index 926303a823..143b68353c 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.9.2 + 1.9.4 dev CFBundleSignature ???? CFBundleVersion diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 32eb582763..b53a6faaf2 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -28,7 +28,7 @@ private enum NodePlane: Int { private extension SKLabelNode { static func basic(at position: CGPoint) -> SKLabelNode { let basic = SKLabelNode(text: "--") - basic.fontSize = 12 + basic.fontSize = UIFont.preferredFont(forTextStyle: .caption2).pointSize basic.fontName = "HelveticaNeue" basic.fontColor = .chartLabel basic.alpha = 0.8 From 7a936d3637c9e472aeb9e58f6c681cec41a69303 Mon Sep 17 00:00:00 2001 From: mpangburn Date: Tue, 8 Jan 2019 17:29:38 -0800 Subject: [PATCH 4/6] 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)) From 76393730a48312f2f43aaa3310ab5a97f825f74c Mon Sep 17 00:00:00 2001 From: mpangburn Date: Thu, 10 Jan 2019 20:30:49 -0800 Subject: [PATCH 5/6] Fixes per @ps2 --- Loop.xcodeproj/project.pbxproj | 16 +++--- .../Base.lproj/ckcomplication.strings | 3 ++ .../ComplicationController.swift | 40 +++++++------- .../Extensions/CLKTextProvider+Compound.h | 7 +-- .../Extensions/CLKTextProvider+Compound.m | 8 ++- .../Managers/ComplicationChartManager.swift | 53 ++++++++++--------- .../Models/GlucoseChartData.swift | 4 +- 7 files changed, 73 insertions(+), 58 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 70e97baa70..76c5aa72e9 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -2647,7 +2647,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -2664,7 +2664,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; @@ -2681,7 +2681,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; 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"; @@ -2705,7 +2705,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; 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"; @@ -2727,7 +2727,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2748,7 +2748,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2827,7 +2827,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 = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; 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"; @@ -2846,7 +2846,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 = ""; + DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; 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"; diff --git a/WatchApp Extension/Base.lproj/ckcomplication.strings b/WatchApp Extension/Base.lproj/ckcomplication.strings index f4d1b4d52c..0aa78a1ee4 100644 --- a/WatchApp Extension/Base.lproj/ckcomplication.strings +++ b/WatchApp Extension/Base.lproj/ckcomplication.strings @@ -12,5 +12,8 @@ /* The complication template example glucose string */ "120" = "120"; +/* The complication template example time string */ +"3MIN" = "3MIN"; + /* 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 ca41ba2b33..e67920f17d 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -43,38 +43,38 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { private let chartManager = ComplicationChartManager() 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 - } + guard + #available(watchOSApplicationExtension 5.0, *), + 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() - } + ExtensionDelegate.shared().loopManager.generateChartData { chartData in + self.chartManager.data = chartData + completion() } } - var makeChart: () -> UIImage? { + func 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) + return CGSize(width: 150.0, height: 47.0) default /* case 184 */: // 44mm - return CGSize(width: 171.0 * scaleFactor, height: 54.0 * scaleFactor) + return CGSize(width: 171.0, height: 54.0) } }() - return { [chartManager] in chartManager.renderChartImage(size: size) } + + let scale = WKInterfaceDevice.current().screenScale + return chartManager.renderChartImage(size: size, scale: scale) } func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { - updateChartManagerIfNeeded { + updateChartManagerIfNeeded(completion: { let entry: CLKComplicationTimelineEntry? if let context = ExtensionDelegate.shared().loopManager.activeContext, @@ -94,7 +94,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } handler(entry) - } + }) } func getTimelineEntries(for complication: CLKComplication, before date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { @@ -131,7 +131,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { 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) + let timeText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "3MIN") switch family { case .modularSmall: diff --git a/WatchApp Extension/Extensions/CLKTextProvider+Compound.h b/WatchApp Extension/Extensions/CLKTextProvider+Compound.h index 8da6380afd..194f99584e 100644 --- a/WatchApp Extension/Extensions/CLKTextProvider+Compound.h +++ b/WatchApp Extension/Extensions/CLKTextProvider+Compound.h @@ -6,15 +6,16 @@ // Copyright © 2018 LoopKit Authors. All rights reserved. // -#ifndef CLKTextProvider_Compound_h #define CLKTextProvider_Compound_h #import +NS_ASSUME_NONNULL_BEGIN + @interface CLKTextProvider (Compound) -+ (CLKTextProvider *)textProviderByJoiningTextProviders: (nonnull NSArray *)textProviders separator:(nullable NSString *) separator; ++ (CLKTextProvider *)textProviderByJoiningTextProviders: (NSArray *)textProviders separator:(NSString *) separator; @end -#endif /* CLKTextProvider_Compound_h */ +NS_ASSUME_NONNULL_END diff --git a/WatchApp Extension/Extensions/CLKTextProvider+Compound.m b/WatchApp Extension/Extensions/CLKTextProvider+Compound.m index 37d147361f..b795ca7b21 100644 --- a/WatchApp Extension/Extensions/CLKTextProvider+Compound.m +++ b/WatchApp Extension/Extensions/CLKTextProvider+Compound.m @@ -8,11 +8,13 @@ #import "CLKTextProvider+Compound.h" +NS_ASSUME_NONNULL_BEGIN + // 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 { ++ (CLKTextProvider *)textProviderByJoiningTextProviders: (NSArray *)textProviders separator:(NSString *) separator { NSString *formatString = @"%@%@"; @@ -22,7 +24,7 @@ + (CLKTextProvider *)textProviderByJoiningTextProviders: (nonnull NSArray UIImage? { + func renderChartImage(size: CGSize, scale: CGFloat) -> UIImage? { + guard let data = data else { + renderedChartImage = nil + return nil + } + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) defer { UIGraphicsEndImageContext() } let context = UIGraphicsGetCurrentContext()! - drawChart(in: context, size: size) - let image = context.makeImage().map(UIImage.init(cgImage:)) + drawChart(in: context, data: data, size: size) + + guard let cgImage = context.makeImage() else { + renderedChartImage = nil + return nil + } + + let image = UIImage(cgImage: cgImage, scale: scale, orientation: .up) renderedChartImage = image return image } - private func drawChart(in context: CGContext, size: CGSize) { - guard let data = data else { - // TODO: handle empty data case - return - } - + private func drawChart(in context: CGContext, data: GlucoseChartData, size: CGSize) { let now = Date() lastRenderDate = now let spannedInterval = DateInterval(start: now - visibleInterval / 2, duration: visibleInterval) @@ -70,15 +75,15 @@ final class ComplicationChartManager { } private func drawGlucoseLabelText(_ text: String, position: GlucoseLabelPosition, scaler: GlucoseChartScaler) { - let attributedText = NSAttributedString(string: text, attributes: ComplicationChartConstants.glucoseLabelAttributes) + let attributedText = NSAttributedString(string: text, attributes: glucoseLabelAttributes) let size = attributedText.size() - let x = scaler.xCoordinate(for: scaler.dates.end) - size.width - ComplicationChartConstants.textInsets.right + let x = scaler.xCoordinate(for: scaler.dates.end) - size.width - textInsets.right let y: CGFloat = { switch position { case .high: - return scaler.yCoordinate(for: scaler.glucoseMax) + ComplicationChartConstants.textInsets.top + return scaler.yCoordinate(for: scaler.glucoseMax) + textInsets.top case .low: - return scaler.yCoordinate(for: scaler.glucoseMin) - size.height - ComplicationChartConstants.textInsets.bottom + return scaler.yCoordinate(for: scaler.glucoseMin) - size.height - textInsets.bottom } }() let rect = CGRect(origin: CGPoint(x: x, y: y), size: size).alignedToScreenScale(WKInterfaceDevice.current().screenScale) @@ -110,7 +115,7 @@ final class ComplicationChartManager { .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) + let glucoseRect = CGRect(origin: origin, size: glucoseSize).alignedToScreenScale(WKInterfaceDevice.current().screenScale) context.fill(glucoseRect) } } @@ -122,7 +127,7 @@ final class ComplicationChartManager { 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]) + let dashedPath = predictedPath.copy(dashingWithPhase: predictionDashPhase, lengths: predictionDashLengths) context.setStrokeColor(UIColor.white.cgColor) context.addPath(dashedPath) context.strokePath() diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift index 6bf47c3670..1be3f2d8d5 100644 --- a/WatchApp Extension/Models/GlucoseChartData.swift +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -68,7 +68,9 @@ struct GlucoseChartData { 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) From b9da2c5487eb03cb20b12681c5ca68db1b1deb3f Mon Sep 17 00:00:00 2001 From: mpangburn Date: Thu, 10 Jan 2019 20:36:09 -0800 Subject: [PATCH 6/6] Revert development team change --- Loop.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 76c5aa72e9..70e97baa70 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -2647,7 +2647,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -2664,7 +2664,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; @@ -2681,7 +2681,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + 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"; @@ -2705,7 +2705,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + 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"; @@ -2727,7 +2727,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2748,7 +2748,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = 5Q5Q8W9ATZ; + DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -2827,7 +2827,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 = 5Q5Q8W9ATZ; + 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"; @@ -2846,7 +2846,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 = 5Q5Q8W9ATZ; + 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";