LegacyInsulinDeliveryTableViewController.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. //
  2. // LegacyInsulinDeliveryTableViewController.swift
  3. // Naterade
  4. //
  5. // Created by Nathan Racklyeft on 1/30/16.
  6. // Copyright © 2016 Nathan Racklyeft. All rights reserved.
  7. //
  8. import UIKit
  9. import LoopKit
  10. private let ReuseIdentifier = "Right Detail"
  11. public final class LegacyInsulinDeliveryTableViewController: UITableViewController {
  12. @IBOutlet var needsConfigurationMessageView: ErrorBackgroundView!
  13. @IBOutlet weak var iobValueLabel: UILabel! {
  14. didSet {
  15. iobValueLabel.textColor = headerValueLabelColor
  16. }
  17. }
  18. @IBOutlet weak var iobDateLabel: UILabel!
  19. @IBOutlet weak var totalValueLabel: UILabel! {
  20. didSet {
  21. totalValueLabel.textColor = headerValueLabelColor
  22. }
  23. }
  24. @IBOutlet weak var totalDateLabel: UILabel!
  25. @IBOutlet weak var dataSourceSegmentedControl: UISegmentedControl! {
  26. didSet {
  27. let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
  28. dataSourceSegmentedControl.setTitleTextAttributes([NSAttributedString.Key.font: titleFont], for: .normal)
  29. dataSourceSegmentedControl.setTitle(NSLocalizedString("Event History", comment: "Segmented button title for insulin delivery log event history"), forSegmentAt: 0)
  30. dataSourceSegmentedControl.setTitle(NSLocalizedString("Reservoir", comment: "Segmented button title for insulin delivery log reservoir history"), forSegmentAt: 1)
  31. }
  32. }
  33. public var enableEntryDeletion: Bool = true
  34. public var doseStore: DoseStore? {
  35. didSet {
  36. if let doseStore = doseStore {
  37. doseStoreObserver = NotificationCenter.default.addObserver(forName: nil, object: doseStore, queue: OperationQueue.main, using: { [weak self] (note) -> Void in
  38. switch note.name {
  39. case DoseStore.valuesDidChange:
  40. if self?.isViewLoaded == true {
  41. self?.reloadData()
  42. }
  43. default:
  44. break
  45. }
  46. })
  47. } else {
  48. doseStoreObserver = nil
  49. }
  50. }
  51. }
  52. public var headerValueLabelColor: UIColor = .label
  53. private var updateTimer: Timer? {
  54. willSet {
  55. if let timer = updateTimer {
  56. timer.invalidate()
  57. }
  58. }
  59. }
  60. public override func viewDidLoad() {
  61. super.viewDidLoad()
  62. state = .display
  63. }
  64. public override func viewWillAppear(_ animated: Bool) {
  65. super.viewWillAppear(animated)
  66. updateTimelyStats(nil)
  67. }
  68. public override func viewDidAppear(_ animated: Bool) {
  69. super.viewDidAppear(animated)
  70. let updateInterval = TimeInterval(minutes: 5)
  71. let timer = Timer(
  72. fireAt: Date().dateCeiledToTimeInterval(updateInterval).addingTimeInterval(2),
  73. interval: updateInterval,
  74. target: self,
  75. selector: #selector(updateTimelyStats(_:)),
  76. userInfo: nil,
  77. repeats: true
  78. )
  79. updateTimer = timer
  80. RunLoop.current.add(timer, forMode: RunLoop.Mode.default)
  81. }
  82. public override func viewWillDisappear(_ animated: Bool) {
  83. super.viewWillDisappear(animated)
  84. updateTimer = nil
  85. }
  86. public override func viewDidDisappear(_ animated: Bool) {
  87. super.viewDidDisappear(animated)
  88. if tableView.isEditing {
  89. tableView.endEditing(true)
  90. }
  91. }
  92. deinit {
  93. if let observer = doseStoreObserver {
  94. NotificationCenter.default.removeObserver(observer)
  95. }
  96. }
  97. public override func setEditing(_ editing: Bool, animated: Bool) {
  98. super.setEditing(editing, animated: animated)
  99. if editing && enableEntryDeletion {
  100. let item = UIBarButtonItem(
  101. title: LocalizedString("Delete All", comment: "Button title to delete all objects"),
  102. style: .plain,
  103. target: self,
  104. action: #selector(confirmDeletion(_:))
  105. )
  106. navigationItem.setLeftBarButton(item, animated: true)
  107. } else {
  108. navigationItem.setLeftBarButton(nil, animated: true)
  109. }
  110. }
  111. // MARK: - Data
  112. private enum State {
  113. case unknown
  114. case unavailable(Error?)
  115. case display
  116. }
  117. private var state = State.unknown {
  118. didSet {
  119. if isViewLoaded {
  120. reloadData()
  121. }
  122. }
  123. }
  124. private enum DataSourceSegment: Int {
  125. case history = 0
  126. case reservoir
  127. }
  128. private enum Values {
  129. case reservoir([ReservoirValue])
  130. case history([PersistedPumpEvent])
  131. }
  132. // Not thread-safe
  133. private var values = Values.reservoir([]) {
  134. didSet {
  135. let count: Int
  136. switch values {
  137. case .reservoir(let values):
  138. count = values.count
  139. case .history(let values):
  140. count = values.count
  141. }
  142. if count > 0 && enableEntryDeletion {
  143. navigationItem.rightBarButtonItem = self.editButtonItem
  144. }
  145. }
  146. }
  147. private func reloadData() {
  148. switch state {
  149. case .unknown:
  150. break
  151. case .unavailable(let error):
  152. self.tableView.tableHeaderView?.isHidden = true
  153. self.tableView.tableFooterView = UIView()
  154. tableView.backgroundView = needsConfigurationMessageView
  155. if let error = error {
  156. needsConfigurationMessageView.errorDescriptionLabel.text = String(describing: error)
  157. } else {
  158. needsConfigurationMessageView.errorDescriptionLabel.text = nil
  159. }
  160. case .display:
  161. self.tableView.backgroundView = nil
  162. self.tableView.tableHeaderView?.isHidden = false
  163. self.tableView.tableFooterView = nil
  164. switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! {
  165. case .reservoir:
  166. doseStore?.getReservoirValues(since: Date.distantPast) { (result) in
  167. DispatchQueue.main.async { () -> Void in
  168. switch result {
  169. case .failure(let error):
  170. self.state = .unavailable(error)
  171. case .success(let reservoirValues):
  172. self.values = .reservoir(reservoirValues)
  173. self.tableView.reloadData()
  174. }
  175. }
  176. self.updateTimelyStats(nil)
  177. self.updateTotal()
  178. }
  179. case .history:
  180. doseStore?.getPumpEventValues(since: Date.distantPast) { (result) in
  181. DispatchQueue.main.async { () -> Void in
  182. switch result {
  183. case .failure(let error):
  184. self.state = .unavailable(error)
  185. case .success(let pumpEventValues):
  186. self.values = .history(pumpEventValues)
  187. self.tableView.reloadData()
  188. }
  189. }
  190. self.updateTimelyStats(nil)
  191. self.updateTotal()
  192. }
  193. }
  194. }
  195. }
  196. @objc func updateTimelyStats(_: Timer?) {
  197. updateIOB()
  198. }
  199. private lazy var iobNumberFormatter: NumberFormatter = {
  200. let formatter = NumberFormatter()
  201. formatter.numberStyle = .decimal
  202. formatter.maximumFractionDigits = 2
  203. return formatter
  204. }()
  205. private lazy var timeFormatter: DateFormatter = {
  206. let formatter = DateFormatter()
  207. formatter.dateStyle = .none
  208. formatter.timeStyle = .short
  209. return formatter
  210. }()
  211. private func updateIOB() {
  212. if case .display = state {
  213. doseStore?.insulinOnBoard(at: Date()) { (result) -> Void in
  214. DispatchQueue.main.async {
  215. switch result {
  216. case .failure:
  217. self.iobValueLabel.text = "…"
  218. self.iobDateLabel.text = nil
  219. case .success(let iob):
  220. self.iobValueLabel.text = self.iobNumberFormatter.string(from: iob.value)
  221. self.iobDateLabel.text = String(format: LocalizedString("com.loudnate.InsulinKit.IOBDateLabel", value: "at %1$@", comment: "The format string describing the date of an IOB value. The first format argument is the localized date."), self.timeFormatter.string(from: iob.startDate))
  222. }
  223. }
  224. }
  225. }
  226. }
  227. private func updateTotal() {
  228. if case .display = state {
  229. let midnight = Calendar.current.startOfDay(for: Date())
  230. doseStore?.getTotalUnitsDelivered(since: midnight) { (result) in
  231. DispatchQueue.main.async {
  232. switch result {
  233. case .failure:
  234. self.totalValueLabel.text = "…"
  235. self.totalDateLabel.text = nil
  236. case .success(let result):
  237. self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none)
  238. self.totalDateLabel.text = String(format: LocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short))
  239. }
  240. }
  241. }
  242. }
  243. }
  244. private var doseStoreObserver: Any? {
  245. willSet {
  246. if let observer = doseStoreObserver {
  247. NotificationCenter.default.removeObserver(observer)
  248. }
  249. }
  250. }
  251. @IBAction func selectedSegmentChanged(_ sender: Any) {
  252. reloadData()
  253. }
  254. @IBAction func confirmDeletion(_ sender: Any) {
  255. guard !deletionPending else {
  256. return
  257. }
  258. let confirmMessage: String
  259. switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! {
  260. case .reservoir:
  261. confirmMessage = LocalizedString("Are you sure you want to delete all reservoir values?", comment: "Action sheet confirmation message for reservoir deletion")
  262. case .history:
  263. confirmMessage = LocalizedString("Are you sure you want to delete all history entries?", comment: "Action sheet confirmation message for pump history deletion")
  264. }
  265. let sheet = UIAlertController(deleteAllConfirmationMessage: confirmMessage) {
  266. self.deleteAllObjects()
  267. }
  268. present(sheet, animated: true)
  269. }
  270. private var deletionPending = false
  271. private func deleteAllObjects() {
  272. guard !deletionPending else {
  273. return
  274. }
  275. deletionPending = true
  276. let completion = { (_: DoseStore.DoseStoreError?) -> Void in
  277. DispatchQueue.main.async {
  278. self.deletionPending = false
  279. self.setEditing(false, animated: true)
  280. }
  281. }
  282. switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! {
  283. case .reservoir:
  284. doseStore?.deleteAllReservoirValues(completion)
  285. case .history:
  286. doseStore?.deleteAllPumpEvents(completion)
  287. }
  288. }
  289. // MARK: - Table view data source
  290. public override func numberOfSections(in tableView: UITableView) -> Int {
  291. switch state {
  292. case .unknown, .unavailable:
  293. return 0
  294. case .display:
  295. return 1
  296. }
  297. }
  298. public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  299. switch values {
  300. case .reservoir(let values):
  301. return values.count
  302. case .history(let values):
  303. return values.count
  304. }
  305. }
  306. public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  307. let cell = tableView.dequeueReusableCell(withIdentifier: ReuseIdentifier, for: indexPath)
  308. if case .display = state {
  309. switch self.values {
  310. case .reservoir(let values):
  311. let entry = values[indexPath.row]
  312. let volume = NumberFormatter.localizedString(from: NSNumber(value: entry.unitVolume), number: .decimal)
  313. let time = timeFormatter.string(from: entry.startDate)
  314. cell.textLabel?.text = String(format: NSLocalizedString("%1$@ U", comment: "Reservoir entry (1: volume value)"), volume)
  315. cell.textLabel?.textColor = .label
  316. cell.detailTextLabel?.text = time
  317. cell.accessoryType = .none
  318. cell.selectionStyle = .none
  319. case .history(let values):
  320. let entry = values[indexPath.row]
  321. let time = timeFormatter.string(from: entry.date)
  322. if let attributedText = entry.dose?.localizedAttributedDescription {
  323. cell.textLabel?.attributedText = attributedText
  324. } else {
  325. cell.textLabel?.text = entry.title
  326. }
  327. cell.detailTextLabel?.text = time
  328. cell.accessoryType = entry.isUploaded ? .checkmark : .none
  329. cell.selectionStyle = .default
  330. }
  331. }
  332. return cell
  333. }
  334. public override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
  335. return enableEntryDeletion
  336. }
  337. public override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
  338. if editingStyle == .delete, case .display = state {
  339. switch values {
  340. case .reservoir(let reservoirValues):
  341. var reservoirValues = reservoirValues
  342. let value = reservoirValues.remove(at: indexPath.row)
  343. self.values = .reservoir(reservoirValues)
  344. tableView.deleteRows(at: [indexPath], with: .automatic)
  345. doseStore?.deleteReservoirValue(value) { (_, error) -> Void in
  346. if let error = error {
  347. DispatchQueue.main.async {
  348. self.present(UIAlertController(with: error), animated: true)
  349. self.reloadData()
  350. }
  351. }
  352. }
  353. case .history(let historyValues):
  354. var historyValues = historyValues
  355. let value = historyValues.remove(at: indexPath.row)
  356. self.values = .history(historyValues)
  357. tableView.deleteRows(at: [indexPath], with: .automatic)
  358. doseStore?.deletePumpEvent(value) { (error) -> Void in
  359. if let error = error {
  360. DispatchQueue.main.async {
  361. self.present(UIAlertController(with: error), animated: true)
  362. self.reloadData()
  363. }
  364. }
  365. }
  366. }
  367. }
  368. }
  369. public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  370. if case .display = state, case .history(let history) = values {
  371. let entry = history[indexPath.row]
  372. let vc = CommandResponseViewController(command: { (completionHandler) -> String in
  373. var description = [String]()
  374. description.append(self.timeFormatter.string(from: entry.date))
  375. if let title = entry.title {
  376. description.append(title)
  377. }
  378. if let dose = entry.dose {
  379. description.append(String(describing: dose))
  380. }
  381. if let raw = entry.raw {
  382. description.append(raw.hexadecimalString)
  383. }
  384. return description.joined(separator: "\n\n")
  385. })
  386. vc.title = LocalizedString("Pump Event", comment: "The title of the screen displaying a pump event")
  387. show(vc, sender: indexPath)
  388. }
  389. }
  390. }
  391. fileprivate extension UIAlertController {
  392. convenience init(deleteAllConfirmationMessage: String, confirmationHandler handler: @escaping () -> Void) {
  393. self.init(
  394. title: nil,
  395. message: deleteAllConfirmationMessage,
  396. preferredStyle: .actionSheet
  397. )
  398. addAction(UIAlertAction(
  399. title: LocalizedString("Delete All", comment: "Button title to delete all objects"),
  400. style: .destructive,
  401. handler: { (_) in handler() }
  402. ))
  403. addAction(UIAlertAction(
  404. title: LocalizedString("Cancel", comment: "The title of the cancel action in an action sheet"),
  405. style: .cancel
  406. ))
  407. }
  408. }
  409. extension DoseEntry {
  410. fileprivate var numberFormatter: NumberFormatter {
  411. let numberFormatter = NumberFormatter()
  412. numberFormatter.maximumFractionDigits = DoseEntry.unitsPerHour.maxFractionDigits
  413. return numberFormatter
  414. }
  415. fileprivate var localizedAttributedDescription: NSAttributedString? {
  416. let font = UIFont.preferredFont(forTextStyle: .body)
  417. switch type {
  418. case .bolus:
  419. let description: String
  420. if let deliveredUnits = deliveredUnits,
  421. deliveredUnits != programmedUnits
  422. {
  423. description = String(format: NSLocalizedString("Interrupted %1$@: <b>%2$@</b> of %3$@ %4$@", comment: "Description of an interrupted bolus dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: programmed value (? if no value), 4: unit)"), type.localizedDescription, numberFormatter.string(from: deliveredUnits) ?? "?", numberFormatter.string(from: programmedUnits) ?? "?", DoseEntry.units.shortLocalizedUnitString())
  424. } else {
  425. description = String(format: NSLocalizedString("%1$@: <b>%2$@</b> %3$@", comment: "Description of a bolus dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: unit)"), type.localizedDescription, numberFormatter.string(from: programmedUnits) ?? "?", DoseEntry.units.shortLocalizedUnitString())
  426. }
  427. return createAttributedDescription(from: description, with: font)
  428. case .basal, .tempBasal:
  429. let description = String(format: NSLocalizedString("%1$@: <b>%2$@</b> %3$@", comment: "Description of a basal temp basal dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: unit)"), type.localizedDescription, numberFormatter.string(from: unitsPerHour) ?? "?", DoseEntry.unitsPerHour.shortLocalizedUnitString())
  430. return createAttributedDescription(from: description, with: font)
  431. case .suspend, .resume:
  432. let attributes: [NSAttributedString.Key: Any] = [
  433. .font: font,
  434. .foregroundColor: UIColor.secondaryLabel
  435. ]
  436. return NSAttributedString(string: type.localizedDescription, attributes: attributes)
  437. }
  438. }
  439. fileprivate func createAttributedDescription(from description: String, with font: UIFont) -> NSAttributedString? {
  440. let descriptionWithFont = String(format:"<style>body{font-family: '-apple-system', '\(font.fontName)'; font-size: \(font.pointSize);}</style>%@", description)
  441. guard let attributedDescription = try? NSMutableAttributedString(data: Data(descriptionWithFont.utf8), options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) else {
  442. return nil
  443. }
  444. attributedDescription.enumerateAttribute(.font, in: NSRange(location: 0, length: attributedDescription.length)) { value, range, stop in
  445. // bold font items have a dominate colour
  446. if let font = value as? UIFont,
  447. font.fontDescriptor.symbolicTraits.contains(.traitBold)
  448. {
  449. attributedDescription.addAttributes([.foregroundColor: UIColor.label], range: range)
  450. } else {
  451. attributedDescription.addAttributes([.foregroundColor: UIColor.secondaryLabel], range: range)
  452. }
  453. }
  454. return attributedDescription
  455. }
  456. }