OmnipodSettingsViewController.swift 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000
  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. let vc = RileyLinkDeviceTableViewController(device: device)
  560. self.show(vc, sender: sender)
  561. case .deletePumpManager:
  562. let confirmVC = UIAlertController(pumpManagerDeletionHandler: {
  563. self.pumpManager.notifyDelegateOfDeactivation {
  564. DispatchQueue.main.async {
  565. self.done()
  566. }
  567. }
  568. })
  569. present(confirmVC, animated: true) {
  570. tableView.deselectRow(at: indexPath, animated: true)
  571. }
  572. }
  573. }
  574. override func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? {
  575. switch sections[indexPath.section] {
  576. case .podDetails, .status:
  577. break
  578. case .diagnostics:
  579. switch Diagnostics(rawValue: indexPath.row)! {
  580. case .readPodStatus, .playTestBeeps, .readPulseLog, .testCommand:
  581. tableView.reloadRows(at: [indexPath], with: .fade)
  582. }
  583. case .configuration:
  584. switch configurationRows[indexPath.row] {
  585. case .suspendResume, .enableDisableConfirmationBeeps, .reminder:
  586. break
  587. case .timeZoneOffset, .replacePod, .insulinType:
  588. tableView.reloadRows(at: [indexPath], with: .fade)
  589. }
  590. case .rileyLinks:
  591. break
  592. case .deletePumpManager:
  593. break
  594. }
  595. return indexPath
  596. }
  597. private func suspendResumeTapped() {
  598. switch suspendResumeTableViewCell.shownAction {
  599. case .resume:
  600. pumpManager.resumeDelivery { (error) in
  601. if let error = error {
  602. DispatchQueue.main.async {
  603. let title = LocalizedString("Error Resuming", comment: "The alert title for a resume error")
  604. self.present(UIAlertController(with: error, title: title), animated: true)
  605. }
  606. }
  607. }
  608. case .suspend:
  609. pumpManager.suspendDelivery { (error) in
  610. if let error = error {
  611. DispatchQueue.main.async {
  612. let title = LocalizedString("Error Suspending", comment: "The alert title for a suspend error")
  613. self.present(UIAlertController(with: error, title: title), animated: true)
  614. }
  615. }
  616. }
  617. default:
  618. break
  619. }
  620. }
  621. private func confirmationBeepsTapped() {
  622. let confirmationBeeps: Bool = pumpManager.confirmationBeeps
  623. func done() {
  624. DispatchQueue.main.async { [weak self] in
  625. if let self = self {
  626. self.confirmationBeepsTableViewCell.updateTextLabel(enabled: self.pumpManager.confirmationBeeps)
  627. self.confirmationBeepsTableViewCell.isLoading = false
  628. }
  629. }
  630. }
  631. confirmationBeepsTableViewCell.isLoading = true
  632. if confirmationBeeps {
  633. pumpManager.setConfirmationBeeps(enabled: false, completion: { (error) in
  634. if let error = error {
  635. DispatchQueue.main.async {
  636. let title = LocalizedString("Error disabling confirmation beeps", comment: "The alert title for disable confirmation beeps error")
  637. self.present(UIAlertController(with: error, title: title), animated: true)
  638. }
  639. }
  640. done()
  641. })
  642. } else {
  643. pumpManager.setConfirmationBeeps(enabled: true, completion: { (error) in
  644. if let error = error {
  645. DispatchQueue.main.async {
  646. let title = LocalizedString("Error enabling confirmation beeps", comment: "The alert title for enable confirmation beeps error")
  647. self.present(UIAlertController(with: error, title: title), animated: true)
  648. }
  649. }
  650. done()
  651. })
  652. }
  653. }
  654. }
  655. extension OmnipodSettingsViewController: CompletionDelegate {
  656. func completionNotifyingDidComplete(_ object: CompletionNotifying) {
  657. if let vc = object as? UIViewController, vc === presentedViewController {
  658. dismiss(animated: true, completion: nil)
  659. }
  660. }
  661. }
  662. extension OmnipodSettingsViewController: RadioSelectionTableViewControllerDelegate {
  663. func radioSelectionTableViewControllerDidChangeSelectedIndex(_ controller: RadioSelectionTableViewController) {
  664. guard let indexPath = self.tableView.indexPathForSelectedRow else {
  665. return
  666. }
  667. switch sections[indexPath.section] {
  668. case .configuration:
  669. switch configurationRows[indexPath.row] {
  670. default:
  671. assertionFailure()
  672. }
  673. default:
  674. assertionFailure()
  675. }
  676. tableView.reloadRows(at: [indexPath], with: .none)
  677. }
  678. }
  679. extension OmnipodSettingsViewController: PodStateObserver {
  680. func podStateDidUpdate(_ state: PodState?) {
  681. let newSections = OmnipodSettingsViewController.sectionList(state)
  682. let sectionsChanged = OmnipodSettingsViewController.sectionList(self.podState) != newSections
  683. let oldConfigurationRowsCount = self.configurationRows.count
  684. let oldState = self.podState
  685. self.podState = state
  686. if sectionsChanged {
  687. self.devicesDataSource.devicesSectionIndex = self.sections.firstIndex(of: .rileyLinks)!
  688. self.tableView.reloadData()
  689. } else {
  690. if oldConfigurationRowsCount != self.configurationRows.count, let idx = newSections.firstIndex(of: .configuration) {
  691. self.tableView.reloadSections([idx], with: .fade)
  692. }
  693. }
  694. guard let statusIdx = newSections.firstIndex(of: .status) else {
  695. return
  696. }
  697. let reloadRows: [StatusRow] = [.bolus, .basal, .reservoirLevel, .deliveredInsulin]
  698. self.tableView.reloadRows(at: reloadRows.map({ IndexPath(row: $0.rawValue, section: statusIdx) }), with: .none)
  699. if oldState?.activeAlerts != state?.activeAlerts,
  700. let alerts = state?.activeAlerts,
  701. let alertCell = self.tableView.cellForRow(at: IndexPath(row: StatusRow.alarms.rawValue, section: statusIdx)) as? AlarmsTableViewCell
  702. {
  703. alertCell.alerts = alerts
  704. }
  705. }
  706. }
  707. extension OmnipodSettingsViewController: PumpManagerStatusObserver {
  708. func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) {
  709. self.pumpManagerStatus = status
  710. self.suspendResumeTableViewCell.basalDeliveryState = status.basalDeliveryState
  711. if let statusSectionIdx = self.sections.firstIndex(of: .status) {
  712. self.tableView.reloadSections([statusSectionIdx], with: .none)
  713. }
  714. }
  715. }
  716. extension OmnipodSettingsViewController: DatePickerTableViewCellDelegate {
  717. func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) {
  718. pumpManager.expirationReminderDate = cell.date
  719. }
  720. }
  721. private extension UIAlertController {
  722. convenience init(pumpManagerDeletionHandler handler: @escaping () -> Void) {
  723. self.init(
  724. title: nil,
  725. message: LocalizedString("Are you sure you want to stop using Omnipod?", comment: "Confirmation message for removing Omnipod PumpManager"),
  726. preferredStyle: .actionSheet
  727. )
  728. addAction(UIAlertAction(
  729. title: LocalizedString("Delete Omnipod", comment: "Button title to delete Omnipod PumpManager"),
  730. style: .destructive,
  731. handler: { (_) in
  732. handler()
  733. }
  734. ))
  735. let cancel = LocalizedString("Cancel", comment: "The title of the cancel action in an action sheet")
  736. addAction(UIAlertAction(title: cancel, style: .cancel, handler: nil))
  737. }
  738. }
  739. private extension TimeInterval {
  740. func format(using units: NSCalendar.Unit) -> String? {
  741. let formatter = DateComponentsFormatter()
  742. formatter.allowedUnits = units
  743. formatter.unitsStyle = .full
  744. formatter.zeroFormattingBehavior = .dropLeading
  745. formatter.maximumUnitCount = 2
  746. return formatter.string(from: self)
  747. }
  748. }
  749. class AlarmsTableViewCell: LoadingTableViewCell {
  750. private var defaultDetailColor: UIColor?
  751. override public init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  752. super.init(style: .value1, reuseIdentifier: reuseIdentifier)
  753. detailTextLabel?.tintAdjustmentMode = .automatic
  754. defaultDetailColor = detailTextLabel?.textColor
  755. }
  756. required public init?(coder aDecoder: NSCoder) {
  757. super.init(coder: aDecoder)
  758. }
  759. private func updateColor() {
  760. if alerts.count == 0 {
  761. detailTextLabel?.textColor = defaultDetailColor
  762. } else {
  763. detailTextLabel?.textColor = tintColor
  764. }
  765. }
  766. public var isEnabled = true {
  767. didSet {
  768. selectionStyle = isEnabled ? .default : .none
  769. }
  770. }
  771. override public func loadingStatusChanged() {
  772. self.detailTextLabel?.isHidden = isLoading
  773. }
  774. var alerts = [AlertSlot: PodAlert]() {
  775. didSet {
  776. updateColor()
  777. if alerts.isEmpty {
  778. detailTextLabel?.text = LocalizedString("None", comment: "Alerts detail when no alerts unacknowledged")
  779. } else {
  780. detailTextLabel?.text = alerts.map { slot, alert in String.init(describing: alert) }.joined(separator: ", ")
  781. }
  782. }
  783. }
  784. open override func tintColorDidChange() {
  785. super.tintColorDidChange()
  786. updateColor()
  787. }
  788. open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  789. super.traitCollectionDidChange(previousTraitCollection)
  790. updateColor()
  791. }
  792. }
  793. private extension UITableViewCell {
  794. private var insulinFormatter: NumberFormatter {
  795. let formatter = NumberFormatter()
  796. formatter.numberStyle = .decimal
  797. formatter.maximumFractionDigits = 3
  798. return formatter
  799. }
  800. private var percentFormatter: NumberFormatter {
  801. let formatter = NumberFormatter()
  802. formatter.numberStyle = .decimal
  803. formatter.maximumFractionDigits = 0
  804. return formatter
  805. }
  806. func setDetailDate(_ date: Date?, formatter: DateFormatter) {
  807. if let date = date {
  808. detailTextLabel?.text = formatter.string(from: date)
  809. } else {
  810. detailTextLabel?.text = "-"
  811. }
  812. }
  813. func setDetailAge(_ age: TimeInterval?) {
  814. if let age = age {
  815. detailTextLabel?.text = fabs(age).format(using: [.day, .hour, .minute])
  816. } else {
  817. detailTextLabel?.text = ""
  818. }
  819. }
  820. func setDetailBasal(suspended: Bool, dose: UnfinalizedDose?) {
  821. if suspended {
  822. detailTextLabel?.text = LocalizedString("Suspended", comment: "The detail text of the basal row when pod is suspended")
  823. } else if let dose = dose {
  824. if let rate = insulinFormatter.string(from: dose.rate) {
  825. detailTextLabel?.text = String(format: LocalizedString("%@ U/hour", comment: "Format string for temp basal rate. (1: The localized amount)"), rate)
  826. }
  827. } else {
  828. detailTextLabel?.text = LocalizedString("Schedule", comment: "The detail text of the basal row when pod is running scheduled basal")
  829. }
  830. }
  831. func setDetailBolus(suspended: Bool, dose: UnfinalizedDose?, deliveredUnits: Double?) {
  832. guard let dose = dose, let delivered = deliveredUnits, !suspended else {
  833. detailTextLabel?.text = LocalizedString("None", comment: "The detail text for bolus delivery when no bolus is being delivered")
  834. return
  835. }
  836. let progress = dose.progress
  837. if let units = self.insulinFormatter.string(from: dose.units), let deliveredUnits = self.insulinFormatter.string(from: delivered) {
  838. if progress >= 1 {
  839. self.detailTextLabel?.text = String(format: LocalizedString("%@ U (Finished)", comment: "Format string for bolus progress when finished. (1: The localized amount)"), units)
  840. } else {
  841. let progressFormatted = percentFormatter.string(from: progress * 100.0) ?? ""
  842. let progressStr = String(format: LocalizedString("%@%%", comment: "Format string for bolus percent progress. (1: Percent progress)"), progressFormatted)
  843. 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)
  844. }
  845. }
  846. }
  847. func setDeliveredInsulinDetail(_ measurements: PodInsulinMeasurements?) {
  848. guard let measurements = measurements else {
  849. detailTextLabel?.text = LocalizedString("Unknown", comment: "The detail text for delivered insulin when no measurement is available")
  850. return
  851. }
  852. if let units = insulinFormatter.string(from: measurements.delivered) {
  853. detailTextLabel?.text = String(format: LocalizedString("%@ U", comment: "Format string for delivered insulin. (1: The localized amount)"), units)
  854. }
  855. }
  856. func setReservoirDetail(_ measurements: PodInsulinMeasurements?) {
  857. guard let measurements = measurements else {
  858. detailTextLabel?.text = LocalizedString("Unknown", comment: "The detail text for delivered insulin when no measurement is available")
  859. return
  860. }
  861. if measurements.reservoirLevel == nil {
  862. if let units = insulinFormatter.string(from: Pod.maximumReservoirReading) {
  863. detailTextLabel?.text = String(format: LocalizedString("%@+ U", comment: "Format string for reservoir reading when above or equal to maximum reading. (1: The localized amount)"), units)
  864. }
  865. } else {
  866. if let reservoirValue = measurements.reservoirLevel,
  867. let units = insulinFormatter.string(from: reservoirValue)
  868. {
  869. detailTextLabel?.text = String(format: LocalizedString("%@ U", comment: "Format string for insulin remaining in reservoir. (1: The localized amount)"), units)
  870. }
  871. }
  872. }
  873. }