OmnipodSettingsViewController.swift 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  1. //
  2. // OmnipodSettingsViewController.swift
  3. // OmniKitUI
  4. //
  5. // Created by Pete Schwamb on 8/5/18.
  6. // Copyright © 2018 Pete Schwamb. All rights reserved.
  7. //
  8. import UIKit
  9. import RileyLinkKitUI
  10. import LoopKit
  11. import OmniKit
  12. import LoopKitUI
  13. public class ConfirmationBeepsTableViewCell: TextButtonTableViewCell {
  14. public func updateTextLabel(enabled: Bool) {
  15. if enabled {
  16. self.textLabel?.text = LocalizedString("Disable Confirmation Beeps", comment: "Title text for button to disable confirmation beeps")
  17. } else {
  18. self.textLabel?.text = LocalizedString("Enable Confirmation Beeps", comment: "Title text for button to enable confirmation beeps")
  19. }
  20. }
  21. override public func loadingStatusChanged() {
  22. self.isEnabled = !isLoading
  23. }
  24. }
  25. public class AutoBolusBeepsTableViewCell: TextButtonTableViewCell {
  26. public func updateTextLabel(enabled: Bool) {
  27. if enabled {
  28. self.textLabel?.text = LocalizedString("Disable Automatic Bolus Beeps", comment: "Title text for button to disable automatic bolus beeps")
  29. } else {
  30. self.textLabel?.text = LocalizedString("Enable Automatic Bolus Beeps", comment: "Title text for button to enable automatic bolus beeps")
  31. }
  32. }
  33. }
  34. class OmnipodSettingsViewController: RileyLinkSettingsViewController {
  35. let pumpManager: OmnipodPumpManager
  36. var statusError: Error?
  37. var podState: PodState? {
  38. didSet {
  39. refreshButton.isHidden = !refreshAvailable
  40. }
  41. }
  42. var pumpManagerStatus: PumpManagerStatus?
  43. var refreshAvailable: Bool {
  44. return podState != nil
  45. }
  46. private var bolusProgressTimer: Timer?
  47. init(pumpManager: OmnipodPumpManager) {
  48. self.pumpManager = pumpManager
  49. podState = pumpManager.state.podState
  50. pumpManagerStatus = pumpManager.status
  51. let devicesSectionIndex = OmnipodSettingsViewController.sectionList(podState).firstIndex(of: .rileyLinks)!
  52. super.init(rileyLinkPumpManager: pumpManager, devicesSectionIndex: devicesSectionIndex, style: .grouped)
  53. pumpManager.addStatusObserver(self, queue: .main)
  54. pumpManager.addPodStateObserver(self, queue: .main)
  55. }
  56. required init?(coder aDecoder: NSCoder) {
  57. fatalError("init(coder:) has not been implemented")
  58. }
  59. lazy var suspendResumeTableViewCell: SuspendResumeTableViewCell = {
  60. let cell = SuspendResumeTableViewCell(style: .default, reuseIdentifier: nil)
  61. cell.basalDeliveryState = pumpManager.status.basalDeliveryState
  62. return cell
  63. }()
  64. lazy var confirmationBeepsTableViewCell: ConfirmationBeepsTableViewCell = {
  65. let cell = ConfirmationBeepsTableViewCell(style: .default, reuseIdentifier: nil)
  66. cell.updateTextLabel(enabled: pumpManager.confirmationBeeps)
  67. return cell
  68. }()
  69. lazy var autoBolusBeepsTableViewCell: AutoBolusBeepsTableViewCell = {
  70. let cell = AutoBolusBeepsTableViewCell(style: .default, reuseIdentifier: nil)
  71. cell.updateTextLabel(enabled: pumpManager.automaticBolusBeeps)
  72. cell.isEnabled = self.pumpManager.confirmationBeeps
  73. return cell
  74. }()
  75. var activityIndicator: UIActivityIndicatorView!
  76. var refreshButton: UIButton!
  77. override func viewDidLoad() {
  78. super.viewDidLoad()
  79. title = LocalizedString("Pod Settings", comment: "Title of the pod settings view controller")
  80. tableView.rowHeight = UITableView.automaticDimension
  81. tableView.estimatedRowHeight = 44
  82. tableView.sectionHeaderHeight = UITableView.automaticDimension
  83. tableView.estimatedSectionHeaderHeight = 55
  84. tableView.register(SettingsTableViewCell.self, forCellReuseIdentifier: SettingsTableViewCell.className)
  85. tableView.register(TextButtonTableViewCell.self, forCellReuseIdentifier: TextButtonTableViewCell.className)
  86. tableView.register(AlarmsTableViewCell.self, forCellReuseIdentifier: AlarmsTableViewCell.className)
  87. tableView.register(ExpirationReminderDateTableViewCell.nib(), forCellReuseIdentifier: ExpirationReminderDateTableViewCell.className)
  88. let podImage = UIImage(named: "PodLarge", in: Bundle(for: OmnipodSettingsViewController.self), compatibleWith: nil)!
  89. let imageView = UIImageView(image: podImage)
  90. imageView.contentMode = .center
  91. imageView.frame.size.height += 18 // feels right
  92. let activityIndicatorStyle: UIActivityIndicatorView.Style
  93. if #available(iOSApplicationExtension 13.0, *) {
  94. activityIndicatorStyle = .medium
  95. } else {
  96. activityIndicatorStyle = .white
  97. }
  98. activityIndicator = UIActivityIndicatorView(style: activityIndicatorStyle)
  99. activityIndicator.hidesWhenStopped = true
  100. imageView.addSubview(activityIndicator)
  101. refreshButton = UIButton(type: .custom)
  102. if #available(iOSApplicationExtension 13.0, *) {
  103. let medConfig = UIImage.SymbolConfiguration(pointSize: 21, weight: .bold, scale: .medium)
  104. refreshButton.setImage(UIImage(systemName: "arrow.clockwise", withConfiguration: medConfig), for: .normal)
  105. refreshButton.tintColor = .systemFill
  106. }
  107. refreshButton.addTarget(self, action: #selector(refreshTapped(_:)), for: .touchUpInside)
  108. imageView.isUserInteractionEnabled = true
  109. imageView.addSubview(refreshButton)
  110. let margin: CGFloat = 15
  111. activityIndicator.translatesAutoresizingMaskIntoConstraints = false
  112. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  113. let parent = imageView.layoutMarginsGuide
  114. NSLayoutConstraint.activate([
  115. activityIndicator.trailingAnchor.constraint(equalTo: parent.trailingAnchor, constant: -margin),
  116. activityIndicator.bottomAnchor.constraint(equalTo: parent.bottomAnchor, constant: -margin),
  117. refreshButton.centerYAnchor.constraint(equalTo: activityIndicator.centerYAnchor),
  118. refreshButton.centerXAnchor.constraint(equalTo: activityIndicator.centerXAnchor),
  119. ])
  120. tableView.tableHeaderView = imageView
  121. if #available(iOSApplicationExtension 13.0, *) {
  122. tableView.tableHeaderView?.backgroundColor = .systemBackground
  123. } else {
  124. tableView.tableHeaderView?.backgroundColor = UIColor.white
  125. }
  126. let button = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped(_:)))
  127. self.navigationItem.setRightBarButton(button, animated: false)
  128. if self.podState != nil {
  129. refreshPodStatus(emitConfirmationBeep: false)
  130. } else {
  131. refreshButton.isHidden = true
  132. }
  133. }
  134. @objc func doneTapped(_ sender: Any) {
  135. done()
  136. }
  137. @objc func refreshTapped(_ sender: Any) {
  138. refreshPodStatus(emitConfirmationBeep: true)
  139. }
  140. private func refreshPodStatus(emitConfirmationBeep: Bool) {
  141. refreshButton.alpha = 0
  142. activityIndicator.startAnimating()
  143. pumpManager.refreshStatus(emitConfirmationBeep: emitConfirmationBeep) { (_) in
  144. DispatchQueue.main.async {
  145. self.refreshButton.alpha = 1
  146. self.activityIndicator.stopAnimating()
  147. }
  148. }
  149. }
  150. private func done() {
  151. if let nav = navigationController as? SettingsNavigationViewController {
  152. nav.notifyComplete()
  153. }
  154. if let nav = navigationController as? OmnipodPumpManagerSetupViewController {
  155. nav.finishedSettingsDisplay()
  156. }
  157. }
  158. override func viewWillAppear(_ animated: Bool) {
  159. if clearsSelectionOnViewWillAppear {
  160. // Manually invoke the delegate for rows deselecting on appear
  161. for indexPath in tableView.indexPathsForSelectedRows ?? [] {
  162. _ = tableView(tableView, willDeselectRowAt: indexPath)
  163. }
  164. }
  165. if let configSectionIdx = self.sections.firstIndex(of: .configuration),
  166. let replacePodRowIdx = self.configurationRows.firstIndex(of: .replacePod)
  167. {
  168. self.tableView.reloadRows(at: [IndexPath(row: replacePodRowIdx, section: configSectionIdx)], with: .none)
  169. }
  170. super.viewWillAppear(animated)
  171. }
  172. // MARK: - Formatters
  173. private lazy var dateFormatter: DateFormatter = {
  174. let dateFormatter = DateFormatter()
  175. dateFormatter.timeStyle = .short
  176. dateFormatter.dateStyle = .medium
  177. dateFormatter.doesRelativeDateFormatting = true
  178. //dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "EEEE 'at' hm", options: 0, locale: nil)
  179. return dateFormatter
  180. }()
  181. // MARK: - Data Source
  182. private enum Section: Int, CaseIterable {
  183. case status = 0
  184. case configuration
  185. case rileyLinks
  186. case diagnostics
  187. case podDetails
  188. case deletePumpManager
  189. }
  190. private class func sectionList(_ podState: PodState?) -> [Section] {
  191. if let podState = podState {
  192. if podState.unfinishedSetup {
  193. return [.configuration, .rileyLinks]
  194. } else {
  195. return [.status, .configuration, .rileyLinks, .diagnostics, .podDetails]
  196. }
  197. } else {
  198. return [.configuration, .rileyLinks, .deletePumpManager]
  199. }
  200. }
  201. private var sections: [Section] {
  202. return OmnipodSettingsViewController.sectionList(podState)
  203. }
  204. private enum PodDetailsRow: Int, CaseIterable {
  205. case podAddress = 0
  206. case podLot
  207. case podTid
  208. case piVersion
  209. case pmVersion
  210. }
  211. private enum Diagnostics: Int, CaseIterable {
  212. case readPodStatus = 0
  213. case playTestBeeps
  214. case readPulseLog
  215. case testCommand
  216. }
  217. private var configurationRows: [ConfigurationRow] {
  218. if podState == nil || podState?.unfinishedSetup == true {
  219. return [.replacePod]
  220. } else {
  221. return ConfigurationRow.allCases
  222. }
  223. }
  224. private enum ConfigurationRow: Int, CaseIterable {
  225. case suspendResume = 0
  226. case enableDisableConfirmationBeeps
  227. case enableDisableAutoBolusBeeps
  228. case reminder
  229. case timeZoneOffset
  230. case insulinType
  231. case replacePod
  232. }
  233. fileprivate enum StatusRow: Int, CaseIterable {
  234. case expiresAt = 0
  235. case podActiveClock
  236. case bolus
  237. case basal
  238. case alarms
  239. case reservoirLevel
  240. case deliveredInsulin
  241. }
  242. // MARK: UITableViewDataSource
  243. override func numberOfSections(in tableView: UITableView) -> Int {
  244. return sections.count
  245. }
  246. override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  247. switch sections[section] {
  248. case .podDetails:
  249. return PodDetailsRow.allCases.count
  250. case .diagnostics:
  251. return Diagnostics.allCases.count
  252. case .configuration:
  253. return configurationRows.count
  254. case .status:
  255. return StatusRow.allCases.count
  256. case .rileyLinks:
  257. return super.tableView(tableView, numberOfRowsInSection: section)
  258. case .deletePumpManager:
  259. return 1
  260. }
  261. }
  262. override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  263. switch sections[section] {
  264. case .status:
  265. return nil // No title, appears below a pod picture
  266. case .configuration:
  267. return LocalizedString("Configuration", comment: "The title of the configuration section in settings")
  268. case .rileyLinks:
  269. return super.tableView(tableView, titleForHeaderInSection: section)
  270. case .diagnostics:
  271. return LocalizedString("Diagnostics", comment: "The title of the diagnostics section in settings")
  272. case .podDetails:
  273. return LocalizedString("Pod Details", comment: "The title of the pod details section in settings")
  274. case .deletePumpManager:
  275. return " " // Use an empty string for more dramatic spacing
  276. }
  277. }
  278. override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
  279. switch sections[section] {
  280. case .rileyLinks:
  281. return super.tableView(tableView, viewForHeaderInSection: section)
  282. case .podDetails, .diagnostics, .configuration, .status, .deletePumpManager:
  283. return nil
  284. }
  285. }
  286. override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  287. switch sections[indexPath.section] {
  288. case .status:
  289. let podState = self.podState!
  290. let statusRow = StatusRow(rawValue: indexPath.row)!
  291. if statusRow == .alarms {
  292. let cell = tableView.dequeueReusableCell(withIdentifier: AlarmsTableViewCell.className, for: indexPath) as! AlarmsTableViewCell
  293. cell.textLabel?.text = LocalizedString("Alarms", comment: "The title of the cell showing alarm status")
  294. cell.alerts = podState.activeAlerts
  295. return cell
  296. }
  297. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  298. switch statusRow {
  299. case .expiresAt:
  300. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  301. if let expiresAt = podState.expiresAt {
  302. if expiresAt.timeIntervalSinceNow > 0 {
  303. cell.textLabel?.text = LocalizedString("Expires", comment: "The title of the cell showing the pod expiration")
  304. } else {
  305. cell.textLabel?.text = LocalizedString("Expired", comment: "The title of the cell showing the pod expiration after expiry")
  306. }
  307. }
  308. cell.setDetailDate(podState.expiresAt, formatter: dateFormatter)
  309. return cell
  310. case .podActiveClock:
  311. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  312. cell.textLabel?.text = LocalizedString("Pod Active Clock", comment: "The title of the cell showing the pod active clock")
  313. cell.setDetailAge(podState.expiresAt?.addingTimeInterval(-Pod.nominalPodLife).timeIntervalSinceNow)
  314. return cell
  315. case .bolus:
  316. cell.textLabel?.text = LocalizedString("Bolus Delivery", comment: "The title of the cell showing pod bolus status")
  317. let deliveredUnits: Double?
  318. if let dose = podState.unfinalizedBolus {
  319. deliveredUnits = pumpManager.roundToSupportedBolusVolume(units: dose.progress * dose.units)
  320. } else {
  321. deliveredUnits = nil
  322. }
  323. cell.setDetailBolus(suspended: podState.isSuspended, dose: podState.unfinalizedBolus, deliveredUnits: deliveredUnits)
  324. // TODO: This timer is in the wrong context; should be part of a custom bolus progress cell
  325. // if bolusProgressTimer == nil {
  326. // bolusProgressTimer = Timer.scheduledTimer(withTimeInterval: .seconds(2), repeats: true) { [weak self] (_) in
  327. // self?.tableView.reloadRows(at: [indexPath], with: .none)
  328. // }
  329. // }
  330. case .basal:
  331. cell.textLabel?.text = LocalizedString("Basal Delivery", comment: "The title of the cell showing pod basal status")
  332. cell.setDetailBasal(suspended: podState.isSuspended, dose: podState.unfinalizedTempBasal)
  333. case .reservoirLevel:
  334. cell.textLabel?.text = LocalizedString("Reservoir", comment: "The title of the cell showing reservoir status")
  335. cell.setReservoirDetail(podState.lastInsulinMeasurements)
  336. case .deliveredInsulin:
  337. cell.textLabel?.text = LocalizedString("Insulin Delivered", comment: "The title of the cell showing delivered insulin")
  338. cell.setDeliveredInsulinDetail(podState.lastInsulinMeasurements)
  339. default:
  340. break
  341. }
  342. return cell
  343. case .configuration:
  344. switch configurationRows[indexPath.row] {
  345. case .suspendResume:
  346. return suspendResumeTableViewCell
  347. case .enableDisableConfirmationBeeps:
  348. return confirmationBeepsTableViewCell
  349. case .enableDisableAutoBolusBeeps:
  350. return autoBolusBeepsTableViewCell
  351. case .reminder:
  352. let cell = tableView.dequeueReusableCell(withIdentifier: ExpirationReminderDateTableViewCell.className, for: indexPath) as! ExpirationReminderDateTableViewCell
  353. if let podState = podState, let reminderDate = pumpManager.expirationReminderDate {
  354. cell.titleLabel.text = LocalizedString("Expiration Reminder", comment: "The title of the cell showing the pod expiration reminder date")
  355. cell.date = reminderDate
  356. cell.datePicker.datePickerMode = .dateAndTime
  357. #if swift(>=5.2)
  358. if #available(iOS 14.0, *) {
  359. cell.datePicker.preferredDatePickerStyle = .wheels
  360. }
  361. #endif
  362. cell.datePicker.maximumDate = podState.expiresAt?.addingTimeInterval(-Pod.expirationReminderAlertMinTimeBeforeExpiration)
  363. cell.datePicker.minimumDate = podState.expiresAt?.addingTimeInterval(-Pod.expirationReminderAlertMaxTimeBeforeExpiration)
  364. cell.datePicker.minuteInterval = 1
  365. cell.delegate = self
  366. }
  367. return cell
  368. case .timeZoneOffset:
  369. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  370. cell.textLabel?.text = LocalizedString("Change Time Zone", comment: "The title of the command to change pump time zone")
  371. let localTimeZone = TimeZone.current
  372. let localTimeZoneName = localTimeZone.abbreviation() ?? localTimeZone.identifier
  373. if let timeZone = pumpManagerStatus?.timeZone {
  374. let timeZoneDiff = TimeInterval(timeZone.secondsFromGMT() - localTimeZone.secondsFromGMT())
  375. let formatter = DateComponentsFormatter()
  376. formatter.allowedUnits = [.hour, .minute]
  377. let diffString = timeZoneDiff != 0 ? formatter.string(from: abs(timeZoneDiff)) ?? String(abs(timeZoneDiff)) : ""
  378. cell.detailTextLabel?.text = String(format: LocalizedString("%1$@%2$@%3$@", comment: "The format string for displaying an offset from a time zone: (1: GMT)(2: -)(3: 4:00)"), localTimeZoneName, timeZoneDiff != 0 ? (timeZoneDiff < 0 ? "-" : "+") : "", diffString)
  379. }
  380. cell.accessoryType = .disclosureIndicator
  381. return cell
  382. case .insulinType:
  383. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  384. cell.prepareForReuse()
  385. cell.textLabel?.text = LocalizedString("Insulin Type", comment: "The title of the Insulin Type")
  386. cell.detailTextLabel?.text = pumpManager.insulinType?.brandName
  387. cell.accessoryType = .disclosureIndicator
  388. return cell
  389. case .replacePod:
  390. let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath) as! TextButtonTableViewCell
  391. if podState == nil {
  392. cell.textLabel?.text = LocalizedString("Pair New Pod", comment: "The title of the command to pair new pod")
  393. } else if let podState = podState, podState.isFaulted {
  394. cell.textLabel?.text = LocalizedString("Replace Pod Now", comment: "The title of the command to replace pod when there is a pod fault")
  395. } else if let podState = podState, podState.unfinishedSetup {
  396. cell.textLabel?.text = LocalizedString("Finish pod setup", comment: "The title of the command to finish pod setup")
  397. } else {
  398. cell.textLabel?.text = LocalizedString("Replace Pod", comment: "The title of the command to replace pod")
  399. cell.tintColor = .deleteColor
  400. }
  401. cell.isEnabled = true
  402. return cell
  403. }
  404. case .rileyLinks:
  405. return super.tableView(tableView, cellForRowAt: indexPath)
  406. case .diagnostics:
  407. switch Diagnostics(rawValue: indexPath.row)! {
  408. case .readPodStatus:
  409. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  410. cell.textLabel?.text = LocalizedString("Read Pod Status", comment: "The title of the command to read the pod status")
  411. cell.accessoryType = .disclosureIndicator
  412. return cell
  413. case .playTestBeeps:
  414. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  415. cell.textLabel?.text = LocalizedString("Play Test Beeps", comment: "The title of the command to play test beeps")
  416. cell.accessoryType = .disclosureIndicator
  417. return cell
  418. case .readPulseLog:
  419. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  420. cell.textLabel?.text = LocalizedString("Read Pulse Log", comment: "The title of the command to read the pulse log")
  421. cell.accessoryType = .disclosureIndicator
  422. return cell
  423. case .testCommand:
  424. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  425. cell.textLabel?.text = LocalizedString("Test Command", comment: "The title of the command to run the test command")
  426. cell.accessoryType = .disclosureIndicator
  427. return cell
  428. }
  429. case .podDetails:
  430. let podState = self.podState!
  431. switch PodDetailsRow(rawValue: indexPath.row)! {
  432. case .podAddress:
  433. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  434. cell.textLabel?.text = LocalizedString("Assigned Address", comment: "The title text for the address assigned to the pod")
  435. cell.detailTextLabel?.text = String(format:"%04X", podState.address)
  436. return cell
  437. case .podLot:
  438. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  439. cell.textLabel?.text = LocalizedString("Lot", comment: "The title of the cell showing the pod lot id")
  440. cell.detailTextLabel?.text = String(format:"L%d", podState.lot)
  441. return cell
  442. case .podTid:
  443. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  444. cell.textLabel?.text = LocalizedString("TID", comment: "The title of the cell showing the pod TID")
  445. cell.detailTextLabel?.text = String(format:"%07d", podState.tid)
  446. return cell
  447. case .piVersion:
  448. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  449. cell.textLabel?.text = LocalizedString("PI Version", comment: "The title of the cell showing the pod pi version")
  450. cell.detailTextLabel?.text = podState.piVersion
  451. return cell
  452. case .pmVersion:
  453. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  454. cell.textLabel?.text = LocalizedString("PM Version", comment: "The title of the cell showing the pod pm version")
  455. cell.detailTextLabel?.text = podState.pmVersion
  456. return cell
  457. }
  458. case .deletePumpManager:
  459. let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath) as! TextButtonTableViewCell
  460. cell.textLabel?.text = LocalizedString("Switch from Omnipod Pumps", comment: "Title text for the button to delete Omnipod PumpManager")
  461. cell.textLabel?.textAlignment = .center
  462. cell.tintColor = .deleteColor
  463. cell.isEnabled = true
  464. return cell
  465. }
  466. }
  467. override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
  468. switch sections[indexPath.section] {
  469. case .status:
  470. switch StatusRow(rawValue: indexPath.row)! {
  471. case .alarms:
  472. return true
  473. default:
  474. return false
  475. }
  476. case .configuration, .rileyLinks, .diagnostics, .deletePumpManager:
  477. return true
  478. case .podDetails:
  479. return false
  480. }
  481. }
  482. override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  483. if indexPath == IndexPath(row: ConfigurationRow.reminder.rawValue, section: Section.configuration.rawValue) {
  484. tableView.beginUpdates()
  485. }
  486. return indexPath
  487. }
  488. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  489. let sender = tableView.cellForRow(at: indexPath)
  490. switch sections[indexPath.section] {
  491. case .status:
  492. switch StatusRow(rawValue: indexPath.row)! {
  493. case .alarms:
  494. if let cell = tableView.cellForRow(at: indexPath) as? AlarmsTableViewCell {
  495. let activeSlots = AlertSet(slots: Array(cell.alerts.keys))
  496. if activeSlots.count > 0 {
  497. cell.isLoading = true
  498. cell.isEnabled = false
  499. pumpManager.acknowledgeAlerts(activeSlots) { (updatedAlerts) in
  500. DispatchQueue.main.async {
  501. cell.isLoading = false
  502. cell.isEnabled = true
  503. if let updatedAlerts = updatedAlerts {
  504. cell.alerts = updatedAlerts
  505. }
  506. }
  507. }
  508. }
  509. tableView.deselectRow(at: indexPath, animated: true)
  510. }
  511. default:
  512. break
  513. }
  514. case .configuration:
  515. switch configurationRows[indexPath.row] {
  516. case .suspendResume:
  517. suspendResumeTapped()
  518. tableView.deselectRow(at: indexPath, animated: true)
  519. case .enableDisableConfirmationBeeps:
  520. confirmationBeepsTapped()
  521. tableView.deselectRow(at: indexPath, animated: true)
  522. case .enableDisableAutoBolusBeeps:
  523. autoBolusBeepsTapped()
  524. tableView.deselectRow(at: indexPath, animated: true)
  525. case .reminder:
  526. tableView.deselectRow(at: indexPath, animated: true)
  527. tableView.endUpdates()
  528. case .timeZoneOffset:
  529. let vc = CommandResponseViewController.changeTime(pumpManager: pumpManager)
  530. vc.title = sender?.textLabel?.text
  531. show(vc, sender: indexPath)
  532. case .insulinType:
  533. let view = InsulinTypeSetting(initialValue: pumpManager.insulinType ?? .novolog, supportedInsulinTypes: InsulinType.allCases) { (newType) in
  534. self.pumpManager.insulinType = newType
  535. }
  536. let vc = DismissibleHostingController(rootView: view)
  537. vc.title = LocalizedString("Insulin Type", comment: "Controller title for insulin type selection screen")
  538. show(vc, sender: sender)
  539. case .replacePod:
  540. let vc: UIViewController
  541. if let podState = podState, podState.isFaulted {
  542. vc = PodReplacementNavigationController.instantiatePodReplacementFlow(pumpManager)
  543. } else if podState == nil || podState!.setupProgress.primingNeeded {
  544. vc = PodReplacementNavigationController.instantiateNewPodFlow(pumpManager)
  545. } else if let podState = podState, podState.unfinishedSetup {
  546. vc = PodReplacementNavigationController.instantiateInsertCannulaFlow(pumpManager)
  547. } else {
  548. vc = PodReplacementNavigationController.instantiatePodReplacementFlow(pumpManager)
  549. }
  550. if var completionNotifying = vc as? CompletionNotifying {
  551. completionNotifying.completionDelegate = self
  552. }
  553. self.navigationController?.present(vc, animated: true, completion: nil)
  554. }
  555. case .rileyLinks:
  556. let device = devicesDataSource.devices[indexPath.row]
  557. guard device.hardwareType != nil else {
  558. tableView.deselectRow(at: indexPath, animated: true)
  559. return
  560. }
  561. let vc = RileyLinkDeviceTableViewController(
  562. device: device,
  563. batteryAlertLevel: pumpManager.rileyLinkBatteryAlertLevel,
  564. batteryAlertLevelChanged: { [weak self] value in
  565. self?.pumpManager.rileyLinkBatteryAlertLevel = value
  566. }
  567. )
  568. self.show(vc, sender: sender)
  569. case .diagnostics:
  570. switch Diagnostics(rawValue: indexPath.row)! {
  571. case .readPodStatus:
  572. let vc = CommandResponseViewController.readPodStatus(pumpManager: pumpManager)
  573. vc.title = sender?.textLabel?.text
  574. show(vc, sender: indexPath)
  575. case .playTestBeeps:
  576. let vc = CommandResponseViewController.playTestBeeps(pumpManager: pumpManager)
  577. vc.title = sender?.textLabel?.text
  578. show(vc, sender: indexPath)
  579. case .readPulseLog:
  580. let vc = CommandResponseViewController.readPulseLog(pumpManager: pumpManager)
  581. vc.title = sender?.textLabel?.text
  582. show(vc, sender: indexPath)
  583. case .testCommand:
  584. let vc = CommandResponseViewController.testingCommands(pumpManager: pumpManager)
  585. vc.title = sender?.textLabel?.text
  586. show(vc, sender: indexPath)
  587. }
  588. case .podDetails:
  589. break
  590. case .deletePumpManager:
  591. let confirmVC = UIAlertController(pumpManagerDeletionHandler: {
  592. self.pumpManager.notifyDelegateOfDeactivation {
  593. DispatchQueue.main.async {
  594. self.done()
  595. }
  596. }
  597. })
  598. present(confirmVC, animated: true) {
  599. tableView.deselectRow(at: indexPath, animated: true)
  600. }
  601. }
  602. }
  603. override func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? {
  604. switch sections[indexPath.section] {
  605. case .status:
  606. break
  607. case .configuration:
  608. switch configurationRows[indexPath.row] {
  609. case .suspendResume, .enableDisableConfirmationBeeps, .enableDisableAutoBolusBeeps, .reminder:
  610. break
  611. case .timeZoneOffset, .replacePod, .insulinType:
  612. tableView.reloadRows(at: [indexPath], with: .fade)
  613. }
  614. case .rileyLinks:
  615. break
  616. case .diagnostics:
  617. tableView.reloadRows(at: [indexPath], with: .fade)
  618. case .podDetails, .deletePumpManager:
  619. break
  620. }
  621. return indexPath
  622. }
  623. private func suspendResumeTapped() {
  624. switch suspendResumeTableViewCell.shownAction {
  625. case .resume:
  626. pumpManager.resumeDelivery { (error) in
  627. if let error = error {
  628. DispatchQueue.main.async {
  629. let title = LocalizedString("Error Resuming", comment: "The alert title for a resume error")
  630. self.present(UIAlertController(with: error, title: title), animated: true)
  631. }
  632. }
  633. }
  634. case .suspend:
  635. pumpManager.suspendDelivery { (error) in
  636. if let error = error {
  637. DispatchQueue.main.async {
  638. let title = LocalizedString("Error Suspending", comment: "The alert title for a suspend error")
  639. self.present(UIAlertController(with: error, title: title), animated: true)
  640. }
  641. }
  642. }
  643. default:
  644. break
  645. }
  646. }
  647. private func setConfirmationBeeps(confirmationBeeps: Bool) {
  648. func done() {
  649. DispatchQueue.main.async { [weak self] in
  650. if let self = self {
  651. self.confirmationBeepsTableViewCell.updateTextLabel(enabled: self.pumpManager.confirmationBeeps)
  652. self.confirmationBeepsTableViewCell.isLoading = false
  653. self.autoBolusBeepsTableViewCell.isEnabled = self.pumpManager.confirmationBeeps
  654. }
  655. }
  656. }
  657. confirmationBeepsTableViewCell.isLoading = true
  658. pumpManager.setConfirmationBeeps(enabled: confirmationBeeps, completion: { (error) in
  659. if let error = error {
  660. DispatchQueue.main.async {
  661. let title: String
  662. if confirmationBeeps {
  663. title = LocalizedString("Error enabling confirmation beeps", comment: "The alert title for enable confirmation beeps error")
  664. } else {
  665. title = LocalizedString("Error disabling confirmation beeps", comment: "The alert title for disable confirmation beeps error")
  666. }
  667. self.present(UIAlertController(with: error, title: title), animated: true)
  668. }
  669. }
  670. done()
  671. })
  672. }
  673. private func confirmationBeepsTapped() {
  674. setConfirmationBeeps(confirmationBeeps: !pumpManager.confirmationBeeps)
  675. }
  676. private func autoBolusBeepsTapped() {
  677. let newValue = !pumpManager.automaticBolusBeeps
  678. pumpManager.automaticBolusBeeps = newValue
  679. func done() {
  680. DispatchQueue.main.async { [weak self] in
  681. if let self = self {
  682. self.autoBolusBeepsTableViewCell.updateTextLabel(enabled: newValue)
  683. self.autoBolusBeepsTableViewCell.isLoading = false
  684. }
  685. }
  686. }
  687. // Beep if confirmation beeps are enabled else just update the value displayed
  688. if pumpManager.confirmationBeeps {
  689. self.autoBolusBeepsTableViewCell.isLoading = true
  690. pumpManager.setConfirmationBeeps(enabled: true, completion: { (error) in
  691. done() // no worries if confirmation beep fails for any reason
  692. })
  693. } else {
  694. self.autoBolusBeepsTableViewCell.updateTextLabel(enabled: newValue)
  695. }
  696. }
  697. }
  698. extension OmnipodSettingsViewController: CompletionDelegate {
  699. func completionNotifyingDidComplete(_ object: CompletionNotifying) {
  700. if let vc = object as? UIViewController, vc === presentedViewController {
  701. dismiss(animated: true, completion: nil)
  702. }
  703. }
  704. }
  705. extension OmnipodSettingsViewController: RadioSelectionTableViewControllerDelegate {
  706. func radioSelectionTableViewControllerDidChangeSelectedIndex(_ controller: RadioSelectionTableViewController) {
  707. guard let indexPath = self.tableView.indexPathForSelectedRow else {
  708. return
  709. }
  710. switch sections[indexPath.section] {
  711. case .configuration:
  712. switch configurationRows[indexPath.row] {
  713. default:
  714. assertionFailure()
  715. }
  716. default:
  717. assertionFailure()
  718. }
  719. tableView.reloadRows(at: [indexPath], with: .none)
  720. }
  721. }
  722. extension OmnipodSettingsViewController: PodStateObserver {
  723. func podStateDidUpdate(_ state: PodState?) {
  724. let newSections = OmnipodSettingsViewController.sectionList(state)
  725. let sectionsChanged = OmnipodSettingsViewController.sectionList(self.podState) != newSections
  726. let oldConfigurationRowsCount = self.configurationRows.count
  727. let oldState = self.podState
  728. self.podState = state
  729. if sectionsChanged {
  730. self.devicesDataSource.devicesSectionIndex = self.sections.firstIndex(of: .rileyLinks)!
  731. self.tableView.reloadData()
  732. } else {
  733. if oldConfigurationRowsCount != self.configurationRows.count, let idx = newSections.firstIndex(of: .configuration) {
  734. self.tableView.reloadSections([idx], with: .fade)
  735. }
  736. }
  737. guard let statusIdx = newSections.firstIndex(of: .status) else {
  738. return
  739. }
  740. let reloadRows: [StatusRow] = [.podActiveClock, .bolus, .basal, .reservoirLevel, .deliveredInsulin]
  741. self.tableView.reloadRows(at: reloadRows.map({ IndexPath(row: $0.rawValue, section: statusIdx) }), with: .none)
  742. if oldState?.activeAlerts != state?.activeAlerts,
  743. let alerts = state?.activeAlerts,
  744. let alertCell = self.tableView.cellForRow(at: IndexPath(row: StatusRow.alarms.rawValue, section: statusIdx)) as? AlarmsTableViewCell
  745. {
  746. alertCell.alerts = alerts
  747. }
  748. }
  749. }
  750. extension OmnipodSettingsViewController: PumpManagerStatusObserver {
  751. func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) {
  752. self.pumpManagerStatus = status
  753. self.suspendResumeTableViewCell.basalDeliveryState = status.basalDeliveryState
  754. if let statusSectionIdx = self.sections.firstIndex(of: .status) {
  755. self.tableView.reloadSections([statusSectionIdx], with: .none)
  756. }
  757. }
  758. }
  759. extension OmnipodSettingsViewController: DatePickerTableViewCellDelegate {
  760. func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) {
  761. pumpManager.expirationReminderDate = cell.date
  762. }
  763. }
  764. private extension UIAlertController {
  765. convenience init(pumpManagerDeletionHandler handler: @escaping () -> Void) {
  766. self.init(
  767. title: nil,
  768. message: LocalizedString("Are you sure you want to stop using Omnipod?", comment: "Confirmation message for removing Omnipod PumpManager"),
  769. preferredStyle: .actionSheet
  770. )
  771. addAction(UIAlertAction(
  772. title: LocalizedString("Delete Omnipod", comment: "Button title to delete Omnipod PumpManager"),
  773. style: .destructive,
  774. handler: { (_) in
  775. handler()
  776. }
  777. ))
  778. let cancel = LocalizedString("Cancel", comment: "The title of the cancel action in an action sheet")
  779. addAction(UIAlertAction(title: cancel, style: .cancel, handler: nil))
  780. }
  781. }
  782. private extension TimeInterval {
  783. func format(using units: NSCalendar.Unit) -> String? {
  784. let formatter = DateComponentsFormatter()
  785. formatter.allowedUnits = units
  786. formatter.unitsStyle = .short
  787. formatter.zeroFormattingBehavior = .dropLeading
  788. formatter.maximumUnitCount = 2
  789. return formatter.string(from: self)
  790. }
  791. }
  792. class AlarmsTableViewCell: LoadingTableViewCell {
  793. private var defaultDetailColor: UIColor?
  794. override public init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  795. super.init(style: .value1, reuseIdentifier: reuseIdentifier)
  796. detailTextLabel?.tintAdjustmentMode = .automatic
  797. defaultDetailColor = detailTextLabel?.textColor
  798. }
  799. required public init?(coder aDecoder: NSCoder) {
  800. super.init(coder: aDecoder)
  801. }
  802. private func updateColor() {
  803. if alerts.count == 0 {
  804. detailTextLabel?.textColor = defaultDetailColor
  805. } else {
  806. detailTextLabel?.textColor = tintColor
  807. }
  808. }
  809. public var isEnabled = true {
  810. didSet {
  811. selectionStyle = isEnabled ? .default : .none
  812. }
  813. }
  814. override public func loadingStatusChanged() {
  815. self.detailTextLabel?.isHidden = isLoading
  816. }
  817. var alerts = [AlertSlot: PodAlert]() {
  818. didSet {
  819. updateColor()
  820. if alerts.isEmpty {
  821. detailTextLabel?.text = LocalizedString("None", comment: "Alerts detail when no alerts unacknowledged")
  822. } else {
  823. detailTextLabel?.text = alerts.map { slot, alert in String.init(describing: alert) }.joined(separator: ", ")
  824. }
  825. }
  826. }
  827. open override func tintColorDidChange() {
  828. super.tintColorDidChange()
  829. updateColor()
  830. }
  831. open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  832. super.traitCollectionDidChange(previousTraitCollection)
  833. updateColor()
  834. }
  835. }
  836. private extension UITableViewCell {
  837. private var insulinFormatter: NumberFormatter {
  838. let formatter = NumberFormatter()
  839. formatter.numberStyle = .decimal
  840. formatter.maximumFractionDigits = 3
  841. return formatter
  842. }
  843. private var percentFormatter: NumberFormatter {
  844. let formatter = NumberFormatter()
  845. formatter.numberStyle = .decimal
  846. formatter.maximumFractionDigits = 0
  847. return formatter
  848. }
  849. func setDetailDate(_ date: Date?, formatter: DateFormatter) {
  850. if let date = date {
  851. detailTextLabel?.text = formatter.string(from: date)
  852. } else {
  853. detailTextLabel?.text = "-"
  854. }
  855. }
  856. func setDetailAge(_ age: TimeInterval?) {
  857. if let age = age {
  858. detailTextLabel?.text = fabs(age).format(using: [.hour, .minute])
  859. } else {
  860. detailTextLabel?.text = ""
  861. }
  862. }
  863. func setDetailBasal(suspended: Bool, dose: UnfinalizedDose?) {
  864. if suspended {
  865. detailTextLabel?.text = LocalizedString("Suspended", comment: "The detail text of the basal row when pod is suspended")
  866. } else if let dose = dose {
  867. if let rate = insulinFormatter.string(from: dose.rate) {
  868. detailTextLabel?.text = String(format: LocalizedString("%@ U/hour", comment: "Format string for temp basal rate. (1: The localized amount)"), rate)
  869. }
  870. } else {
  871. detailTextLabel?.text = LocalizedString("Schedule", comment: "The detail text of the basal row when pod is running scheduled basal")
  872. }
  873. }
  874. func setDetailBolus(suspended: Bool, dose: UnfinalizedDose?, deliveredUnits: Double?) {
  875. guard let dose = dose, let delivered = deliveredUnits, !suspended else {
  876. detailTextLabel?.text = LocalizedString("None", comment: "The detail text for bolus delivery when no bolus is being delivered")
  877. return
  878. }
  879. let progress = dose.progress
  880. if let units = self.insulinFormatter.string(from: dose.units), let deliveredUnits = self.insulinFormatter.string(from: delivered) {
  881. if progress >= 1 {
  882. self.detailTextLabel?.text = String(format: LocalizedString("%@ U (Finished)", comment: "Format string for bolus progress when finished. (1: The localized amount)"), units)
  883. } else {
  884. let progressFormatted = percentFormatter.string(from: progress * 100.0) ?? ""
  885. let progressStr = String(format: LocalizedString("%@%%", comment: "Format string for bolus percent progress. (1: Percent progress)"), progressFormatted)
  886. self.detailTextLabel?.text = String(format: LocalizedString("%@ U of %@ U (%@)", comment: "Format string for bolus progress. (1: The delivered amount) (2: The programmed amount) (3: the percent progress)"), deliveredUnits, units, progressStr)
  887. }
  888. }
  889. }
  890. func setDeliveredInsulinDetail(_ measurements: PodInsulinMeasurements?) {
  891. guard let measurements = measurements else {
  892. detailTextLabel?.text = LocalizedString("Unknown", comment: "The detail text for delivered insulin when no measurement is available")
  893. return
  894. }
  895. if let units = insulinFormatter.string(from: measurements.delivered) {
  896. detailTextLabel?.text = String(format: LocalizedString("%@ U", comment: "Format string for delivered insulin. (1: The localized amount)"), units)
  897. }
  898. }
  899. func setReservoirDetail(_ measurements: PodInsulinMeasurements?) {
  900. guard let measurements = measurements else {
  901. detailTextLabel?.text = LocalizedString("Unknown", comment: "The detail text for delivered insulin when no measurement is available")
  902. return
  903. }
  904. if measurements.reservoirLevel == nil {
  905. if let units = insulinFormatter.string(from: Pod.maximumReservoirReading) {
  906. detailTextLabel?.text = String(format: LocalizedString("%@+ U", comment: "Format string for reservoir reading when above or equal to maximum reading. (1: The localized amount)"), units)
  907. }
  908. } else {
  909. if let reservoirValue = measurements.reservoirLevel,
  910. let units = insulinFormatter.string(from: reservoirValue)
  911. {
  912. detailTextLabel?.text = String(format: LocalizedString("%@ U", comment: "Format string for insulin remaining in reservoir. (1: The localized amount)"), units)
  913. }
  914. }
  915. }
  916. }