OmnipodSettingsViewController.swift 44 KB

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