From 000dc938ada74e9f32db35f2a0552b5a8fdca70a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 8 May 2017 19:49:16 -0500 Subject: [PATCH 1/4] Customizing bolus errors with new certainty distinction --- Loop.xcodeproj/project.pbxproj | 2 +- Loop/AppDelegate.swift | 12 ++-- Loop/Managers/AnalyticsManager.swift | 4 +- Loop/Managers/DeviceDataManager.swift | 29 +++++--- Loop/Managers/NotificationManager.swift | 62 +++++++++------- Loop/Managers/WatchDataManager.swift | 6 +- Loop/Models/LoopError.swift | 72 +++++++++++++++++-- .../StatusTableViewController.swift | 6 +- WatchApp Extension/ExtensionDelegate.swift | 9 +++ .../PushNotificationPayload.apns | 11 +-- WatchApp/Base.lproj/Interface.storyboard | 28 ++++++-- 11 files changed, 172 insertions(+), 69 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 9ffad996c2..29a0bae981 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -1089,7 +1089,7 @@ 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */, ); name = Loop; - productName = Naterade; + productName = Loop; productReference = 43776F8C1B8022E90074EA36 /* Loop.app */; productType = "com.apple.product-type.application"; }; diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 25bd46728b..994f8d987f 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -70,18 +70,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { switch response.actionIdentifier { - case NotificationManager.Action.RetryBolus.rawValue: - if let units = response.notification.request.content.userInfo[NotificationManager.UserInfoKey.BolusAmount.rawValue] as? Double, - let startDate = response.notification.request.content.userInfo[NotificationManager.UserInfoKey.BolusStartDate.rawValue] as? Date, + case NotificationManager.Action.retryBolus.rawValue: + if let units = response.notification.request.content.userInfo[NotificationManager.UserInfoKey.bolusAmount.rawValue] as? Double, + let startDate = response.notification.request.content.userInfo[NotificationManager.UserInfoKey.bolusStartDate.rawValue] as? Date, startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { AnalyticsManager.sharedManager.didRetryBolus() - deviceManager.enactBolus(units: units) { (error) in - if error != nil { - NotificationManager.sendBolusFailureNotificationForAmount(units, atStartDate: startDate) - } - + deviceManager.enactBolus(units: units, at: startDate) { (_) in completionHandler() } return diff --git a/Loop/Managers/AnalyticsManager.swift b/Loop/Managers/AnalyticsManager.swift index 2d49130154..5f48b269de 100644 --- a/Loop/Managers/AnalyticsManager.swift +++ b/Loop/Managers/AnalyticsManager.swift @@ -118,7 +118,7 @@ final class AnalyticsManager { // MARK: - Loop Events func didAddCarbsFromWatch(_ carbs: Double) { - logEvent("Carb entry created", withProperties: ["source" : "Watch", "value": carbs], outOfSession: true) + logEvent("Carb entry created", withProperties: ["source" : "Watch"], outOfSession: true) } func didRetryBolus() { @@ -126,7 +126,7 @@ final class AnalyticsManager { } func didSetBolusFromWatch(_ units: Double) { - logEvent("Bolus set", withProperties: ["source" : "Watch", "value": units], outOfSession: true) + logEvent("Bolus set", withProperties: ["source" : "Watch"], outOfSession: true) } func loopDidSucceed() { diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 6b2f0283ef..dd5b11286f 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -361,19 +361,27 @@ final class DeviceDataManager { /// - parameter units: The number of units to deliver /// - parameter completion: A clsure called after the command is complete. This closure takes a single argument: /// - error: An error describing why the command failed - func enactBolus(units: Double, completion: @escaping (_ error: Error?) -> Void) { + func enactBolus(units: Double, at startDate: Date = Date(), completion: @escaping (_ error: Error?) -> Void) { + let notify = { (error: Error?) -> Void in + if let error = error { + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: startDate) + } + + completion(error) + } + guard units > 0 else { - completion(nil) + notify(nil) return } guard let device = rileyLinkManager.firstConnectedDevice else { - completion(LoopError.connectionError) + notify(LoopError.connectionError) return } guard let ops = device.ops else { - completion(LoopError.configurationError("PumpOps")) + notify(LoopError.configurationError("PumpOps")) return } @@ -381,10 +389,10 @@ final class DeviceDataManager { ops.setNormalBolus(units: units) { (error) in if let error = error { self.logger.addError(error, fromSource: "Bolus") - completion(LoopError.communicationError) + notify(error) } else { self.loopManager.addExpectedBolus(units, at: Date()) - completion(nil) + notify(nil) } } } @@ -401,13 +409,18 @@ final class DeviceDataManager { switch result { case .failure(let error): self.logger.addError(error, fromSource: "Bolus") - completion(error) + notify(error) case .success: setBolus() } } case .failure(let error): - completion(error) + switch error { + case let error as PumpCommsError: + notify(SetBolusError.certain(error)) + default: + notify(error) + } } } } else { diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 6667183cd7..18e6ae97b9 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -9,36 +9,38 @@ import UIKit import UserNotifications +import RileyLinkKit + struct NotificationManager { enum Category: String { - case BolusFailure - case LoopNotRunning - case PumpBatteryLow - case PumpReservoirEmpty - case PumpReservoirLow + case bolusFailure + case loopNotRunning + case pumpBatteryLow + case pumpReservoirEmpty + case pumpReservoirLow } enum Action: String { - case RetryBolus + case retryBolus } enum UserInfoKey: String { - case BolusAmount - case BolusStartDate + case bolusAmount + case bolusStartDate } private static var notificationCategories: Set { var categories = [UNNotificationCategory]() let retryBolusAction = UNNotificationAction( - identifier: Action.RetryBolus.rawValue, + identifier: Action.retryBolus.rawValue, title: NSLocalizedString("Retry", comment: "The title of the notification action to retry a bolus command"), options: [] ) categories.append(UNNotificationCategory( - identifier: Category.BolusFailure.rawValue, + identifier: Category.bolusFailure.rawValue, actions: [retryBolusAction], intentIdentifiers: [], options: [] @@ -57,25 +59,35 @@ struct NotificationManager { // MARK: - Notifications - static func sendBolusFailureNotificationForAmount(_ units: Double, atStartDate startDate: Date) { + static func sendBolusFailureNotification(for error: Error, units: Double, at startDate: Date) { let notification = UNMutableNotificationContent() notification.title = NSLocalizedString("Bolus", comment: "The notification title for a bolus failure") - notification.body = String(format: NSLocalizedString("%@ U bolus may have failed.", comment: "The notification alert describing a possible bolus failure. The substitution parameter is the size of the bolus in units."), NumberFormatter.localizedString(from: NSNumber(value: units), number: .decimal)) + + switch error { + case let error as RileyLinkKit.SetBolusError: + notification.subtitle = error.errorDescriptionWithUnits(units) + notification.body = String(format: "%@ %@", error.failureReason!, error.recoverySuggestion!) + case let error as LocalizedError: + notification.body = error.errorDescription ?? error.localizedDescription + default: + notification.body = error.localizedDescription + } + notification.sound = UNNotificationSound.default() if startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { - notification.categoryIdentifier = Category.BolusFailure.rawValue + notification.categoryIdentifier = Category.bolusFailure.rawValue } notification.userInfo = [ - UserInfoKey.BolusAmount.rawValue: units, - UserInfoKey.BolusStartDate.rawValue: startDate + UserInfoKey.bolusAmount.rawValue: units, + UserInfoKey.bolusStartDate.rawValue: startDate ] let request = UNNotificationRequest( // Only support 1 bolus notification at once - identifier: Category.BolusFailure.rawValue, + identifier: Category.bolusFailure.rawValue, content: notification, trigger: nil ) @@ -107,11 +119,11 @@ struct NotificationManager { notification.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.LoopNotRunning.rawValue - notification.threadIdentifier = Category.LoopNotRunning.rawValue + notification.categoryIdentifier = Category.loopNotRunning.rawValue + notification.threadIdentifier = Category.loopNotRunning.rawValue let request = UNNotificationRequest( - identifier: "\(Category.LoopNotRunning.rawValue)\(failureInterval)", + identifier: "\(Category.loopNotRunning.rawValue)\(failureInterval)", content: notification, trigger: UNTimeIntervalNotificationTrigger( timeInterval: failureInterval + gracePeriod, @@ -129,10 +141,10 @@ struct NotificationManager { notification.title = NSLocalizedString("Pump Battery Low", comment: "The notification title for a low pump battery") notification.body = NSLocalizedString("Change the pump battery immediately", comment: "The notification alert describing a low pump battery") notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.PumpBatteryLow.rawValue + notification.categoryIdentifier = Category.pumpBatteryLow.rawValue let request = UNNotificationRequest( - identifier: Category.PumpBatteryLow.rawValue, + identifier: Category.pumpBatteryLow.rawValue, content: notification, trigger: nil ) @@ -146,11 +158,11 @@ struct NotificationManager { notification.title = NSLocalizedString("Pump Reservoir Empty", comment: "The notification title for an empty pump reservoir") notification.body = NSLocalizedString("Change the pump reservoir now", comment: "The notification alert describing an empty pump reservoir") notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.PumpReservoirEmpty.rawValue + notification.categoryIdentifier = Category.pumpReservoirEmpty.rawValue let request = UNNotificationRequest( // Not a typo: this should replace any pump reservoir low notifications - identifier: Category.PumpReservoirLow.rawValue, + identifier: Category.pumpReservoirLow.rawValue, content: notification, trigger: nil ) @@ -179,10 +191,10 @@ struct NotificationManager { } notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.PumpReservoirLow.rawValue + notification.categoryIdentifier = Category.pumpReservoirLow.rawValue let request = UNNotificationRequest( - identifier: Category.PumpReservoirLow.rawValue, + identifier: Category.pumpReservoirLow.rawValue, content: notification, trigger: nil ) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index e0f25e6810..4fe1cfd678 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -154,10 +154,8 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } case SetBolusUserInfo.name?: if let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) { - self.deviceDataManager.enactBolus(units: bolus.value) { (error) in - if error != nil { - NotificationManager.sendBolusFailureNotificationForAmount(bolus.value, atStartDate: bolus.startDate) - } else { + self.deviceDataManager.enactBolus(units: bolus.value, at: bolus.startDate) { (error) in + if error == nil { AnalyticsManager.sharedManager.didSetBolusFromWatch(bolus.value) } diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 057ca758d4..7cbafa421d 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -7,10 +7,12 @@ // import Foundation +import RileyLinkKit + enum LoopError: Error { - // Failure during device communication - case communicationError + // A bolus failed to start + case bolusCommand(SetBolusError) // Missing or unexpected configuration values case configurationError(String) @@ -34,6 +36,7 @@ enum LoopError: Error { case invalidData(details: String) } + extension LoopError: LocalizedError { public var recoverySuggestion: String? { @@ -46,14 +49,13 @@ extension LoopError: LocalizedError { } public var errorDescription: String? { - let formatter = DateComponentsFormatter() formatter.allowedUnits = [.minute] formatter.unitsStyle = .full switch self { - case .communicationError: - return NSLocalizedString("Communication Error", comment: "The error message displayed after a communication error.") + case .bolusCommand(let error): + return error.errorDescription case .configurationError(let details): return String(format: NSLocalizedString("Configuration Error: %1$@", comment: "The error message displayed for configuration errors. (1: configuration error details)"), details) case .connectionError: @@ -76,3 +78,63 @@ extension LoopError: LocalizedError { } } + +extension SetBolusError: LocalizedError { + public func errorDescriptionWithUnits(_ units: Double) -> String { + let format: String + + switch self { + case .certain: + format = NSLocalizedString("%1$@ U bolus failed", comment: "Describes a certain bolus failure (1: size of the bolus in units)") + case .uncertain: + format = NSLocalizedString("%1$@ U bolus may not have succeeded", comment: "Describes an uncertain bolus failure (1: size of the bolus in units)") + } + + return String(format: format, NumberFormatter.localizedString(from: NSNumber(value: units), number: .decimal)) + } + + public var failureReason: String? { + switch self { + case .certain(let error): + return error.failureReason + case .uncertain(let error): + return error.failureReason + } + } + + public var recoverySuggestion: String? { + switch self { + case .certain: + return NSLocalizedString("It is safe to retry.", comment: "Recovery instruction for a certain bolus failure") + case .uncertain: + return NSLocalizedString("Check your pump before retrying.", comment: "Recovery instruction for an uncertain bolus failure") + } + } +} + + +extension PumpCommsError: LocalizedError { + public var failureReason: String? { + switch self { + case .bolusInProgress: + return NSLocalizedString("A bolus is already in progress.", comment: "Communications error for a bolus currently running") + case .crosstalk: + return NSLocalizedString("Radio interference detected.", comment: "") + case .noResponse: + return NSLocalizedString("Pump did not respond.", comment: "") + case .pumpSuspended: + return NSLocalizedString("Pump is suspended.", comment: "") + case .rfCommsFailure: + return NSLocalizedString("Communication Error.", comment: "") + case .rileyLinkTimeout: + return NSLocalizedString("RileyLink timed out.", comment: "") + case .unexpectedResponse: + return NSLocalizedString("Pump responded unexpectedly.", comment: "") + case .unknownPumpModel: + return NSLocalizedString("Unknown pump model.", comment: "") + case .unknownResponse: + return NSLocalizedString("Unknown response from pump.", comment: "") + } + } +} + diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 8e4eb5d75c..d3429ca72b 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -798,12 +798,8 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize if let bolusViewController = segue.source as? BolusViewController { if let bolus = bolusViewController.bolus, bolus > 0 { self.bolusState = .enacting - let startDate = Date() - dataManager.enactBolus(units: bolus) { (error) in + dataManager.enactBolus(units: bolus) { (_) in self.bolusState = nil - if error != nil { - NotificationManager.sendBolusFailureNotificationForAmount(bolus, atStartDate: startDate) - } } } else { self.bolusState = nil diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 39a541977f..de3c30d510 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -9,6 +9,7 @@ import WatchConnectivity import WatchKit import os +import UserNotifications final class ExtensionDelegate: NSObject, WKExtensionDelegate { @@ -40,6 +41,7 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { func applicationDidFinishLaunching() { // Perform any final initialization of your application. + UNUserNotificationCenter.current().delegate = self } func applicationDidBecomeActive() { @@ -170,6 +172,13 @@ extension ExtensionDelegate: WCSessionDelegate { } +extension ExtensionDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.badge, .sound, .alert]) + } +} + + extension ExtensionDelegate { /// Global shortcut to present an alert for a specific error out-of-context with a specific interface controller. diff --git a/WatchApp Extension/PushNotificationPayload.apns b/WatchApp Extension/PushNotificationPayload.apns index e793a02b3c..edffb57568 100644 --- a/WatchApp Extension/PushNotificationPayload.apns +++ b/WatchApp Extension/PushNotificationPayload.apns @@ -1,16 +1,17 @@ { "aps": { "alert": { - "body": "Test message", - "title": "Optional title" + "body": "RileyLink timed out. Check your pump before retrying.", + "title": "Bolus", + "subtitle": "3.5 U bolus failed" }, - "category": "myCategory" + "category": "bolusFailure" }, "WatchKit Simulator Actions": [ { - "title": "First Button", - "identifier": "firstButtonAction" + "title": "Retry", + "identifier": "retryBolus" } ], diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard index f58c6910b6..6cccfe1bb8 100644 --- a/WatchApp/Base.lproj/Interface.storyboard +++ b/WatchApp/Base.lproj/Interface.storyboard @@ -1,8 +1,12 @@ - + + + + - - + + + @@ -227,11 +231,23 @@ - + - - + + + + From 3767bff1193542c965f1b11198a06f4884257bdd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 8 May 2017 22:17:42 -0500 Subject: [PATCH 2/4] Clarify crosstalk, and display message associated with rfCommsFailure --- Loop/Models/LoopError.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 7cbafa421d..de8db516c1 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -119,13 +119,13 @@ extension PumpCommsError: LocalizedError { case .bolusInProgress: return NSLocalizedString("A bolus is already in progress.", comment: "Communications error for a bolus currently running") case .crosstalk: - return NSLocalizedString("Radio interference detected.", comment: "") + return NSLocalizedString("Comms with another pump detected.", comment: "") case .noResponse: return NSLocalizedString("Pump did not respond.", comment: "") case .pumpSuspended: return NSLocalizedString("Pump is suspended.", comment: "") - case .rfCommsFailure: - return NSLocalizedString("Communication Error.", comment: "") + case .rfCommsFailure(let msg): + return NSLocalizedString("Communication Error: " + msg, comment: "") case .rileyLinkTimeout: return NSLocalizedString("RileyLink timed out.", comment: "") case .unexpectedResponse: From a8f9f8da30740167417fcd84ea681416c95008e6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 8 May 2017 22:32:06 -0500 Subject: [PATCH 3/4] Just return error reason for rfCommsFailure errors, without wordy prefix --- Loop/Models/LoopError.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index de8db516c1..6599d8cac4 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -125,7 +125,7 @@ extension PumpCommsError: LocalizedError { case .pumpSuspended: return NSLocalizedString("Pump is suspended.", comment: "") case .rfCommsFailure(let msg): - return NSLocalizedString("Communication Error: " + msg, comment: "") + return msg case .rileyLinkTimeout: return NSLocalizedString("RileyLink timed out.", comment: "") case .unexpectedResponse: From 50bc574b83fa4ab40cc680e91c2d698740deb2f7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 11 May 2017 22:30:26 -0500 Subject: [PATCH 4/4] Pump and bolus error localizations are in RLKit framework now --- Loop/Models/LoopError.swift | 59 ------------------------------------- 1 file changed, 59 deletions(-) diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 6599d8cac4..103fa481dd 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -79,62 +79,3 @@ extension LoopError: LocalizedError { } -extension SetBolusError: LocalizedError { - public func errorDescriptionWithUnits(_ units: Double) -> String { - let format: String - - switch self { - case .certain: - format = NSLocalizedString("%1$@ U bolus failed", comment: "Describes a certain bolus failure (1: size of the bolus in units)") - case .uncertain: - format = NSLocalizedString("%1$@ U bolus may not have succeeded", comment: "Describes an uncertain bolus failure (1: size of the bolus in units)") - } - - return String(format: format, NumberFormatter.localizedString(from: NSNumber(value: units), number: .decimal)) - } - - public var failureReason: String? { - switch self { - case .certain(let error): - return error.failureReason - case .uncertain(let error): - return error.failureReason - } - } - - public var recoverySuggestion: String? { - switch self { - case .certain: - return NSLocalizedString("It is safe to retry.", comment: "Recovery instruction for a certain bolus failure") - case .uncertain: - return NSLocalizedString("Check your pump before retrying.", comment: "Recovery instruction for an uncertain bolus failure") - } - } -} - - -extension PumpCommsError: LocalizedError { - public var failureReason: String? { - switch self { - case .bolusInProgress: - return NSLocalizedString("A bolus is already in progress.", comment: "Communications error for a bolus currently running") - case .crosstalk: - return NSLocalizedString("Comms with another pump detected.", comment: "") - case .noResponse: - return NSLocalizedString("Pump did not respond.", comment: "") - case .pumpSuspended: - return NSLocalizedString("Pump is suspended.", comment: "") - case .rfCommsFailure(let msg): - return msg - case .rileyLinkTimeout: - return NSLocalizedString("RileyLink timed out.", comment: "") - case .unexpectedResponse: - return NSLocalizedString("Pump responded unexpectedly.", comment: "") - case .unknownPumpModel: - return NSLocalizedString("Unknown pump model.", comment: "") - case .unknownResponse: - return NSLocalizedString("Unknown response from pump.", comment: "") - } - } -} -