TransmitterSettingsViewController.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. //
  2. // TransmitterSettingsViewController.swift
  3. // Loop
  4. //
  5. // Copyright © 2018 LoopKit Authors. All rights reserved.
  6. //
  7. import UIKit
  8. import Combine
  9. import HealthKit
  10. import LoopKit
  11. import LoopKitUI
  12. import CGMBLEKit
  13. import ShareClientUI
  14. class TransmitterSettingsViewController: UITableViewController {
  15. let cgmManager: TransmitterManager & CGMManagerUI
  16. private let displayGlucoseUnitObservable: DisplayGlucoseUnitObservable
  17. private lazy var cancellables = Set<AnyCancellable>()
  18. private var glucoseUnit: HKUnit {
  19. displayGlucoseUnitObservable.displayGlucoseUnit
  20. }
  21. init(cgmManager: TransmitterManager & CGMManagerUI, displayGlucoseUnitObservable: DisplayGlucoseUnitObservable) {
  22. self.cgmManager = cgmManager
  23. self.displayGlucoseUnitObservable = displayGlucoseUnitObservable
  24. super.init(style: .grouped)
  25. cgmManager.addObserver(self, queue: .main)
  26. displayGlucoseUnitObservable.$displayGlucoseUnit
  27. .sink { [weak self] _ in self?.tableView.reloadData() }
  28. .store(in: &cancellables)
  29. }
  30. required init?(coder aDecoder: NSCoder) {
  31. fatalError("init(coder:) has not been implemented")
  32. }
  33. override func viewDidLoad() {
  34. super.viewDidLoad()
  35. title = cgmManager.localizedTitle
  36. tableView.rowHeight = UITableView.automaticDimension
  37. tableView.estimatedRowHeight = 44
  38. tableView.sectionHeaderHeight = UITableView.automaticDimension
  39. tableView.estimatedSectionHeaderHeight = 55
  40. tableView.register(SettingsTableViewCell.self, forCellReuseIdentifier: SettingsTableViewCell.className)
  41. tableView.register(TextButtonTableViewCell.self, forCellReuseIdentifier: TextButtonTableViewCell.className)
  42. tableView.register(SwitchTableViewCell.self, forCellReuseIdentifier: SwitchTableViewCell.className)
  43. let button = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped(_:)))
  44. self.navigationItem.setRightBarButton(button, animated: false)
  45. }
  46. @objc func doneTapped(_ sender: Any) {
  47. complete()
  48. }
  49. private func complete() {
  50. if let nav = navigationController as? SettingsNavigationViewController {
  51. nav.notifyComplete()
  52. }
  53. }
  54. override func viewWillAppear(_ animated: Bool) {
  55. if clearsSelectionOnViewWillAppear {
  56. // Manually invoke the delegate for rows deselecting on appear
  57. for indexPath in tableView.indexPathsForSelectedRows ?? [] {
  58. _ = tableView(tableView, willDeselectRowAt: indexPath)
  59. }
  60. }
  61. super.viewWillAppear(animated)
  62. }
  63. // MARK: - UITableViewDataSource
  64. private enum Section: Int, CaseIterable {
  65. case transmitterID
  66. case remoteDataSync
  67. case latestReading
  68. case latestCalibration
  69. case latestConnection
  70. case ages
  71. case share
  72. case delete
  73. }
  74. override func numberOfSections(in tableView: UITableView) -> Int {
  75. return Section.allCases.count
  76. }
  77. private enum LatestReadingRow: Int, CaseIterable {
  78. case glucose
  79. case date
  80. case trend
  81. case status
  82. }
  83. private enum LatestCalibrationRow: Int, CaseIterable {
  84. case glucose
  85. case date
  86. }
  87. private enum LatestConnectionRow: Int, CaseIterable {
  88. case date
  89. }
  90. private enum AgeRow: Int, CaseIterable {
  91. case sensorAge
  92. case sensorCountdown
  93. case sensorExpirationDate
  94. case transmitter
  95. }
  96. private enum ShareRow: Int, CaseIterable {
  97. case settings
  98. case openApp
  99. }
  100. override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  101. switch Section(rawValue: section)! {
  102. case .transmitterID:
  103. return 1
  104. case .remoteDataSync:
  105. return 1
  106. case .latestReading:
  107. return LatestReadingRow.allCases.count
  108. case .latestCalibration:
  109. return LatestCalibrationRow.allCases.count
  110. case .latestConnection:
  111. return LatestConnectionRow.allCases.count
  112. case .ages:
  113. return AgeRow.allCases.count
  114. case .share:
  115. return ShareRow.allCases.count
  116. case .delete:
  117. return 1
  118. }
  119. }
  120. private lazy var glucoseFormatter: QuantityFormatter = {
  121. let formatter = QuantityFormatter()
  122. formatter.setPreferredNumberFormatter(for: glucoseUnit)
  123. return formatter
  124. }()
  125. private lazy var dateFormatter: DateFormatter = {
  126. let formatter = DateFormatter()
  127. formatter.dateStyle = .long
  128. formatter.timeStyle = .long
  129. formatter.doesRelativeDateFormatting = true
  130. return formatter
  131. }()
  132. private lazy var sensorExpirationFullFormatter: DateFormatter = {
  133. let formatter = DateFormatter()
  134. formatter.dateStyle = .full
  135. formatter.timeStyle = .short
  136. formatter.doesRelativeDateFormatting = true
  137. //formatter.dateFormat = "E, MMM d 'at' h:mm a"
  138. return formatter
  139. }()
  140. private lazy var sensorExpirationRelativeFormatter: DateFormatter = {
  141. let formatter = DateFormatter()
  142. formatter.dateStyle = .long
  143. formatter.timeStyle = .short
  144. formatter.doesRelativeDateFormatting = true
  145. return formatter
  146. }()
  147. private lazy var sensorExpAbsFormatter: DateFormatter = {
  148. let formatter = DateFormatter()
  149. formatter.dateStyle = .long
  150. formatter.timeStyle = .short
  151. formatter.doesRelativeDateFormatting = false
  152. return formatter
  153. }()
  154. private lazy var sessionLengthFormatter: DateComponentsFormatter = {
  155. let formatter = DateComponentsFormatter()
  156. formatter.allowedUnits = [.day, .hour, .minute]
  157. formatter.unitsStyle = .full
  158. formatter.maximumUnitCount = 2
  159. return formatter
  160. }()
  161. private lazy var transmitterLengthFormatter: DateComponentsFormatter = {
  162. let formatter = DateComponentsFormatter()
  163. formatter.allowedUnits = [.day]
  164. formatter.unitsStyle = .full
  165. return formatter
  166. }()
  167. override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  168. switch Section(rawValue: indexPath.section)! {
  169. case .transmitterID:
  170. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell
  171. cell.textLabel?.text = LocalizedString("Transmitter ID", comment: "The title text for the Dexcom G5/G6 transmitter ID config value")
  172. cell.detailTextLabel?.text = cgmManager.transmitter.ID
  173. return cell
  174. case .remoteDataSync:
  175. let switchCell = tableView.dequeueReusableCell(withIdentifier: SwitchTableViewCell.className, for: indexPath) as! SwitchTableViewCell
  176. switchCell.selectionStyle = .none
  177. switchCell.switch?.isOn = cgmManager.shouldSyncToRemoteService
  178. switchCell.textLabel?.text = LocalizedString("Upload Readings", comment: "The title text for the upload glucose switch cell")
  179. switchCell.switch?.addTarget(self, action: #selector(uploadEnabledChanged(_:)), for: .valueChanged)
  180. return switchCell
  181. case .latestReading:
  182. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell
  183. let glucose = cgmManager.latestReading
  184. switch LatestReadingRow(rawValue: indexPath.row)! {
  185. case .glucose:
  186. cell.setGlucose(glucose?.glucose, unit: glucoseUnit, formatter: glucoseFormatter, isDisplayOnly: glucose?.isDisplayOnly ?? false)
  187. case .date:
  188. cell.setGlucoseDate(glucose?.readDate, formatter: dateFormatter)
  189. case .trend:
  190. cell.textLabel?.text = LocalizedString("Trend", comment: "Title describing glucose trend")
  191. if let trendRate = glucose?.trendRate {
  192. let glucoseUnitPerMinute = glucoseUnit.unitDivided(by: .minute())
  193. let trendPerMinute = HKQuantity(unit: glucoseUnit, doubleValue: trendRate.doubleValue(for: glucoseUnitPerMinute))
  194. if let formatted = glucoseFormatter.string(from: trendPerMinute, for: glucoseUnit) {
  195. cell.detailTextLabel?.text = String(format: LocalizedString("%@/min", comment: "Format string for glucose trend per minute. (1: glucose value and unit)"), formatted)
  196. } else {
  197. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  198. }
  199. } else {
  200. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  201. }
  202. case .status:
  203. cell.textLabel?.text = LocalizedString("Status", comment: "Title describing CGM calibration and battery state")
  204. if let stateDescription = glucose?.stateDescription, !stateDescription.isEmpty {
  205. cell.detailTextLabel?.text = stateDescription
  206. } else {
  207. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  208. }
  209. }
  210. return cell
  211. case .latestCalibration:
  212. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell
  213. let calibration = cgmManager.latestReading?.lastCalibration
  214. switch LatestCalibrationRow(rawValue: indexPath.row)! {
  215. case .glucose:
  216. cell.setGlucose(calibration?.glucose, unit: glucoseUnit, formatter: glucoseFormatter, isDisplayOnly: false)
  217. case .date:
  218. cell.setGlucoseDate(calibration?.date, formatter: dateFormatter)
  219. }
  220. return cell
  221. case .latestConnection:
  222. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell
  223. let connection = cgmManager.latestConnection
  224. switch LatestConnectionRow(rawValue: indexPath.row)! {
  225. case .date:
  226. cell.setGlucoseDate(connection, formatter: dateFormatter)
  227. cell.accessoryType = .disclosureIndicator
  228. }
  229. return cell
  230. case .ages:
  231. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell
  232. let glucose = cgmManager.latestReading
  233. switch AgeRow(rawValue: indexPath.row)! {
  234. case .sensorAge:
  235. cell.textLabel?.text = LocalizedString("Session Age", comment: "Title describing sensor session age")
  236. if let stateDescription = glucose?.stateDescription, !stateDescription.isEmpty && !stateDescription.contains("stopped") {
  237. if let sessionStart = cgmManager.latestReading?.sessionStartDate {
  238. cell.detailTextLabel?.text = sessionLengthFormatter.string(from: Date().timeIntervalSince(sessionStart))
  239. } else {
  240. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  241. }
  242. } else {
  243. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  244. }
  245. case .sensorCountdown:
  246. cell.textLabel?.text = LocalizedString("Sensor Expires", comment: "Title describing sensor sensor expiration")
  247. if let stateDescription = glucose?.stateDescription, !stateDescription.isEmpty && !stateDescription.contains("stopped") {
  248. if let sessionExp = cgmManager.latestReading?.sessionExpDate {
  249. let sessionCountDown = sessionExp.timeIntervalSince(Date())
  250. if sessionCountDown < 0 {
  251. cell.textLabel?.text = LocalizedString("Sensor Expired", comment: "Title describing past sensor sensor expiration")
  252. cell.detailTextLabel?.text = (sessionLengthFormatter.string(from: sessionCountDown * -1) ?? "") + " ago"
  253. } else {
  254. cell.detailTextLabel?.text = sessionLengthFormatter.string(from: sessionCountDown)
  255. }
  256. } else {
  257. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  258. }
  259. } else {
  260. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  261. }
  262. case .sensorExpirationDate:
  263. cell.textLabel?.text = ""
  264. if let stateDescription = glucose?.stateDescription, !stateDescription.isEmpty && !stateDescription.contains("stopped") {
  265. if let sessionExp = cgmManager.latestReading?.sessionExpDate {
  266. if sensorExpirationRelativeFormatter.string(from: sessionExp) == sensorExpAbsFormatter.string(from: sessionExp) {
  267. cell.detailTextLabel?.text = sensorExpirationFullFormatter.string(from: sessionExp)
  268. } else {
  269. cell.detailTextLabel?.text = sensorExpirationRelativeFormatter.string(from: sessionExp)
  270. }
  271. } else {
  272. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  273. }
  274. } else {
  275. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  276. }
  277. case .transmitter:
  278. cell.textLabel?.text = LocalizedString("Transmitter Age", comment: "Title describing transmitter session age")
  279. if let activation = cgmManager.latestReading?.activationDate {
  280. cell.detailTextLabel?.text = transmitterLengthFormatter.string(from: Date().timeIntervalSince(activation))
  281. } else {
  282. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  283. }
  284. }
  285. return cell
  286. case .share:
  287. switch ShareRow(rawValue: indexPath.row)! {
  288. case .settings:
  289. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath) as! SettingsTableViewCell
  290. let service = cgmManager.shareManager.shareService
  291. cell.textLabel?.text = service.title
  292. cell.detailTextLabel?.text = service.username ?? SettingsTableViewCell.TapToSetString
  293. cell.accessoryType = .disclosureIndicator
  294. return cell
  295. case .openApp:
  296. let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath)
  297. cell.textLabel?.text = LocalizedString("Open App", comment: "Button title to open CGM app")
  298. return cell
  299. }
  300. case .delete:
  301. let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath) as! TextButtonTableViewCell
  302. cell.textLabel?.text = LocalizedString("Delete CGM", comment: "Title text for the button to remove a CGM from Loop")
  303. cell.textLabel?.textAlignment = .center
  304. cell.tintColor = .delete
  305. cell.isEnabled = true
  306. return cell
  307. }
  308. }
  309. override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  310. switch Section(rawValue: section)! {
  311. case .transmitterID:
  312. return nil
  313. case .remoteDataSync:
  314. return LocalizedString("Remote Data Synchronization", comment: "Section title for remote data synchronization")
  315. case .latestReading:
  316. return LocalizedString("Latest Reading", comment: "Section title for latest glucose reading")
  317. case .latestCalibration:
  318. return LocalizedString("Latest Calibration", comment: "Section title for latest glucose calibration")
  319. case .latestConnection:
  320. return LocalizedString("Latest Connection", comment: "Section title for latest connection date")
  321. case .ages:
  322. return nil
  323. case .share:
  324. return nil
  325. case .delete:
  326. return " " // Use an empty string for more dramatic spacing
  327. }
  328. }
  329. override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
  330. switch Section(rawValue: indexPath.section)! {
  331. case .transmitterID:
  332. return false
  333. case .remoteDataSync:
  334. return false
  335. case .latestReading:
  336. return false
  337. case .latestCalibration:
  338. return false
  339. case .latestConnection:
  340. return true
  341. case .ages:
  342. return false
  343. case .share:
  344. return true
  345. case .delete:
  346. return true
  347. }
  348. }
  349. override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  350. if self.tableView(tableView, shouldHighlightRowAt: indexPath) {
  351. return indexPath
  352. } else {
  353. return nil
  354. }
  355. }
  356. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  357. switch Section(rawValue: indexPath.section)! {
  358. case .transmitterID:
  359. break
  360. case .remoteDataSync:
  361. break
  362. case .latestReading:
  363. break
  364. case .latestCalibration:
  365. break
  366. case .latestConnection:
  367. let vc = CommandResponseViewController(command: { (completionHandler) -> String in
  368. return String(reflecting: self.cgmManager)
  369. })
  370. vc.title = self.title
  371. show(vc, sender: nil)
  372. case .ages:
  373. break
  374. case .share:
  375. switch ShareRow(rawValue: indexPath.row)! {
  376. case .settings:
  377. let vc = ShareClientSettingsViewController(cgmManager: cgmManager.shareManager, displayGlucoseUnitObservable: displayGlucoseUnitObservable, allowsDeletion: false)
  378. show(vc, sender: nil)
  379. return // Don't deselect
  380. case .openApp:
  381. if let appURL = URL(string: "dexcomg6://") {
  382. UIApplication.shared.open(appURL)
  383. }
  384. }
  385. case .delete:
  386. let confirmVC = UIAlertController(cgmDeletionHandler: {
  387. self.cgmManager.notifyDelegateOfDeletion {
  388. DispatchQueue.main.async {
  389. self.complete()
  390. }
  391. }
  392. })
  393. present(confirmVC, animated: true) {
  394. tableView.deselectRow(at: indexPath, animated: true)
  395. }
  396. }
  397. tableView.deselectRow(at: indexPath, animated: true)
  398. }
  399. override func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? {
  400. switch Section(rawValue: indexPath.section)! {
  401. case .transmitterID:
  402. break
  403. case .remoteDataSync:
  404. break
  405. case .latestReading:
  406. break
  407. case .latestCalibration:
  408. break
  409. case .latestConnection:
  410. break
  411. case .ages:
  412. break
  413. case .share:
  414. switch ShareRow(rawValue: indexPath.row)! {
  415. case .settings:
  416. tableView.reloadRows(at: [indexPath], with: .fade)
  417. case .openApp:
  418. break
  419. }
  420. case .delete:
  421. break
  422. }
  423. return indexPath
  424. }
  425. @objc private func uploadEnabledChanged(_ sender: UISwitch) {
  426. cgmManager.shouldSyncToRemoteService = sender.isOn
  427. }
  428. }
  429. extension TransmitterSettingsViewController: TransmitterManagerObserver {
  430. func transmitterManagerDidUpdateLatestReading(_ manager: TransmitterManager) {
  431. tableView.reloadData()
  432. }
  433. }
  434. private extension UIAlertController {
  435. convenience init(cgmDeletionHandler handler: @escaping () -> Void) {
  436. self.init(
  437. title: nil,
  438. message: LocalizedString("Are you sure you want to delete this CGM?", comment: "Confirmation message for deleting a CGM"),
  439. preferredStyle: .actionSheet
  440. )
  441. addAction(UIAlertAction(
  442. title: LocalizedString("Delete CGM", comment: "Button title to delete CGM"),
  443. style: .destructive,
  444. handler: { (_) in
  445. handler()
  446. }
  447. ))
  448. let cancel = LocalizedString("Cancel", comment: "The title of the cancel action in an action sheet")
  449. addAction(UIAlertAction(title: cancel, style: .cancel, handler: nil))
  450. }
  451. }
  452. private extension SettingsTableViewCell {
  453. func setGlucose(_ glucose: HKQuantity?, unit: HKUnit, formatter: QuantityFormatter, isDisplayOnly: Bool) {
  454. if isDisplayOnly {
  455. textLabel?.text = LocalizedString("Glucose (Adjusted)", comment: "Describes a glucose value adjusted to reflect a recent calibration")
  456. } else {
  457. textLabel?.text = LocalizedString("Glucose", comment: "Title describing glucose value")
  458. }
  459. if let quantity = glucose, let formatted = formatter.string(from: quantity, for: unit) {
  460. detailTextLabel?.text = formatted
  461. } else {
  462. detailTextLabel?.text = SettingsTableViewCell.NoValueString
  463. }
  464. }
  465. func setGlucoseDate(_ date: Date?, formatter: DateFormatter) {
  466. textLabel?.text = LocalizedString("Date", comment: "Title describing glucose date")
  467. if let date = date {
  468. detailTextLabel?.text = formatter.string(from: date)
  469. } else {
  470. detailTextLabel?.text = SettingsTableViewCell.NoValueString
  471. }
  472. }
  473. }