OmnipodSettingsViewModel.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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 basal rates. You can 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. if remaining > 0 {
  434. return .timeRemaining(remaining)
  435. } else {
  436. return .expired
  437. }
  438. }
  439. case .noPod:
  440. return .noPod
  441. case .activating:
  442. return .podActivating
  443. case .deactivating:
  444. return .podDeactivating
  445. case .active:
  446. if let podTimeRemaining = podTimeRemaining {
  447. if podTimeRemaining > 0 {
  448. return .timeRemaining(podTimeRemaining)
  449. } else {
  450. return .expired
  451. }
  452. } else {
  453. return .podDeactivating
  454. }
  455. }
  456. }
  457. var basalDeliveryRate: Double? {
  458. if let tempBasal = state.podState?.unfinalizedTempBasal, !tempBasal.isFinished() {
  459. return tempBasal.rate
  460. } else {
  461. switch state.podState?.suspendState {
  462. case .resumed:
  463. var calendar = Calendar(identifier: .gregorian)
  464. calendar.timeZone = state.timeZone
  465. return state.basalSchedule.currentRate(using: calendar, at: dateGenerator())
  466. case .suspended, .none:
  467. return nil
  468. }
  469. }
  470. }
  471. fileprivate var podServiceTimeRemaining : TimeInterval? {
  472. guard let podTimeRemaining = podTimeRemaining else {
  473. return nil;
  474. }
  475. return max(0, Pod.serviceDuration - Pod.nominalPodLife + podTimeRemaining);
  476. }
  477. private func podDetails(fromPodState podState: PodState) -> PodDetails {
  478. return PodDetails(
  479. lotNumber: podState.lot,
  480. sequenceNumber: podState.tid,
  481. piVersion: podState.piVersion,
  482. pmVersion: podState.pmVersion,
  483. totalDelivery: podState.lastInsulinMeasurements?.delivered,
  484. lastStatus: podState.lastInsulinMeasurements?.validTime,
  485. fault: podState.fault?.faultEventCode,
  486. activatedAt: podState.activatedAt,
  487. activeTime: podState.activeTime,
  488. pdmRef: podState.fault?.pdmRef
  489. )
  490. }
  491. public var podDetails: PodDetails? {
  492. guard let podState = state.podState else {
  493. return nil
  494. }
  495. return podDetails(fromPodState: podState)
  496. }
  497. public var previousPodDetails: PodDetails? {
  498. guard let podState = state.previousPodState else {
  499. return nil
  500. }
  501. return podDetails(fromPodState: podState)
  502. }
  503. }