OmnipodUICoordinator.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. //
  2. // OmnipodUICoordinator.swift
  3. // OmniKit
  4. //
  5. // Created by Pete Schwamb on 2/16/20.
  6. // Copyright © 2021 LoopKit Authors. All rights reserved.
  7. //
  8. import Foundation
  9. import UIKit
  10. import SwiftUI
  11. import Combine
  12. import LoopKit
  13. import LoopKitUI
  14. import OmniKit
  15. import RileyLinkKit
  16. import RileyLinkBLEKit
  17. import RileyLinkKitUI
  18. enum OmnipodUIScreen {
  19. case firstRunScreen
  20. case expirationReminderSetup
  21. case lowReservoirReminderSetup
  22. case insulinTypeSelection
  23. case rileyLinkSetup
  24. case pairAndPrime
  25. case insertCannula
  26. case confirmAttachment
  27. case checkInsertedCannula
  28. case setupComplete
  29. case pendingCommandRecovery
  30. case uncertaintyRecovered
  31. case deactivate
  32. case settings
  33. func next() -> OmnipodUIScreen? {
  34. switch self {
  35. case .firstRunScreen:
  36. return .expirationReminderSetup
  37. case .expirationReminderSetup:
  38. return .lowReservoirReminderSetup
  39. case .lowReservoirReminderSetup:
  40. return .insulinTypeSelection
  41. case .insulinTypeSelection:
  42. return .rileyLinkSetup
  43. case .rileyLinkSetup:
  44. return .pairAndPrime
  45. case .pairAndPrime:
  46. return .confirmAttachment
  47. case .confirmAttachment:
  48. return .insertCannula
  49. case .insertCannula:
  50. return .checkInsertedCannula
  51. case .checkInsertedCannula:
  52. return .setupComplete
  53. case .setupComplete:
  54. return nil
  55. case .pendingCommandRecovery:
  56. return .deactivate
  57. case .uncertaintyRecovered:
  58. return nil
  59. case .deactivate:
  60. return .pairAndPrime
  61. case .settings:
  62. return nil
  63. }
  64. }
  65. }
  66. class OmnipodUICoordinator: UINavigationController, PumpManagerOnboarding, CompletionNotifying, UINavigationControllerDelegate {
  67. public weak var pumpManagerOnboardingDelegate: PumpManagerOnboardingDelegate?
  68. public weak var completionDelegate: CompletionDelegate?
  69. var pumpManager: OmnipodPumpManager
  70. private var disposables = Set<AnyCancellable>()
  71. var currentScreen: OmnipodUIScreen {
  72. return screenStack.last!
  73. }
  74. var screenStack = [OmnipodUIScreen]()
  75. private let colorPalette: LoopUIColorPalette
  76. private var pumpManagerType: OmnipodPumpManager.Type?
  77. private var allowedInsulinTypes: [InsulinType]
  78. private var allowDebugFeatures: Bool
  79. private func viewControllerForScreen(_ screen: OmnipodUIScreen) -> UIViewController {
  80. switch screen {
  81. case .firstRunScreen:
  82. let view = PodSetupView(nextAction: { [weak self] in self?.stepFinished() },
  83. allowDebugFeatures: allowDebugFeatures,
  84. skipOnboarding: { [weak self] in // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY
  85. guard let self = self else { return }
  86. self.pumpManager.completeOnboard()
  87. self.completionDelegate?.completionNotifyingDidComplete(self)
  88. })
  89. return hostingController(rootView: view)
  90. case .rileyLinkSetup:
  91. let dataSource = RileyLinkListDataSource(rileyLinkPumpManager: pumpManager)
  92. var view = RileyLinkSetupView(
  93. dataSource: dataSource,
  94. nextAction: { [weak self] in self?.stepFinished() })
  95. view.cancelButtonTapped = { [weak self] in
  96. self?.setupCanceled()
  97. }
  98. return hostingController(rootView: view)
  99. case .expirationReminderSetup:
  100. var view = ExpirationReminderSetupView(expirationReminderDefault: Int(pumpManager.defaultExpirationReminderOffset.hours))
  101. view.valueChanged = { [weak self] value in
  102. self?.pumpManager.defaultExpirationReminderOffset = .hours(Double(value))
  103. }
  104. view.continueButtonTapped = { [weak self] in
  105. guard let self = self else { return }
  106. if !self.pumpManager.isOnboarded {
  107. self.pumpManager.completeOnboard()
  108. self.pumpManagerOnboardingDelegate?.pumpManagerOnboarding(didOnboardPumpManager: self.pumpManager)
  109. }
  110. self.stepFinished()
  111. }
  112. view.cancelButtonTapped = { [weak self] in
  113. self?.setupCanceled()
  114. }
  115. let hostedView = hostingController(rootView: view)
  116. hostedView.navigationItem.title = LocalizedString("Expiration Reminder", comment: "Title for ExpirationReminderSetupView")
  117. return hostedView
  118. case .lowReservoirReminderSetup:
  119. var view = LowReservoirReminderSetupView(lowReservoirReminderValue: Int(pumpManager.lowReservoirReminderValue))
  120. view.valueChanged = { [weak self] value in
  121. self?.pumpManager.lowReservoirReminderValue = Double(value)
  122. }
  123. view.continueButtonTapped = { [weak self] in
  124. self?.pumpManager.initialConfigurationCompleted = true
  125. self?.stepFinished()
  126. }
  127. view.cancelButtonTapped = { [weak self] in
  128. self?.setupCanceled()
  129. }
  130. let hostedView = hostingController(rootView: view)
  131. hostedView.navigationItem.title = LocalizedString("Low Reservoir", comment: "Title for LowReservoirReminderSetupView")
  132. hostedView.navigationItem.backButtonDisplayMode = .generic
  133. return hostedView
  134. case .insulinTypeSelection:
  135. let didConfirm: (InsulinType) -> Void = { [weak self] (confirmedType) in
  136. self?.pumpManager.insulinType = confirmedType
  137. self?.stepFinished()
  138. }
  139. let didCancel: () -> Void = { [weak self] in
  140. self?.setupCanceled()
  141. }
  142. let insulinSelectionView = InsulinTypeConfirmation(initialValue: .novolog, supportedInsulinTypes: allowedInsulinTypes, didConfirm: didConfirm, didCancel: didCancel)
  143. let hostedView = hostingController(rootView: insulinSelectionView)
  144. hostedView.navigationItem.title = LocalizedString("Insulin Type", comment: "Title for insulin type selection screen")
  145. return hostedView
  146. case .deactivate:
  147. let viewModel = DeactivatePodViewModel(podDeactivator: pumpManager, podAttachedToBody: pumpManager.podAttachmentConfirmed, fault: pumpManager.state.podState?.fault)
  148. viewModel.didFinish = { [weak self] in
  149. self?.stepFinished()
  150. }
  151. viewModel.didCancel = { [weak self] in
  152. self?.setupCanceled()
  153. }
  154. let view = DeactivatePodView(viewModel: viewModel)
  155. let hostedView = hostingController(rootView: view)
  156. hostedView.navigationItem.title = LocalizedString("Deactivate Pod", comment: "Title for deactivate pod screen")
  157. return hostedView
  158. case .settings:
  159. let viewModel = OmnipodSettingsViewModel(pumpManager: pumpManager)
  160. viewModel.didFinish = { [weak self] in
  161. self?.stepFinished()
  162. }
  163. viewModel.navigateTo = { [weak self] (screen) in
  164. self?.navigateTo(screen)
  165. }
  166. let rileyLinkListDataSource = RileyLinkListDataSource(rileyLinkPumpManager: pumpManager)
  167. let handleRileyLinkSelection = { [weak self] (device: RileyLinkDevice) in
  168. if let self = self {
  169. let vc = RileyLinkDeviceTableViewController(
  170. device: device,
  171. batteryAlertLevel: self.pumpManager.rileyLinkBatteryAlertLevel,
  172. batteryAlertLevelChanged: { [weak self] value in
  173. self?.pumpManager.rileyLinkBatteryAlertLevel = value
  174. }
  175. )
  176. self.show(vc, sender: self)
  177. }
  178. }
  179. let view = OmnipodSettingsView(viewModel: viewModel, rileyLinkListDataSource: rileyLinkListDataSource, handleRileyLinkSelection: handleRileyLinkSelection, supportedInsulinTypes: allowedInsulinTypes)
  180. return hostingController(rootView: view)
  181. case .pairAndPrime:
  182. pumpManagerOnboardingDelegate?.pumpManagerOnboarding(didCreatePumpManager: pumpManager)
  183. let viewModel = PairPodViewModel(podPairer: pumpManager)
  184. viewModel.didFinish = { [weak self] in
  185. self?.stepFinished()
  186. }
  187. viewModel.didCancelSetup = { [weak self] in
  188. self?.setupCanceled()
  189. }
  190. viewModel.didRequestDeactivation = { [weak self] in
  191. self?.navigateTo(.deactivate)
  192. }
  193. let view = hostingController(rootView: PairPodView(viewModel: viewModel))
  194. view.navigationItem.title = LocalizedString("Pair Pod", comment: "Title for pod pairing screen")
  195. view.navigationItem.backButtonDisplayMode = .generic
  196. return view
  197. case .confirmAttachment:
  198. let view = AttachPodView(
  199. didConfirmAttachment: { [weak self] in
  200. self?.pumpManager.podAttachmentConfirmed = true
  201. self?.stepFinished()
  202. },
  203. didRequestDeactivation: { [weak self] in
  204. self?.navigateTo(.deactivate)
  205. })
  206. let vc = hostingController(rootView: view)
  207. vc.navigationItem.title = LocalizedString("Attach Pod", comment: "Title for Attach Pod screen")
  208. vc.navigationItem.hidesBackButton = true
  209. return vc
  210. case .insertCannula:
  211. let viewModel = InsertCannulaViewModel(cannulaInserter: pumpManager)
  212. viewModel.didFinish = { [weak self] in
  213. self?.stepFinished()
  214. }
  215. viewModel.didRequestDeactivation = { [weak self] in
  216. self?.navigateTo(.deactivate)
  217. }
  218. let view = hostingController(rootView: InsertCannulaView(viewModel: viewModel))
  219. view.navigationItem.title = LocalizedString("Insert Cannula", comment: "Title for insert cannula screen")
  220. view.navigationItem.hidesBackButton = true
  221. return view
  222. case .checkInsertedCannula:
  223. let view = CheckInsertedCannulaView(
  224. didRequestDeactivation: { [weak self] in
  225. self?.navigateTo(.deactivate)
  226. },
  227. wasInsertedProperly: { [weak self] in
  228. self?.stepFinished()
  229. }
  230. )
  231. let hostedView = hostingController(rootView: view)
  232. hostedView.navigationItem.title = LocalizedString("Check Cannula", comment: "Title for check cannula screen")
  233. hostedView.navigationItem.hidesBackButton = true
  234. return hostedView
  235. case .setupComplete:
  236. guard let podExpiresAt = pumpManager.expiresAt,
  237. let allowedExpirationReminderDates = pumpManager.allowedExpirationReminderDates
  238. else {
  239. fatalError("Cannot show setup complete UI without expiration and allowed reminder dates.")
  240. }
  241. let formatter = DateFormatter()
  242. formatter.dateStyle = .medium
  243. formatter.timeStyle = .short
  244. let view = SetupCompleteView(
  245. scheduledReminderDate: pumpManager.scheduledExpirationReminder,
  246. dateFormatter: formatter,
  247. allowedDates: allowedExpirationReminderDates,
  248. onSaveScheduledExpirationReminder: { [weak self] (newExpirationReminderDate, completion) in
  249. var intervalBeforeExpiration : TimeInterval?
  250. if let newExpirationReminderDate = newExpirationReminderDate {
  251. intervalBeforeExpiration = podExpiresAt.timeIntervalSince(newExpirationReminderDate)
  252. }
  253. self?.pumpManager.updateExpirationReminder(intervalBeforeExpiration, completion: completion)
  254. },
  255. didFinish: { [weak self] in
  256. self?.stepFinished()
  257. },
  258. didRequestDeactivation: { [weak self] in
  259. self?.navigateTo(.deactivate)
  260. }
  261. )
  262. let hostedView = hostingController(rootView: view)
  263. hostedView.navigationItem.title = LocalizedString("Setup Complete", comment: "Title for setup complete screen")
  264. return hostedView
  265. case .pendingCommandRecovery:
  266. if let pendingCommand = pumpManager.state.podState?.unacknowledgedCommand, pumpManager.state.podState?.needsCommsRecovery == true {
  267. let model = DeliveryUncertaintyRecoveryViewModel(appName: appName, uncertaintyStartedAt: pendingCommand.commandDate)
  268. model.didRecover = { [weak self] in
  269. self?.navigateTo(.uncertaintyRecovered)
  270. }
  271. model.onDeactivate = { [weak self] in
  272. self?.navigateTo(.deactivate)
  273. }
  274. model.onDismiss = { [weak self] in
  275. if let self = self {
  276. self.completionDelegate?.completionNotifyingDidComplete(self)
  277. }
  278. }
  279. pumpManager.getPodStatus() { _ in }
  280. let handleRileyLinkSelection = { [weak self] (device: RileyLinkDevice) in
  281. if let self = self {
  282. let vc = RileyLinkDeviceTableViewController(
  283. device: device,
  284. batteryAlertLevel: self.pumpManager.rileyLinkBatteryAlertLevel,
  285. batteryAlertLevelChanged: { [weak self] value in
  286. self?.pumpManager.rileyLinkBatteryAlertLevel = value
  287. }
  288. )
  289. self.show(vc, sender: self)
  290. }
  291. }
  292. let dataSource = RileyLinkListDataSource(rileyLinkPumpManager: pumpManager)
  293. let view = DeliveryUncertaintyRecoveryView(model: model, rileyLinkListDataSource: dataSource, handleRileyLinkSelection: handleRileyLinkSelection)
  294. let hostedView = hostingController(rootView: view)
  295. hostedView.navigationItem.title = LocalizedString("Unable To Reach Pod", comment: "Title for pending command recovery screen")
  296. return hostedView
  297. } else {
  298. fatalError("Pending command recovery UI attempted without pending command")
  299. }
  300. case .uncertaintyRecovered:
  301. var view = UncertaintyRecoveredView(appName: appName)
  302. view.didFinish = { [weak self] in
  303. self?.stepFinished()
  304. }
  305. let hostedView = hostingController(rootView: view)
  306. hostedView.navigationItem.title = LocalizedString("Comms Recovered", comment: "Title for uncertainty recovered screen")
  307. return hostedView
  308. }
  309. }
  310. private func hostingController<Content: View>(rootView: Content) -> DismissibleHostingController {
  311. return DismissibleHostingController(rootView: rootView, colorPalette: colorPalette)
  312. }
  313. private func stepFinished() {
  314. if let nextStep = currentScreen.next() {
  315. navigateTo(nextStep)
  316. } else {
  317. completionDelegate?.completionNotifyingDidComplete(self)
  318. }
  319. }
  320. func navigateTo(_ screen: OmnipodUIScreen) {
  321. screenStack.append(screen)
  322. let viewController = viewControllerForScreen(screen)
  323. viewController.isModalInPresentation = false
  324. self.pushViewController(viewController, animated: true)
  325. viewController.view.layoutSubviews()
  326. }
  327. private func setupCanceled() {
  328. completionDelegate?.completionNotifyingDidComplete(self)
  329. }
  330. init(pumpManager: OmnipodPumpManager? = nil, colorPalette: LoopUIColorPalette, pumpManagerSettings: PumpManagerSetupSettings? = nil, allowDebugFeatures: Bool, allowedInsulinTypes: [InsulinType] = [])
  331. {
  332. if pumpManager == nil, let pumpManagerSettings = pumpManagerSettings {
  333. let basalSchedule = pumpManagerSettings.basalSchedule
  334. let deviceProvider = RileyLinkBluetoothDeviceProvider(autoConnectIDs: [])
  335. let pumpManagerState = OmnipodPumpManagerState(
  336. isOnboarded: false,
  337. podState: nil,
  338. timeZone: basalSchedule.timeZone,
  339. basalSchedule: BasalSchedule(repeatingScheduleValues: basalSchedule.items),
  340. rileyLinkConnectionManagerState: nil,
  341. insulinType: nil,
  342. maximumTempBasalRate: pumpManagerSettings.maxBasalRateUnitsPerHour)
  343. self.pumpManager = OmnipodPumpManager(state: pumpManagerState, rileyLinkDeviceProvider: deviceProvider)
  344. } else {
  345. guard let pumpManager = pumpManager else {
  346. fatalError("Unable to create Omnipod PumpManager")
  347. }
  348. self.pumpManager = pumpManager
  349. }
  350. self.colorPalette = colorPalette
  351. self.allowDebugFeatures = allowDebugFeatures
  352. self.allowedInsulinTypes = allowedInsulinTypes
  353. super.init(navigationBarClass: UINavigationBar.self, toolbarClass: UIToolbar.self)
  354. }
  355. required init?(coder aDecoder: NSCoder) {
  356. fatalError("init(coder:) has not been implemented")
  357. }
  358. private func determineInitialStep() -> OmnipodUIScreen {
  359. if pumpManager.state.podState?.needsCommsRecovery == true {
  360. return .pendingCommandRecovery
  361. } else if pumpManager.podCommState == .activating {
  362. if pumpManager.podAttachmentConfirmed {
  363. return .insertCannula
  364. } else {
  365. return .pairAndPrime // need to finish the priming
  366. }
  367. } else if !pumpManager.isOnboarded {
  368. if !pumpManager.initialConfigurationCompleted {
  369. return .firstRunScreen
  370. }
  371. return .pairAndPrime // pair and prime a new pod
  372. } else {
  373. return .settings
  374. }
  375. }
  376. override func viewWillAppear(_ animated: Bool) {
  377. super.viewWillAppear(animated)
  378. if screenStack.isEmpty {
  379. screenStack = [determineInitialStep()]
  380. let viewController = viewControllerForScreen(currentScreen)
  381. viewController.isModalInPresentation = false
  382. setViewControllers([viewController], animated: false)
  383. }
  384. }
  385. override func viewDidDisappear(_ animated: Bool) {
  386. super.viewDidDisappear(animated)
  387. completionDelegate?.completionNotifyingDidComplete(self)
  388. }
  389. var customTraitCollection: UITraitCollection {
  390. // Select height reduced layouts on iPhone SE and iPod Touch,
  391. // and select regular width layouts on larger screens, for list rendering styles
  392. if UIScreen.main.bounds.height <= 640 {
  393. return UITraitCollection(traitsFrom: [super.traitCollection, UITraitCollection(verticalSizeClass: .compact)])
  394. } else {
  395. return UITraitCollection(traitsFrom: [super.traitCollection, UITraitCollection(horizontalSizeClass: .regular)])
  396. }
  397. }
  398. override func viewDidLoad() {
  399. super.viewDidLoad()
  400. self.navigationBar.prefersLargeTitles = true
  401. delegate = self
  402. }
  403. public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
  404. setOverrideTraitCollection(customTraitCollection, forChild: viewController)
  405. if viewControllers.count < screenStack.count {
  406. // Navigation back
  407. let _ = screenStack.popLast()
  408. }
  409. viewController.view.backgroundColor = UIColor.secondarySystemBackground
  410. }
  411. let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String
  412. }