OmnipodSettingsViewModel.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. //
  2. // OmnipodSettingsViewModel.swift
  3. // OmniKit
  4. //
  5. // Created by Pete Schwamb on 3/8/20.
  6. // Copyright © 2021 LoopKit Authors. All rights reserved.
  7. //
  8. import SwiftUI
  9. import LoopKit
  10. import LoopKitUI
  11. import HealthKit
  12. import OmniKit
  13. import Combine
  14. enum OmnipodSettingsViewAlert {
  15. case suspendError(Error)
  16. case resumeError(Error)
  17. case cancelManualBasalError(Error)
  18. case syncTimeError(OmnipodPumpManagerError)
  19. }
  20. struct OmnipodSettingsNotice {
  21. let title: String
  22. let description: String
  23. }
  24. class OmnipodSettingsViewModel: ObservableObject {
  25. @Published var lifeState: PodLifeState
  26. @Published var activatedAt: Date?
  27. @Published var expiresAt: Date?
  28. @Published var beepPreference: BeepPreference
  29. @Published var silencePodPreference: SilencePodPreference
  30. @Published var rileylinkConnected: Bool
  31. var activatedAtString: String {
  32. if let activatedAt = activatedAt {
  33. return dateFormatter.string(from: activatedAt)
  34. } else {
  35. return "—"
  36. }
  37. }
  38. var expiresAtString: String {
  39. if let expiresAt = expiresAt {
  40. return dateFormatter.string(from: expiresAt)
  41. } else {
  42. return "—"
  43. }
  44. }
  45. var serviceTimeRemainingString: String? {
  46. if let serviceTimeRemaining = pumpManager.podServiceTimeRemaining, let serviceTimeRemainingString = timeRemainingFormatter.string(from: serviceTimeRemaining) {
  47. return serviceTimeRemainingString
  48. } else {
  49. return nil
  50. }
  51. }
  52. // Expiration reminder date for current pod
  53. @Published var expirationReminderDate: Date?
  54. var allowedScheduledReminderDates: [Date]? {
  55. return pumpManager.allowedExpirationReminderDates
  56. }
  57. // Hours before expiration
  58. @Published var expirationReminderDefault: Int {
  59. didSet {
  60. self.pumpManager.defaultExpirationReminderOffset = .hours(Double(expirationReminderDefault))
  61. }
  62. }
  63. // Units to alert at
  64. @Published var lowReservoirAlertValue: Int
  65. @Published var basalDeliveryState: PumpManagerStatus.BasalDeliveryState?
  66. @Published var basalDeliveryRate: Double?
  67. @Published var activeAlert: OmnipodSettingsViewAlert? = nil {
  68. didSet {
  69. if activeAlert != nil {
  70. alertIsPresented = true
  71. }
  72. }
  73. }
  74. @Published var alertIsPresented: Bool = false {
  75. didSet {
  76. if !alertIsPresented {
  77. activeAlert = nil
  78. }
  79. }
  80. }
  81. @Published var reservoirLevel: ReservoirLevel?
  82. @Published var reservoirLevelHighlightState: ReservoirLevelHighlightState?
  83. @Published var synchronizingTime: Bool = false
  84. @Published var podCommState: PodCommState
  85. @Published var insulinType: InsulinType?
  86. @Published var podDetails: PodDetails?
  87. @Published var previousPodDetails: PodDetails?
  88. var timeZone: TimeZone {
  89. return pumpManager.status.timeZone
  90. }
  91. var viewTitle: String {
  92. return pumpManager.localizedTitle
  93. }
  94. var isClockOffset: Bool {
  95. return pumpManager.isClockOffset
  96. }
  97. var isPodDataStale: Bool {
  98. return Date().timeIntervalSince(pumpManager.lastSync ?? .distantPast) > .minutes(12)
  99. }
  100. var recoveryText: String? {
  101. if case .fault = podCommState {
  102. return LocalizedString("⚠️ Insulin delivery stopped. Change Pod now.", comment: "The action string on pod status page when pod faulted")
  103. } else if podOk && isPodDataStale {
  104. return LocalizedString("Make sure your phone and pod are close to each other. If communication issues persist, move to a new area.", comment: "The action string on pod status page when pod data is stale")
  105. } else if let serviceTimeRemaining = pumpManager.podServiceTimeRemaining, serviceTimeRemaining <= Pod.serviceDuration - Pod.nominalPodLife {
  106. if let serviceTimeRemainingString = serviceTimeRemainingString {
  107. return String(format: LocalizedString("Change Pod now. Insulin delivery will stop in %1$@ or when no more insulin remains.", comment: "Format string for the action string on pod status page when pod expired. (1: service time remaining)"), serviceTimeRemainingString)
  108. } else {
  109. return LocalizedString("Change Pod now. Insulin delivery will stop 8 hours after the Pod has expired or when no more insulin remains.", comment: "The action string on pod status page when pod expired")
  110. }
  111. } else {
  112. return nil
  113. }
  114. }
  115. var notice: OmnipodSettingsNotice? {
  116. if pumpManager.isClockOffset {
  117. return OmnipodSettingsNotice(
  118. title: LocalizedString("Time Change Detected", comment: "title for time change detected notice"),
  119. description: LocalizedString("The time on your pump is different from the current time. Your pump’s time controls your scheduled therapy settings. Scroll down to Pump Time row to review the time difference and configure your pump.", comment: "description for time change detected notice"))
  120. } else {
  121. return nil
  122. }
  123. }
  124. var isScheduledBasal: Bool {
  125. switch basalDeliveryState {
  126. case .active(_), .initiatingTempBasal:
  127. return true
  128. case .tempBasal(_), .cancelingTempBasal, .suspending, .suspended(_), .resuming, .none:
  129. return false
  130. }
  131. }
  132. let dateFormatter: DateFormatter = {
  133. let dateFormatter = DateFormatter()
  134. dateFormatter.timeStyle = .short
  135. dateFormatter.dateStyle = .medium
  136. dateFormatter.doesRelativeDateFormatting = true
  137. return dateFormatter
  138. }()
  139. let timeFormatter: DateFormatter = {
  140. let dateFormatter = DateFormatter()
  141. dateFormatter.timeStyle = .short
  142. dateFormatter.dateStyle = .none
  143. return dateFormatter
  144. }()
  145. let timeRemainingFormatter: DateComponentsFormatter = {
  146. let dateComponentsFormatter = DateComponentsFormatter()
  147. dateComponentsFormatter.allowedUnits = [.hour, .minute]
  148. dateComponentsFormatter.unitsStyle = .full
  149. dateComponentsFormatter.zeroFormattingBehavior = .dropAll
  150. return dateComponentsFormatter
  151. }()
  152. let basalRateFormatter: NumberFormatter = {
  153. let numberFormatter = NumberFormatter()
  154. numberFormatter.numberStyle = .decimal
  155. numberFormatter.minimumFractionDigits = 1
  156. numberFormatter.minimumIntegerDigits = 1
  157. return numberFormatter
  158. }()
  159. var manualBasalTimeRemaining: TimeInterval? {
  160. if case .tempBasal(let dose) = basalDeliveryState, !(dose.automatic ?? true) {
  161. let remaining = dose.endDate.timeIntervalSinceNow
  162. if remaining > 0 {
  163. return remaining
  164. }
  165. }
  166. return nil
  167. }
  168. let reservoirVolumeFormatter = QuantityFormatter(for: .internationalUnit())
  169. var didFinish: (() -> Void)?
  170. var navigateTo: ((OmnipodUIScreen) -> Void)?
  171. private let pumpManager: OmnipodPumpManager
  172. private lazy var cancellables = Set<AnyCancellable>()
  173. init(pumpManager: OmnipodPumpManager) {
  174. self.pumpManager = pumpManager
  175. lifeState = pumpManager.lifeState
  176. activatedAt = pumpManager.podActivatedAt
  177. expiresAt = pumpManager.expiresAt
  178. basalDeliveryState = pumpManager.status.basalDeliveryState
  179. basalDeliveryRate = self.pumpManager.basalDeliveryRate
  180. reservoirLevel = self.pumpManager.reservoirLevel
  181. reservoirLevelHighlightState = self.pumpManager.reservoirLevelHighlightState
  182. expirationReminderDate = self.pumpManager.scheduledExpirationReminder
  183. expirationReminderDefault = Int(self.pumpManager.defaultExpirationReminderOffset.hours)
  184. lowReservoirAlertValue = Int(self.pumpManager.state.lowReservoirReminderValue)
  185. podCommState = self.pumpManager.podCommState
  186. beepPreference = self.pumpManager.beepPreference
  187. silencePodPreference = self.pumpManager.silencePod ? .enabled : .disabled
  188. insulinType = self.pumpManager.insulinType
  189. podDetails = self.pumpManager.podDetails
  190. previousPodDetails = self.pumpManager.previousPodDetails
  191. // TODO:
  192. rileylinkConnected = false
  193. pumpManager.addPodStateObserver(self, queue: DispatchQueue.main)
  194. pumpManager.addStatusObserver(self, queue: DispatchQueue.main)
  195. // Register for device notifications
  196. NotificationCenter.default.publisher(for: .DeviceConnectionStateDidChange)
  197. .sink { [weak self] _ in
  198. self?.updateConnectionStatus()
  199. }
  200. .store(in: &cancellables)
  201. // Trigger refresh
  202. pumpManager.getPodStatus() { _ in }
  203. updateConnectionStatus()
  204. }
  205. func updateConnectionStatus() {
  206. pumpManager.rileyLinkDeviceProvider.getDevices { (devices) in
  207. DispatchQueue.main.async { [weak self] in
  208. self?.rileylinkConnected = devices.firstConnected != nil
  209. }
  210. }
  211. }
  212. func changeTimeZoneTapped() {
  213. synchronizingTime = true
  214. pumpManager.setTime { (error) in
  215. DispatchQueue.main.async {
  216. self.synchronizingTime = false
  217. self.lifeState = self.pumpManager.lifeState
  218. if let error = error {
  219. self.activeAlert = .syncTimeError(error)
  220. }
  221. }
  222. }
  223. }
  224. func doneTapped() {
  225. self.didFinish?()
  226. }
  227. func stopUsingOmnipodTapped() {
  228. pumpManager.notifyDelegateOfDeactivation {
  229. DispatchQueue.main.async {
  230. self.didFinish?()
  231. }
  232. }
  233. }
  234. func suspendDelivery(duration: TimeInterval) {
  235. pumpManager.suspendDelivery(withSuspendReminders: duration) { (error) in
  236. DispatchQueue.main.async {
  237. if let error = error {
  238. self.activeAlert = .suspendError(error)
  239. }
  240. }
  241. }
  242. }
  243. func resumeDelivery() {
  244. pumpManager.resumeDelivery { (error) in
  245. DispatchQueue.main.async {
  246. if let error = error {
  247. self.activeAlert = .resumeError(error)
  248. }
  249. }
  250. }
  251. }
  252. func runTemporaryBasalProgram(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) {
  253. pumpManager.runTemporaryBasalProgram(unitsPerHour: unitsPerHour, for: duration, automatic: false, completion: completion)
  254. }
  255. func saveScheduledExpirationReminder(_ selectedDate: Date?, _ completion: @escaping (Error?) -> Void) {
  256. if let podExpiresAt = pumpManager.podExpiresAt {
  257. var intervalBeforeExpiration : TimeInterval?
  258. if let selectedDate = selectedDate {
  259. intervalBeforeExpiration = .hours(round(podExpiresAt.timeIntervalSince(selectedDate).hours))
  260. }
  261. pumpManager.updateExpirationReminder(intervalBeforeExpiration) { (error) in
  262. DispatchQueue.main.async {
  263. if error == nil {
  264. self.expirationReminderDate = selectedDate
  265. }
  266. completion(error)
  267. }
  268. }
  269. }
  270. }
  271. func saveLowReservoirReminder(_ selectedValue: Int, _ completion: @escaping (Error?) -> Void) {
  272. pumpManager.updateLowReservoirReminder(selectedValue) { (error) in
  273. DispatchQueue.main.async {
  274. if error == nil {
  275. self.lowReservoirAlertValue = selectedValue
  276. }
  277. completion(error)
  278. }
  279. }
  280. }
  281. func readPodStatus(_ completion: @escaping (_ result: PumpManagerResult<DetailedStatus>) -> Void) {
  282. pumpManager.getDetailedStatus() { (result) in
  283. DispatchQueue.main.async {
  284. completion(result)
  285. }
  286. }
  287. }
  288. func readPulseLog(_ completion: @escaping (_ result: Result<String, Error>) -> Void) {
  289. pumpManager.readPulseLog() { (result) in
  290. DispatchQueue.main.async {
  291. completion(result)
  292. }
  293. }
  294. }
  295. func playTestBeeps(_ completion: @escaping (Error?) -> Void) {
  296. pumpManager.playTestBeeps(completion: completion)
  297. }
  298. func pumpManagerDetails(_ completion: @escaping (_ result: String) -> Void) {
  299. completion(pumpManager.debugDescription)
  300. }
  301. func setConfirmationBeeps(_ preference: BeepPreference, _ completion: @escaping (_ error: LocalizedError?) -> Void) {
  302. pumpManager.setConfirmationBeeps(newPreference: preference) { error in
  303. DispatchQueue.main.async {
  304. if error == nil {
  305. self.beepPreference = preference
  306. }
  307. completion(error)
  308. }
  309. }
  310. }
  311. func setSilencePod(_ silencePodPreference: SilencePodPreference, _ completion: @escaping (_ error: LocalizedError?) -> Void) {
  312. pumpManager.setSilencePod(silencePod: silencePodPreference == .enabled) { error in
  313. DispatchQueue.main.async {
  314. if error == nil {
  315. self.silencePodPreference = silencePodPreference
  316. }
  317. completion(error)
  318. }
  319. }
  320. }
  321. func didChangeInsulinType(_ newType: InsulinType?) {
  322. self.pumpManager.insulinType = newType
  323. }
  324. var podOk: Bool {
  325. guard basalDeliveryState != nil else { return false }
  326. switch podCommState {
  327. case .noPod, .activating, .deactivating, .fault:
  328. return false
  329. default:
  330. return true
  331. }
  332. }
  333. var noPod: Bool {
  334. return podCommState == .noPod
  335. }
  336. var podError: String? {
  337. switch podCommState {
  338. case .fault(let status):
  339. switch status.faultEventCode.faultType {
  340. case .reservoirEmpty:
  341. return LocalizedString("No Insulin", comment: "Error message for reservoir view when reservoir empty")
  342. case .exceededMaximumPodLife80Hrs:
  343. return LocalizedString("Pod Expired", comment: "Error message for reservoir view when pod expired")
  344. case .occluded, .occlusionCheckStartup1, .occlusionCheckStartup2, .occlusionCheckTimeouts1, .occlusionCheckTimeouts2, .occlusionCheckTimeouts3, .occlusionCheckPulseIssue, .occlusionCheckBolusProblem, .occlusionCheckAboveThreshold, .occlusionCheckValueTooHigh:
  345. return LocalizedString("Pod Occlusion", comment: "Error message for reservoir view when pod occlusion checks failed")
  346. default:
  347. return String(format: LocalizedString("Pod Fault %1$03d", comment: "Error message for reservoir view during general pod fault: (1: fault code value)"), status.faultEventCode.rawValue)
  348. }
  349. case .active:
  350. if isPodDataStale {
  351. return LocalizedString("Signal Loss", comment: "Error message for reservoir view during general pod fault")
  352. } else {
  353. return nil
  354. }
  355. default:
  356. return nil
  357. }
  358. }
  359. func reservoirText(for level: ReservoirLevel) -> String {
  360. switch level {
  361. case .aboveThreshold:
  362. let quantity = HKQuantity(unit: .internationalUnit(), doubleValue: Pod.maximumReservoirReading)
  363. let thresholdString = reservoirVolumeFormatter.string(from: quantity, for: .internationalUnit(), includeUnit: false) ?? ""
  364. let unitString = reservoirVolumeFormatter.string(from: .internationalUnit(), forValue: Pod.maximumReservoirReading, avoidLineBreaking: true)
  365. return String(format: LocalizedString("%1$@+ %2$@", comment: "Format string for reservoir level above max measurable threshold. (1: measurable reservoir threshold) (2: units)"),
  366. thresholdString, unitString)
  367. case .valid(let value):
  368. let quantity = HKQuantity(unit: .internationalUnit(), doubleValue: value)
  369. return reservoirVolumeFormatter.string(from: quantity, for: .internationalUnit()) ?? ""
  370. }
  371. }
  372. var suspendResumeActionText: String {
  373. let defaultText = LocalizedString("Suspend Insulin Delivery", comment: "Text for suspend resume button when insulin delivery active")
  374. guard podOk else {
  375. return defaultText
  376. }
  377. switch basalDeliveryState {
  378. case .suspending:
  379. return LocalizedString("Suspending insulin delivery...", comment: "Text for suspend resume button when insulin delivery is suspending")
  380. case .suspended:
  381. return LocalizedString("Resume Insulin Delivery", comment: "Text for suspend resume button when insulin delivery is suspended")
  382. case .resuming:
  383. return LocalizedString("Resuming insulin delivery...", comment: "Text for suspend resume button when insulin delivery is resuming")
  384. default:
  385. return defaultText
  386. }
  387. }
  388. var basalTransitioning: Bool {
  389. switch basalDeliveryState {
  390. case .suspending, .resuming:
  391. return true
  392. default:
  393. return false
  394. }
  395. }
  396. func suspendResumeButtonColor(guidanceColors: GuidanceColors) -> Color {
  397. guard podOk else {
  398. return Color.secondary
  399. }
  400. switch basalDeliveryState {
  401. case .suspending, .resuming:
  402. return Color.secondary
  403. case .suspended:
  404. return guidanceColors.warning
  405. default:
  406. return .accentColor
  407. }
  408. }
  409. func suspendResumeActionColor() -> Color {
  410. guard podOk else {
  411. return Color.secondary
  412. }
  413. switch basalDeliveryState {
  414. case .suspending, .resuming:
  415. return Color.secondary
  416. default:
  417. return Color.accentColor
  418. }
  419. }
  420. var isSuspendedOrResuming: Bool {
  421. switch basalDeliveryState {
  422. case .suspended, .resuming:
  423. return true
  424. default:
  425. return false
  426. }
  427. }
  428. public var allowedTempBasalRates: [Double] {
  429. return Pod.supportedTempBasalRates.filter { $0 <= pumpManager.state.maximumTempBasalRate }
  430. }
  431. }
  432. extension OmnipodSettingsViewModel: PodStateObserver {
  433. func podStateDidUpdate(_ state: PodState?) {
  434. lifeState = self.pumpManager.lifeState
  435. basalDeliveryRate = self.pumpManager.basalDeliveryRate
  436. reservoirLevel = self.pumpManager.reservoirLevel
  437. activatedAt = state?.activatedAt
  438. expiresAt = state?.expiresAt
  439. reservoirLevelHighlightState = self.pumpManager.reservoirLevelHighlightState
  440. expirationReminderDate = self.pumpManager.scheduledExpirationReminder
  441. podCommState = self.pumpManager.podCommState
  442. beepPreference = self.pumpManager.beepPreference
  443. insulinType = self.pumpManager.insulinType
  444. podDetails = self.pumpManager.podDetails
  445. previousPodDetails = self.pumpManager.previousPodDetails
  446. }
  447. func podConnectionStateDidChange(isConnected: Bool) {
  448. self.rileylinkConnected = isConnected
  449. }
  450. }
  451. extension OmnipodSettingsViewModel: PumpManagerStatusObserver {
  452. func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) {
  453. basalDeliveryState = self.pumpManager.status.basalDeliveryState
  454. }
  455. }
  456. extension OmnipodPumpManager {
  457. var lifeState: PodLifeState {
  458. switch podCommState {
  459. case .fault(let status):
  460. switch status.faultEventCode.faultType {
  461. case .exceededMaximumPodLife80Hrs:
  462. return .expired
  463. default:
  464. let remaining = Pod.nominalPodLife - (status.faultEventTimeSinceActivation ?? Pod.nominalPodLife)
  465. let podTimeUntilReminder = remaining - (state.scheduledExpirationReminderOffset ?? 0)
  466. if remaining > 0 {
  467. return .timeRemaining(timeUntilExpiration: remaining, timeUntilExpirationReminder: podTimeUntilReminder)
  468. } else {
  469. return .expired
  470. }
  471. }
  472. case .noPod:
  473. return .noPod
  474. case .activating:
  475. return .podActivating
  476. case .deactivating:
  477. return .podDeactivating
  478. case .active:
  479. if let podTimeRemaining = podTimeRemaining {
  480. if podTimeRemaining > 0 {
  481. let podTimeUntilReminder = podTimeRemaining - (state.scheduledExpirationReminderOffset ?? 0)
  482. return .timeRemaining(timeUntilExpiration: podTimeRemaining, timeUntilExpirationReminder: podTimeUntilReminder)
  483. } else {
  484. return .expired
  485. }
  486. } else {
  487. return .podDeactivating
  488. }
  489. }
  490. }
  491. var basalDeliveryRate: Double? {
  492. if let tempBasal = state.podState?.unfinalizedTempBasal, !tempBasal.isFinished() {
  493. return tempBasal.rate
  494. } else {
  495. switch state.podState?.suspendState {
  496. case .resumed:
  497. var calendar = Calendar(identifier: .gregorian)
  498. calendar.timeZone = state.timeZone
  499. return state.basalSchedule.currentRate(using: calendar, at: dateGenerator())
  500. case .suspended, .none:
  501. return nil
  502. }
  503. }
  504. }
  505. fileprivate var podServiceTimeRemaining : TimeInterval? {
  506. guard let podTimeRemaining = podTimeRemaining else {
  507. return nil;
  508. }
  509. return max(0, Pod.serviceDuration - Pod.nominalPodLife + podTimeRemaining);
  510. }
  511. private func podDetails(fromPodState podState: PodState) -> PodDetails {
  512. return PodDetails(
  513. lotNumber: podState.lot,
  514. sequenceNumber: podState.tid,
  515. piVersion: podState.piVersion,
  516. pmVersion: podState.pmVersion,
  517. totalDelivery: podState.lastInsulinMeasurements?.delivered,
  518. lastStatus: podState.lastInsulinMeasurements?.validTime,
  519. fault: podState.fault?.faultEventCode,
  520. activatedAt: podState.activatedAt,
  521. activeTime: podState.activeTime,
  522. pdmRef: podState.fault?.pdmRef
  523. )
  524. }
  525. public var podDetails: PodDetails? {
  526. guard let podState = state.podState else {
  527. return nil
  528. }
  529. return podDetails(fromPodState: podState)
  530. }
  531. public var previousPodDetails: PodDetails? {
  532. guard let podState = state.previousPodState else {
  533. return nil
  534. }
  535. return podDetails(fromPodState: podState)
  536. }
  537. }