TransmitterSettingsViewController.swift 21 KB

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