MockCGMManagerSettingsViewController.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. //
  2. // MockCGMManagerSettingsViewController.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 11/23/18.
  6. // Copyright © 2018 LoopKit Authors. All rights reserved.
  7. //
  8. import UIKit
  9. import HealthKit
  10. import LoopKit
  11. import LoopKitUI
  12. import MockKit
  13. final class MockCGMManagerSettingsViewController: UITableViewController {
  14. let cgmManager: MockCGMManager
  15. let glucoseUnit: HKUnit
  16. init(cgmManager: MockCGMManager, glucoseUnit: HKUnit) {
  17. self.cgmManager = cgmManager
  18. self.glucoseUnit = glucoseUnit
  19. super.init(style: .grouped)
  20. }
  21. required init?(coder aDecoder: NSCoder) {
  22. fatalError("init(coder:) has not been implemented")
  23. }
  24. override func viewDidLoad() {
  25. super.viewDidLoad()
  26. title = "CGM Settings"
  27. tableView.rowHeight = UITableView.automaticDimension
  28. tableView.estimatedRowHeight = 44
  29. tableView.register(SettingsTableViewCell.self, forCellReuseIdentifier: SettingsTableViewCell.className)
  30. tableView.register(TextButtonTableViewCell.self, forCellReuseIdentifier: TextButtonTableViewCell.className)
  31. let button = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped(_:)))
  32. self.navigationItem.setRightBarButton(button, animated: false)
  33. }
  34. @objc func doneTapped(_ sender: Any) {
  35. done()
  36. }
  37. private func done() {
  38. if let nav = navigationController as? SettingsNavigationViewController {
  39. nav.notifyComplete()
  40. }
  41. if let nav = navigationController as? MockPumpManagerSetupViewController {
  42. nav.finishedSettingsDisplay()
  43. }
  44. }
  45. // MARK: - Data Source
  46. private enum Section: Int, CaseIterable {
  47. case model = 0
  48. case effects
  49. case history
  50. case deleteCGM
  51. }
  52. private enum ModelRow: Int, CaseIterable {
  53. case constant = 0
  54. case sineCurve
  55. case noData
  56. }
  57. private enum EffectsRow: Int, CaseIterable {
  58. case noise = 0
  59. case lowOutlier
  60. case highOutlier
  61. case error
  62. }
  63. private enum HistoryRow: Int, CaseIterable {
  64. case trend = 0
  65. case backfill
  66. }
  67. // MARK: - UITableViewDataSource
  68. override func numberOfSections(in tableView: UITableView) -> Int {
  69. return Section.allCases.count
  70. }
  71. override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  72. switch Section(rawValue: section)! {
  73. case .model:
  74. return ModelRow.allCases.count
  75. case .effects:
  76. return EffectsRow.allCases.count
  77. case .history:
  78. return HistoryRow.allCases.count
  79. case .deleteCGM:
  80. return 1
  81. }
  82. }
  83. override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  84. switch Section(rawValue: section)! {
  85. case .model:
  86. return "Model"
  87. case .effects:
  88. return "Effects"
  89. case .history:
  90. return "History"
  91. case .deleteCGM:
  92. return " " // Use an empty string for more dramatic spacing
  93. }
  94. }
  95. private lazy var quantityFormatter = QuantityFormatter()
  96. private lazy var percentageFormatter: NumberFormatter = {
  97. let formatter = NumberFormatter()
  98. formatter.minimumIntegerDigits = 1
  99. formatter.maximumFractionDigits = 1
  100. return formatter
  101. }()
  102. override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  103. switch Section(rawValue: indexPath.section)! {
  104. case .model:
  105. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  106. switch ModelRow(rawValue: indexPath.row)! {
  107. case .constant:
  108. cell.textLabel?.text = "Constant"
  109. if case .constant(let glucose) = cgmManager.dataSource.model {
  110. cell.detailTextLabel?.text = quantityFormatter.string(from: glucose, for: glucoseUnit)
  111. cell.accessoryType = .checkmark
  112. } else {
  113. cell.accessoryType = .disclosureIndicator
  114. }
  115. case .sineCurve:
  116. cell.textLabel?.text = "Sine Curve"
  117. if case .sineCurve(parameters: (baseGlucose: let baseGlucose, amplitude: let amplitude, period: _, referenceDate: _)) = cgmManager.dataSource.model {
  118. if let baseGlucoseText = quantityFormatter.numberFormatter.string(from: baseGlucose.doubleValue(for: glucoseUnit)),
  119. let amplitudeText = quantityFormatter.string(from: amplitude, for: glucoseUnit) {
  120. cell.detailTextLabel?.text = "\(baseGlucoseText) ± \(amplitudeText)"
  121. }
  122. cell.accessoryType = .checkmark
  123. } else {
  124. cell.accessoryType = .disclosureIndicator
  125. }
  126. case .noData:
  127. cell.textLabel?.text = "No Data"
  128. if case .noData = cgmManager.dataSource.model {
  129. cell.accessoryType = .checkmark
  130. }
  131. }
  132. return cell
  133. case .effects:
  134. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  135. switch EffectsRow(rawValue: indexPath.row)! {
  136. case .noise:
  137. cell.textLabel?.text = "Glucose Noise"
  138. if let maximumDeltaMagnitude = cgmManager.dataSource.effects.glucoseNoise {
  139. cell.detailTextLabel?.text = quantityFormatter.string(from: maximumDeltaMagnitude, for: glucoseUnit)
  140. } else {
  141. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  142. }
  143. case .lowOutlier:
  144. cell.textLabel?.text = "Random Low Outlier"
  145. if let chance = cgmManager.dataSource.effects.randomLowOutlier?.chance,
  146. let percentageString = percentageFormatter.string(from: chance * 100)
  147. {
  148. cell.detailTextLabel?.text = "\(percentageString)% chance"
  149. } else {
  150. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  151. }
  152. case .highOutlier:
  153. cell.textLabel?.text = "Random High Outlier"
  154. if let chance = cgmManager.dataSource.effects.randomHighOutlier?.chance,
  155. let percentageString = percentageFormatter.string(from: chance * 100)
  156. {
  157. cell.detailTextLabel?.text = "\(percentageString)% chance"
  158. } else {
  159. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  160. }
  161. case .error:
  162. cell.textLabel?.text = "Random Error"
  163. if let chance = cgmManager.dataSource.effects.randomErrorChance,
  164. let percentageString = percentageFormatter.string(from: chance * 100)
  165. {
  166. cell.detailTextLabel?.text = "\(percentageString)% chance"
  167. } else {
  168. cell.detailTextLabel?.text = SettingsTableViewCell.NoValueString
  169. }
  170. }
  171. cell.accessoryType = .disclosureIndicator
  172. return cell
  173. case .history:
  174. let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
  175. switch HistoryRow(rawValue: indexPath.row)! {
  176. case .trend:
  177. cell.textLabel?.text = "Trend"
  178. cell.detailTextLabel?.text = cgmManager.mockSensorState.trendType?.symbol
  179. case .backfill:
  180. cell.textLabel?.text = "Backfill Glucose"
  181. }
  182. cell.accessoryType = .disclosureIndicator
  183. return cell
  184. case .deleteCGM:
  185. let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath) as! TextButtonTableViewCell
  186. cell.textLabel?.text = "Delete CGM"
  187. cell.textLabel?.textAlignment = .center
  188. cell.tintColor = .delete
  189. cell.isEnabled = true
  190. return cell
  191. }
  192. }
  193. // MARK: - UITableViewDelegate
  194. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  195. let sender = tableView.cellForRow(at: indexPath)
  196. switch Section(rawValue: indexPath.section)! {
  197. case .model:
  198. switch ModelRow(rawValue: indexPath.row)! {
  199. case .constant:
  200. let vc = GlucoseEntryTableViewController(glucoseUnit: glucoseUnit)
  201. vc.title = "Constant"
  202. vc.indexPath = indexPath
  203. vc.contextHelp = "A constant glucose model returns a fixed glucose value regardless of context."
  204. vc.glucoseEntryDelegate = self
  205. show(vc, sender: sender)
  206. case .sineCurve:
  207. let vc = SineCurveParametersTableViewController(glucoseUnit: glucoseUnit)
  208. if case .sineCurve(parameters: let parameters) = cgmManager.dataSource.model {
  209. vc.parameters = parameters
  210. } else {
  211. vc.parameters = nil
  212. }
  213. vc.contextHelp = "The sine curve parameters describe a mathematical model for glucose value production."
  214. vc.delegate = self
  215. show(vc, sender: sender)
  216. case .noData:
  217. cgmManager.dataSource.model = .noData
  218. tableView.reloadRows(at: indexPaths(forSection: .model, rows: ModelRow.self), with: .automatic)
  219. }
  220. case .effects:
  221. switch EffectsRow(rawValue: indexPath.row)! {
  222. case .noise:
  223. let vc = GlucoseEntryTableViewController(glucoseUnit: glucoseUnit)
  224. if let maximumDeltaMagnitude = cgmManager.dataSource.effects.glucoseNoise {
  225. vc.glucose = maximumDeltaMagnitude
  226. }
  227. vc.title = "Glucose Noise"
  228. vc.contextHelp = "The magnitude of glucose noise applied to CGM values determines the maximum random amount of variation applied to each glucose value."
  229. vc.indexPath = indexPath
  230. vc.glucoseEntryDelegate = self
  231. show(vc, sender: sender)
  232. case .lowOutlier:
  233. let vc = RandomOutlierTableViewController(glucoseUnit: glucoseUnit)
  234. vc.title = "Low Outlier"
  235. vc.randomOutlier = cgmManager.dataSource.effects.randomLowOutlier
  236. vc.contextHelp = "Produced glucose values will have a chance of being decreased by the delta quantity."
  237. vc.indexPath = indexPath
  238. vc.delegate = self
  239. show(vc, sender: sender)
  240. case .highOutlier:
  241. let vc = RandomOutlierTableViewController(glucoseUnit: glucoseUnit)
  242. vc.title = "High Outlier"
  243. vc.randomOutlier = cgmManager.dataSource.effects.randomHighOutlier
  244. vc.contextHelp = "Produced glucose values will have a chance of being increased by the delta quantity."
  245. vc.indexPath = indexPath
  246. vc.delegate = self
  247. show(vc, sender: sender)
  248. case .error:
  249. let vc = PercentageTextFieldTableViewController()
  250. if let chance = cgmManager.dataSource.effects.randomErrorChance {
  251. vc.percentage = chance
  252. }
  253. vc.title = "Random Error"
  254. vc.contextHelp = "The percentage determines the chance with which the CGM will error when a glucose value is requested."
  255. vc.indexPath = indexPath
  256. vc.percentageDelegate = self
  257. show(vc, sender: sender)
  258. }
  259. case .history:
  260. switch HistoryRow(rawValue: indexPath.row)! {
  261. case .trend:
  262. let vc = GlucoseTrendTableViewController()
  263. vc.glucoseTrend = cgmManager.mockSensorState.trendType
  264. vc.title = "Glucose Trend"
  265. vc.glucoseTrendDelegate = self
  266. show(vc, sender: sender)
  267. case .backfill:
  268. let vc = DateAndDurationTableViewController()
  269. vc.inputMode = .duration(.hours(3))
  270. vc.title = "Backfill"
  271. vc.contextHelp = "Performing a backfill will not delete existing prior glucose values."
  272. vc.indexPath = indexPath
  273. vc.onSave { inputMode in
  274. guard case .duration(let duration) = inputMode else {
  275. assertionFailure()
  276. return
  277. }
  278. self.cgmManager.backfillData(datingBack: duration)
  279. }
  280. show(vc, sender: sender)
  281. }
  282. case .deleteCGM:
  283. let confirmVC = UIAlertController(cgmDeletionHandler: {
  284. self.cgmManager.notifyDelegateOfDeletion {
  285. DispatchQueue.main.async {
  286. self.done()
  287. }
  288. }
  289. })
  290. present(confirmVC, animated: true) {
  291. tableView.deselectRow(at: indexPath, animated: true)
  292. }
  293. }
  294. }
  295. private func indexPaths<Row: CaseIterable & RawRepresentable>(
  296. forSection section: Section,
  297. rows _: Row.Type
  298. ) -> [IndexPath] where Row.RawValue == Int {
  299. let rows = Row.allCases
  300. return zip(rows, repeatElement(section, count: rows.count)).map { row, section in
  301. return IndexPath(row: row.rawValue, section: section.rawValue)
  302. }
  303. }
  304. }
  305. extension MockCGMManagerSettingsViewController: GlucoseEntryTableViewControllerDelegate {
  306. func glucoseEntryTableViewControllerDidChangeGlucose(_ controller: GlucoseEntryTableViewController) {
  307. guard let indexPath = controller.indexPath else {
  308. assertionFailure()
  309. return
  310. }
  311. tableView.deselectRow(at: indexPath, animated: true)
  312. switch indexPath {
  313. case [Section.model.rawValue, ModelRow.constant.rawValue]:
  314. if let glucose = controller.glucose {
  315. cgmManager.dataSource.model = .constant(glucose)
  316. tableView.reloadRows(at: indexPaths(forSection: .model, rows: ModelRow.self), with: .automatic)
  317. }
  318. case [Section.effects.rawValue, EffectsRow.noise.rawValue]:
  319. if let glucose = controller.glucose {
  320. cgmManager.dataSource.effects.glucoseNoise = glucose
  321. }
  322. tableView.reloadRows(at: [indexPath], with: .automatic)
  323. default:
  324. assertionFailure()
  325. }
  326. }
  327. }
  328. extension MockCGMManagerSettingsViewController: SineCurveParametersTableViewControllerDelegate {
  329. func sineCurveParametersTableViewControllerDidUpdateParameters(_ controller: SineCurveParametersTableViewController) {
  330. if let parameters = controller.parameters {
  331. cgmManager.dataSource.model = .sineCurve(parameters: parameters)
  332. tableView.reloadRows(at: indexPaths(forSection: .model, rows: ModelRow.self), with: .automatic)
  333. }
  334. }
  335. }
  336. extension MockCGMManagerSettingsViewController: RandomOutlierTableViewControllerDelegate {
  337. func randomOutlierTableViewControllerDidChangeOutlier(_ controller: RandomOutlierTableViewController) {
  338. guard let indexPath = controller.indexPath else {
  339. assertionFailure()
  340. return
  341. }
  342. switch indexPath {
  343. case [Section.effects.rawValue, EffectsRow.lowOutlier.rawValue]:
  344. cgmManager.dataSource.effects.randomLowOutlier = controller.randomOutlier
  345. case [Section.effects.rawValue, EffectsRow.highOutlier.rawValue]:
  346. cgmManager.dataSource.effects.randomHighOutlier = controller.randomOutlier
  347. default:
  348. assertionFailure()
  349. }
  350. tableView.reloadRows(at: [indexPath], with: .automatic)
  351. }
  352. }
  353. extension MockCGMManagerSettingsViewController: PercentageTextFieldTableViewControllerDelegate {
  354. func percentageTextFieldTableViewControllerDidChangePercentage(_ controller: PercentageTextFieldTableViewController) {
  355. guard let indexPath = controller.indexPath else {
  356. assertionFailure()
  357. return
  358. }
  359. switch indexPath {
  360. case [Section.effects.rawValue, EffectsRow.error.rawValue]:
  361. if let chance = controller.percentage {
  362. cgmManager.dataSource.effects.randomErrorChance = chance.clamped(to: 0...100)
  363. }
  364. tableView.reloadRows(at: [indexPath], with: .automatic)
  365. default:
  366. assertionFailure()
  367. }
  368. }
  369. }
  370. extension MockCGMManagerSettingsViewController: GlucoseTrendTableViewControllerDelegate {
  371. func glucoseTrendTableViewControllerDidChangeTrend(_ controller: GlucoseTrendTableViewController) {
  372. cgmManager.mockSensorState.trendType = controller.glucoseTrend
  373. tableView.reloadRows(at: [[Section.history.rawValue, HistoryRow.trend.rawValue]], with: .automatic)
  374. }
  375. }
  376. private extension UIAlertController {
  377. convenience init(cgmDeletionHandler handler: @escaping () -> Void) {
  378. self.init(
  379. title: nil,
  380. message: "Are you sure you want to delete this CGM?",
  381. preferredStyle: .actionSheet
  382. )
  383. addAction(UIAlertAction(
  384. title: "Delete CGM",
  385. style: .destructive,
  386. handler: { _ in
  387. handler()
  388. }
  389. ))
  390. let cancel = "Cancel"
  391. addAction(UIAlertAction(title: cancel, style: .cancel, handler: nil))
  392. }
  393. }