OmnipodSettingsViewModel.swift 21 KB

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