TreatmentsStateModel.swift 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import LocalAuthentication
  5. import LoopKit
  6. import Observation
  7. import SwiftUI
  8. import Swinject
  9. extension Treatments {
  10. @Observable final class StateModel: BaseStateModel<Provider> {
  11. @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
  12. @ObservationIgnored @Injected() var apsManager: APSManager!
  13. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  14. @ObservationIgnored @Injected() var pumpHistoryStorage: PumpHistoryStorage!
  15. @ObservationIgnored @Injected() var settings: SettingsManager!
  16. @ObservationIgnored @Injected() var nsManager: NightscoutManager!
  17. @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
  18. @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
  19. @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
  20. @ObservationIgnored @Injected() var bolusCalculationManager: BolusCalculationManager!
  21. var lowGlucose: Decimal = 70
  22. var highGlucose: Decimal = 180
  23. var glucoseColorScheme: GlucoseColorScheme = .staticColor
  24. var predictions: Predictions?
  25. var amount: Decimal = 0
  26. var insulinRecommended: Decimal = 0
  27. var insulinRequired: Decimal = 0
  28. var units: GlucoseUnits = .mgdL
  29. var threshold: Decimal = 0
  30. var maxBolus: Decimal = 0
  31. var maxExternal: Decimal { maxBolus * 3 }
  32. var maxIOB: Decimal = 0
  33. var maxCOB: Decimal = 0
  34. var errorString: Decimal = 0
  35. var evBG: Decimal = 0
  36. var insulin: Decimal = 0
  37. var isf: Decimal = 0
  38. var error: Bool = false
  39. var minGuardBG: Decimal = 0
  40. var minDelta: Decimal = 0
  41. var expectedDelta: Decimal = 0
  42. var minPredBG: Decimal = 0
  43. var lastLoopDate: Date?
  44. var isAwaitingDeterminationResult: Bool = false
  45. var carbRatio: Decimal = 0
  46. var addButtonPressed: Bool = false
  47. var target: Decimal = 0
  48. var cob: Int16 = 0
  49. var iob: Decimal = 0
  50. var currentBG: Decimal = 0
  51. var fifteenMinInsulin: Decimal = 0
  52. var deltaBG: Decimal = 0
  53. var targetDifferenceInsulin: Decimal = 0
  54. var targetDifference: Decimal = 0
  55. var wholeCob: Decimal = 0
  56. var wholeCobInsulin: Decimal = 0
  57. var iobInsulinReduction: Decimal = 0
  58. var wholeCalc: Decimal = 0
  59. var factoredInsulin: Decimal = 0
  60. var insulinCalculated: Decimal = 0
  61. var fraction: Decimal = 0
  62. var basal: Decimal = 0
  63. var fattyMeals: Bool = false
  64. var fattyMealFactor: Decimal = 0
  65. var useFattyMealCorrectionFactor: Bool = false
  66. var displayPresets: Bool = true
  67. var confirmBolus: Bool = false
  68. var currentBasal: Decimal = 0
  69. var currentCarbRatio: Decimal = 0
  70. var currentBGTarget: Decimal = 0
  71. var currentISF: Decimal = 0
  72. var sweetMeals: Bool = false
  73. var sweetMealFactor: Decimal = 0
  74. var useSuperBolus: Bool = false
  75. var superBolusInsulin: Decimal = 0
  76. var meal: [CarbsEntry]?
  77. var carbs: Decimal = 0
  78. var fat: Decimal = 0
  79. var protein: Decimal = 0
  80. var note: String = ""
  81. var date = Date()
  82. let defaultDate = Date()
  83. var carbsRequired: Decimal?
  84. var useFPUconversion: Bool = false
  85. var dish: String = ""
  86. var selection: MealPresetStored?
  87. var summation: [String] = []
  88. var maxCarbs: Decimal = 0
  89. var maxFat: Decimal = 0
  90. var maxProtein: Decimal = 0
  91. var id_: String = ""
  92. var summary: String = ""
  93. var externalInsulin: Bool = false
  94. var showInfo: Bool = false
  95. var glucoseFromPersistence: [GlucoseStored] = []
  96. var determination: [OrefDetermination] = []
  97. var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
  98. var predictionsForChart: Predictions?
  99. var simulatedDetermination: Determination?
  100. @MainActor var determinationObjectIDs: [NSManagedObjectID] = []
  101. var minForecast: [Int] = []
  102. var maxForecast: [Int] = []
  103. @MainActor var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
  104. var forecastDisplayType: ForecastDisplayType = .cone
  105. var isSmoothingEnabled: Bool = false
  106. var stops: [Gradient.Stop] = []
  107. let now = Date.now
  108. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  109. let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
  110. let determinationFetchContext = CoreDataStack.shared.newTaskContext()
  111. let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
  112. var isActive: Bool = false
  113. var showDeterminationFailureAlert = false
  114. var determinationFailureMessage = ""
  115. // Queue for handling Core Data change notifications
  116. private let queue = DispatchQueue(label: "TreatmentsStateModel.queue", qos: .userInitiated)
  117. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  118. private var subscriptions = Set<AnyCancellable>()
  119. typealias PumpEvent = PumpEventStored.EventType
  120. var bolusProgress: Decimal?
  121. var bolusStatus: BolusStatus = .noBolus
  122. var lastPumpBolus: PumpEventStored?
  123. func unsubscribe() {
  124. subscriptions.forEach { $0.cancel() }
  125. subscriptions.removeAll()
  126. }
  127. override func subscribe() {
  128. guard isActive else {
  129. return
  130. }
  131. debug(.bolusState, "subscribe fired")
  132. coreDataPublisher =
  133. changedObjectsOnManagedObjectContextDidSavePublisher()
  134. .receive(on: queue)
  135. .share()
  136. .eraseToAnyPublisher()
  137. registerHandlers()
  138. registerSubscribers()
  139. setupBolusStateConcurrently()
  140. subscribeToBolusProgress()
  141. }
  142. deinit {
  143. debug(.bolusState, "StateModel deinit called")
  144. }
  145. private var hasCleanedUp = false
  146. func cleanupTreatmentState() {
  147. guard !hasCleanedUp else { return }
  148. hasCleanedUp = true
  149. unsubscribe()
  150. lifetime = Lifetime()
  151. broadcaster?.unregister(DeterminationObserver.self, observer: self)
  152. broadcaster?.unregister(BolusFailureObserver.self, observer: self)
  153. debug(.bolusState, "StateModel cleanup() finished")
  154. }
  155. private func setupBolusStateConcurrently() {
  156. debug(.bolusState, "Setting up bolus state concurrently...")
  157. Task {
  158. do {
  159. try await withThrowingTaskGroup(of: Void.self) { group in
  160. group.addTask {
  161. self.setupGlucoseArray()
  162. }
  163. group.addTask {
  164. self.setupDeterminationsAndForecasts()
  165. }
  166. group.addTask {
  167. await self.setupSettings()
  168. }
  169. group.addTask {
  170. self.registerObservers()
  171. }
  172. group.addTask {
  173. self.setupLastBolus()
  174. }
  175. // Wait for all tasks to complete
  176. try await group.waitForAll()
  177. }
  178. } catch let error as NSError {
  179. debug(.default, "Failed to setup bolus state concurrently: \(error)")
  180. }
  181. }
  182. }
  183. /// Mirrors `apsManager.bolusProgress` (a `CurrentValueSubject<Decimal?, Never>`) directly into the
  184. /// state model so the View can read both the progress fraction (0.0–1.0) and a derived in-progress
  185. /// flag. Stored in `lifetime` to match the Home module's pattern (HomeStateModel.registerObservers).
  186. private func subscribeToBolusProgress() {
  187. apsManager.bolusProgress
  188. .receive(on: DispatchQueue.main)
  189. .weakAssign(to: \.bolusProgress, on: self)
  190. .store(in: &lifetime)
  191. provider.deviceManager.bolusTrigger
  192. .receive(on: DispatchQueue.main)
  193. .weakAssign(to: \.bolusStatus, on: self)
  194. .store(in: &lifetime)
  195. }
  196. func cancelBolus() {
  197. Task {
  198. await apsManager.cancelBolus(nil)
  199. try? await apsManager.determineBasalSync()
  200. }
  201. }
  202. // MARK: - Basal
  203. private enum SettingType {
  204. case basal
  205. case carbRatio
  206. case bgTarget
  207. case isf
  208. }
  209. func getAllSettingsValues() async {
  210. await withTaskGroup(of: Void.self) { group in
  211. group.addTask {
  212. await self.getCurrentSettingValue(for: .basal)
  213. }
  214. group.addTask {
  215. await self.getCurrentSettingValue(for: .carbRatio)
  216. }
  217. group.addTask {
  218. await self.getCurrentSettingValue(for: .bgTarget)
  219. }
  220. group.addTask {
  221. await self.getCurrentSettingValue(for: .isf)
  222. }
  223. group.addTask {
  224. let getMaxBolus = await self.provider.getPumpSettings().maxBolus
  225. await MainActor.run {
  226. self.maxBolus = getMaxBolus
  227. }
  228. }
  229. group.addTask {
  230. let getPreferences = await self.provider.getPreferences()
  231. await MainActor.run {
  232. self.maxIOB = getPreferences.maxIOB
  233. self.maxCOB = getPreferences.maxCOB
  234. }
  235. }
  236. }
  237. }
  238. private func setupDeterminationsAndForecasts() {
  239. Task {
  240. async let getAllSettingsDefaults: () = getAllSettingsValues()
  241. async let setupDeterminations: () = setupDeterminationsArray()
  242. await getAllSettingsDefaults
  243. await setupDeterminations
  244. // Determination has updated, so we can use this to draw the initial Forecast Chart
  245. let forecastData = await mapForecastsForChart()
  246. await updateForecasts(with: forecastData)
  247. }
  248. }
  249. private func registerObservers() {
  250. broadcaster.register(DeterminationObserver.self, observer: self)
  251. broadcaster.register(BolusFailureObserver.self, observer: self)
  252. }
  253. @MainActor private func setupSettings() async {
  254. units = settingsManager.settings.units
  255. fraction = settings.settings.overrideFactor
  256. fattyMeals = settings.settings.fattyMeals
  257. fattyMealFactor = settings.settings.fattyMealFactor
  258. sweetMeals = settings.settings.sweetMeals
  259. sweetMealFactor = settings.settings.sweetMealFactor
  260. displayPresets = settings.settings.displayPresets
  261. confirmBolus = settings.settings.confirmBolus
  262. forecastDisplayType = settings.settings.forecastDisplayType
  263. lowGlucose = settingsManager.settings.low
  264. highGlucose = settingsManager.settings.high
  265. maxCarbs = settings.settings.maxCarbs
  266. maxFat = settings.settings.maxFat
  267. maxProtein = settings.settings.maxProtein
  268. useFPUconversion = settingsManager.settings.useFPUconversion
  269. isSmoothingEnabled = settingsManager.settings.smoothGlucose
  270. glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  271. }
  272. private func getCurrentSettingValue(for type: SettingType) async {
  273. let now = Date()
  274. let calendar = Calendar.current
  275. let entries: [(start: String, value: Decimal)]
  276. switch type {
  277. case .basal:
  278. let basalEntries = await provider.getBasalProfile()
  279. entries = basalEntries.map { ($0.start, $0.rate) }
  280. case .carbRatio:
  281. let carbRatios = await provider.getCarbRatios()
  282. entries = carbRatios.schedule.map { ($0.start, $0.ratio) }
  283. case .bgTarget:
  284. let bgTargets = await provider.getBGTargets()
  285. entries = bgTargets.targets.map { ($0.start, $0.low) }
  286. case .isf:
  287. let isfValues = await provider.getISFValues()
  288. entries = isfValues.sensitivities.map { ($0.start, $0.sensitivity) }
  289. }
  290. for (index, entry) in entries.enumerated() {
  291. guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
  292. debug(.default, "Invalid entry start time: \(entry.start)")
  293. continue
  294. }
  295. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  296. let entryStartTime = calendar.date(
  297. bySettingHour: entryComponents.hour!,
  298. minute: entryComponents.minute!,
  299. second: entryComponents.second ?? 0, // Set seconds to 0 if not provided
  300. of: now
  301. )!
  302. let entryEndTime: Date
  303. if index < entries.count - 1 {
  304. if let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start) {
  305. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  306. entryEndTime = calendar.date(
  307. bySettingHour: nextEntryComponents.hour!,
  308. minute: nextEntryComponents.minute!,
  309. second: nextEntryComponents.second ?? 0,
  310. of: now
  311. )!
  312. } else {
  313. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  314. }
  315. } else {
  316. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  317. }
  318. if now >= entryStartTime, now < entryEndTime {
  319. await MainActor.run {
  320. switch type {
  321. case .basal:
  322. currentBasal = entry.value
  323. case .carbRatio:
  324. currentCarbRatio = entry.value
  325. case .bgTarget:
  326. currentBGTarget = entry.value
  327. case .isf:
  328. currentISF = entry.value
  329. }
  330. }
  331. return
  332. }
  333. }
  334. }
  335. // MARK: CALCULATIONS FOR THE BOLUS CALCULATOR
  336. /// Calculate insulin recommendation
  337. func calculateInsulin() async -> Decimal {
  338. // Safely get minPredBG on main thread
  339. let localMinPredBG = await MainActor.run {
  340. minPredBG
  341. }
  342. // Use the cob value of the simulation if we have a simulated determination
  343. var simulatedCOB: Int16?
  344. if let simulatedCobValue = simulatedDetermination?.cob {
  345. // Convert Decimal to Int16 and cap at maxCOB
  346. let cobInt16 = Int16(truncating: NSDecimalNumber(decimal: simulatedCobValue))
  347. let maxCobInt16 = Int16(truncating: NSDecimalNumber(decimal: maxCOB))
  348. simulatedCOB = min(maxCobInt16, cobInt16)
  349. }
  350. // Check if this is a backdated entry by comparing with the default date using a tolerance
  351. let isBackdated = abs(date.timeIntervalSince(defaultDate)) > 1.0
  352. let result = await bolusCalculationManager.handleBolusCalculation(
  353. carbs: carbs,
  354. useFattyMealCorrection: useFattyMealCorrectionFactor,
  355. useSuperBolus: useSuperBolus,
  356. lastLoopDate: apsManager.lastLoopDate,
  357. minPredBG: localMinPredBG,
  358. simulatedCOB: simulatedCOB,
  359. isBackdated: isBackdated
  360. )
  361. // Update state properties with calculation results on main thread
  362. await MainActor.run {
  363. targetDifference = result.targetDifference
  364. targetDifferenceInsulin = result.targetDifferenceInsulin
  365. wholeCob = result.wholeCob
  366. wholeCobInsulin = result.wholeCobInsulin
  367. iobInsulinReduction = result.iobInsulinReduction
  368. superBolusInsulin = result.superBolusInsulin
  369. wholeCalc = result.wholeCalc
  370. factoredInsulin = result.factoredInsulin
  371. fifteenMinInsulin = result.fifteenMinutesInsulin
  372. }
  373. return apsManager.roundBolus(amount: result.insulinCalculated)
  374. }
  375. // MARK: - Button tasks
  376. func invokeTreatmentsTask() {
  377. Task {
  378. debug(.bolusState, "invokeTreatmentsTask fired")
  379. await MainActor.run {
  380. self.addButtonPressed = true
  381. }
  382. let isInsulinGiven = amount > 0
  383. let isCarbsPresent = carbs > 0
  384. let isFatPresent = fat > 0
  385. let isProteinPresent = protein > 0
  386. if isCarbsPresent || isFatPresent || isProteinPresent {
  387. await saveMeal()
  388. }
  389. if isInsulinGiven {
  390. await handleInsulin(isExternal: externalInsulin)
  391. } else {
  392. hideModal()
  393. return
  394. }
  395. // If glucose data is stale end the custom loading animation by hiding the modal
  396. // Get date on Main thread
  397. let date = await MainActor.run {
  398. glucoseFromPersistence.first?.date
  399. }
  400. guard glucoseStorage.isGlucoseDataFresh(date) else {
  401. await MainActor.run {
  402. isAwaitingDeterminationResult = false
  403. showDeterminationFailureAlert = true
  404. determinationFailureMessage = "Glucose data is stale"
  405. }
  406. return hideModal()
  407. }
  408. }
  409. }
  410. // MARK: - Insulin
  411. private func handleInsulin(isExternal: Bool) async {
  412. debug(.bolusState, "handleInsulin fired")
  413. if !isExternal {
  414. await addPumpInsulin()
  415. } else {
  416. await addExternalInsulin()
  417. }
  418. }
  419. /// Returns a user-facing localized error message for a given authentication error.
  420. ///
  421. /// This function inspects the provided `Error` to determine whether it is an `LAError`,
  422. /// and maps its error code to a human-readable, localized string describing the reason
  423. /// for the failure. If the error is not an `LAError`, a generic fallback message is returned.
  424. ///
  425. /// - Parameter error: The `Error` returned from an authentication attempt (e.g., via `LAContext.evaluatePolicy`).
  426. /// - Returns: A localized `String` describing the cause of the authentication failure.
  427. private func parseAuthenticationError(from error: Error) -> String {
  428. guard let laError = error as? LAError else {
  429. return String(
  430. localized: "An unknown authentication error occurred. Please try again."
  431. )
  432. }
  433. switch laError.code {
  434. case .authenticationFailed:
  435. return String(
  436. localized: "Authentication failed. Please try again."
  437. )
  438. case .userCancel:
  439. return String(
  440. localized: "Authentication was canceled by you."
  441. )
  442. case .userFallback:
  443. return String(
  444. localized: "You tapped the fallback option, but no fallback method is configured."
  445. )
  446. case .systemCancel:
  447. return String(
  448. localized: "Authentication was canceled by the system. Try again."
  449. )
  450. case .appCancel:
  451. return String(
  452. localized: "Authentication was canceled by the app."
  453. )
  454. case .invalidContext:
  455. return String(
  456. localized: "Authentication context is invalid. Please try again."
  457. )
  458. case .notInteractive:
  459. return String(
  460. localized: "Authentication UI cannot be displayed. Try restarting the app."
  461. )
  462. case .passcodeNotSet:
  463. return String(
  464. localized: "Authentication requires a device passcode. Please set one in iOS Settings > Face ID & Passcode."
  465. )
  466. case .biometryNotAvailable:
  467. return String(
  468. localized: "Biometric authentication is not available on this device."
  469. )
  470. case .biometryNotEnrolled:
  471. return String(
  472. localized: "No biometric identities are enrolled. Please set up Face ID or Touch ID."
  473. )
  474. case .biometryLockout,
  475. .touchIDLockout:
  476. return String(
  477. localized: "Biometric authentication is locked due to multiple failed attempts. Please unlock your device using your passcode."
  478. )
  479. case .biometryDisconnected,
  480. .biometryNotPaired:
  481. return String(
  482. localized: "Biometric accessory is missing or not connected. Please reconnect it and try again."
  483. )
  484. default:
  485. return String(
  486. localized: "An unknown biometric authentication error occurred. Please try again."
  487. )
  488. }
  489. }
  490. func addPumpInsulin() async {
  491. guard amount > 0 else {
  492. showModal(for: nil)
  493. return
  494. }
  495. let maxAmount = Double(min(amount, maxBolus))
  496. do {
  497. let authenticated = try await unlockmanager.unlock()
  498. if authenticated {
  499. // show loading animation
  500. await MainActor.run {
  501. self.isAwaitingDeterminationResult = true
  502. }
  503. await apsManager.enactBolus(amount: maxAmount, isSMB: false, callback: nil)
  504. }
  505. } catch {
  506. debug(.bolusState, "Authentication error for pump bolus: \(error)")
  507. await MainActor.run {
  508. self.isAwaitingDeterminationResult = false
  509. self.showDeterminationFailureAlert = true
  510. self.determinationFailureMessage = parseAuthenticationError(from: error)
  511. }
  512. }
  513. }
  514. // MARK: - EXTERNAL INSULIN
  515. func addExternalInsulin() async {
  516. guard amount > 0 else {
  517. showModal(for: nil)
  518. return
  519. }
  520. await MainActor.run {
  521. self.amount = min(self.amount, self.maxBolus * 3)
  522. }
  523. do {
  524. let authenticated = try await unlockmanager.unlock()
  525. if authenticated {
  526. // show loading animation
  527. await MainActor.run {
  528. self.isAwaitingDeterminationResult = true
  529. }
  530. // store external dose to pump history
  531. await pumpHistoryStorage.storeExternalInsulinEvent(amount: amount, timestamp: date)
  532. // perform determine basal sync
  533. try await apsManager.determineBasalSync()
  534. }
  535. } catch {
  536. debug(.bolusState, "authentication error for external insulin: \(error)")
  537. await MainActor.run {
  538. self.isAwaitingDeterminationResult = false
  539. self.showDeterminationFailureAlert = true
  540. self.determinationFailureMessage = parseAuthenticationError(from: error)
  541. }
  542. }
  543. }
  544. // MARK: - Carbs
  545. func saveMeal() async {
  546. do {
  547. guard carbs > 0 || fat > 0 || protein > 0 else { return }
  548. await MainActor.run {
  549. self.carbs = min(self.carbs, self.maxCarbs)
  550. self.fat = min(self.fat, self.maxFat)
  551. self.protein = min(self.protein, self.maxProtein)
  552. self.id_ = UUID().uuidString
  553. }
  554. let carbsToStore = [CarbsEntry(
  555. id: id_,
  556. createdAt: now,
  557. actualDate: date,
  558. carbs: carbs,
  559. fat: fat,
  560. protein: protein,
  561. note: note,
  562. enteredBy: CarbsEntry.local,
  563. isFPU: false,
  564. fpuID: fat > 0 || protein > 0 ? UUID().uuidString : nil
  565. )]
  566. try await carbsStorage.storeCarbs(carbsToStore, areFetchedFromRemote: false)
  567. // only perform determine basal sync if the user doesn't use the pump bolus, otherwise the enact bolus func in the APSManger does a sync
  568. if amount <= 0 {
  569. await MainActor.run {
  570. self.isAwaitingDeterminationResult = true
  571. }
  572. try await apsManager.determineBasalSync()
  573. }
  574. } catch {
  575. debug(.default, "\(DebuggingIdentifiers.failed) Failed to save carbs: \(error)")
  576. }
  577. }
  578. // MARK: - Presets
  579. func deletePreset() {
  580. if selection != nil {
  581. viewContext.delete(selection!)
  582. do {
  583. guard viewContext.hasChanges else { return }
  584. try viewContext.save()
  585. } catch {
  586. print(error.localizedDescription)
  587. }
  588. carbs = 0
  589. fat = 0
  590. protein = 0
  591. }
  592. selection = nil
  593. }
  594. func removePresetFromNewMeal() {
  595. let a = summation.firstIndex(where: { $0 == selection?.dish! })
  596. if a != nil, summation[a ?? 0] != "" {
  597. summation.remove(at: a!)
  598. }
  599. }
  600. func addPresetToNewMeal() {
  601. if let selection = selection, let dish = selection.dish {
  602. summation.append(dish)
  603. }
  604. }
  605. func addNewPresetToWaitersNotepad(_ dish: String) {
  606. summation.append(dish)
  607. }
  608. func addToSummation() {
  609. summation.append(selection?.dish ?? "")
  610. }
  611. }
  612. }
  613. extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
  614. func determinationDidUpdate(_: Determination) {
  615. guard isActive else {
  616. debug(.bolusState, "skipping determinationDidUpdate; view not active")
  617. return
  618. }
  619. DispatchQueue.main.async {
  620. debug(.bolusState, "determinationDidUpdate fired")
  621. self.isAwaitingDeterminationResult = false
  622. if self.addButtonPressed {
  623. self.hideModal()
  624. }
  625. }
  626. }
  627. func bolusDidFail() {
  628. DispatchQueue.main.async {
  629. debug(.bolusState, "bolusDidFail fired")
  630. self.isAwaitingDeterminationResult = false
  631. if self.addButtonPressed {
  632. self.hideModal()
  633. }
  634. }
  635. }
  636. }
  637. extension Treatments.StateModel {
  638. private func registerHandlers() {
  639. coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
  640. guard let self = self else { return }
  641. Task {
  642. await self.setupDeterminationsArray()
  643. let forecastData = await self.mapForecastsForChart()
  644. await self.updateForecasts(with: forecastData)
  645. }
  646. }.store(in: &subscriptions)
  647. // Due to the Batch insert this only is used for observing Deletion of Glucose entries
  648. coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
  649. guard let self = self else { return }
  650. self.setupGlucoseArray()
  651. }.store(in: &subscriptions)
  652. // Refresh `lastPumpBolus` whenever a new pump event lands (mirrors HomeStateModel)
  653. coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
  654. self?.setupLastBolus()
  655. }.store(in: &subscriptions)
  656. }
  657. private func registerSubscribers() {
  658. glucoseStorage.updatePublisher
  659. .receive(on: DispatchQueue.global(qos: .background))
  660. .sink { [weak self] _ in
  661. guard let self = self else { return }
  662. self.setupGlucoseArray()
  663. }
  664. .store(in: &subscriptions)
  665. }
  666. }
  667. // MARK: - Setup Glucose and Determinations
  668. extension Treatments.StateModel {
  669. // Glucose
  670. private func setupGlucoseArray() {
  671. Task {
  672. do {
  673. let ids = try await self.fetchGlucose()
  674. let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
  675. .getNSManagedObject(with: ids, context: viewContext)
  676. await updateGlucoseArray(with: glucoseObjects)
  677. } catch {
  678. debug(
  679. .default,
  680. "\(DebuggingIdentifiers.failed) Error setting up glucose array: \(error)"
  681. )
  682. }
  683. }
  684. }
  685. private func fetchGlucose() async throws -> [NSManagedObjectID] {
  686. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  687. ofType: GlucoseStored.self,
  688. onContext: glucoseFetchContext,
  689. predicate: NSPredicate.glucose,
  690. key: "date",
  691. ascending: false
  692. )
  693. return try await glucoseFetchContext.perform {
  694. guard let fetchedResults = results as? [GlucoseStored] else {
  695. throw CoreDataError.fetchError(function: #function, file: #file)
  696. }
  697. return fetchedResults.map(\.objectID)
  698. }
  699. }
  700. @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
  701. // Store all objects for the forecast graph
  702. glucoseFromPersistence = objects
  703. // Always use the most recent reading for current glucose
  704. let lastGlucose = objects.first?.glucose ?? 0
  705. // Filter for readings less than 20 minutes old
  706. let twentyMinutesAgo = Date().addingTimeInterval(-20 * 60)
  707. let recentObjects = objects.filter {
  708. guard let date = $0.date else { return false }
  709. return date > twentyMinutesAgo
  710. }
  711. // Calculate delta using newest and oldest readings within 20-minute window
  712. let delta: Decimal
  713. if let newestInWindow = recentObjects.first?.glucose, let oldestInWindow = recentObjects.last?.glucose {
  714. // Newest is at index 0, oldest is at the last index
  715. delta = Decimal(newestInWindow) - Decimal(oldestInWindow)
  716. } else {
  717. // Not enough data points in the window
  718. delta = 0
  719. }
  720. currentBG = Decimal(lastGlucose)
  721. deltaBG = delta
  722. }
  723. // Determinations
  724. private func setupDeterminationsArray() async {
  725. do {
  726. let fetchedObjectIDs = try await determinationStorage.fetchLastDeterminationObjectID(
  727. predicate: NSPredicate.predicateFor30MinAgoForDetermination
  728. )
  729. await MainActor.run {
  730. determinationObjectIDs = fetchedObjectIDs
  731. }
  732. let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
  733. .getNSManagedObject(with: determinationObjectIDs, context: viewContext)
  734. updateDeterminationsArray(with: determinationObjects)
  735. } catch let error as CoreDataError {
  736. debug(.default, "Core Data error: \(error)")
  737. } catch {
  738. debug(.default, "Unexpected error: \(error)")
  739. }
  740. }
  741. private func mapForecastsForChart() async -> Determination? {
  742. do {
  743. let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
  744. .getNSManagedObject(with: determinationObjectIDs, context: determinationFetchContext)
  745. let determination = await determinationFetchContext.perform {
  746. let determinationObject = determinationObjects.first
  747. let forecastsSet = determinationObject?.forecasts ?? []
  748. let predictions = Predictions(
  749. iob: forecastsSet.extractValues(for: "iob"),
  750. zt: forecastsSet.extractValues(for: "zt"),
  751. cob: forecastsSet.extractValues(for: "cob"),
  752. uam: forecastsSet.extractValues(for: "uam")
  753. )
  754. return Determination(
  755. id: UUID(),
  756. reason: "",
  757. units: 0,
  758. insulinReq: 0,
  759. sensitivityRatio: 0,
  760. rate: 0,
  761. duration: 0,
  762. iob: 0,
  763. cob: 0,
  764. predictions: predictions.isEmpty ? nil : predictions,
  765. carbsReq: 0,
  766. temp: nil,
  767. reservoir: 0,
  768. carbRatio: 0,
  769. received: false
  770. )
  771. }
  772. guard !determinationObjects.isEmpty else {
  773. return nil
  774. }
  775. return determination
  776. } catch {
  777. debug(
  778. .default,
  779. "\(DebuggingIdentifiers.failed) Error mapping forecasts for chart: \(error)"
  780. )
  781. return nil
  782. }
  783. }
  784. private func updateDeterminationsArray(with objects: [OrefDetermination]) {
  785. Task { @MainActor in
  786. guard let mostRecentDetermination = objects.first else { return }
  787. determination = objects
  788. // setup vars for bolus calculation
  789. insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
  790. evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
  791. minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
  792. lastLoopDate = apsManager.lastLoopDate as Date?
  793. insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
  794. target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
  795. isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
  796. cob = mostRecentDetermination.cob as Int16
  797. iob = (mostRecentDetermination.iob ?? 0) as Decimal
  798. basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
  799. carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
  800. insulinCalculated = await calculateInsulin()
  801. }
  802. }
  803. }
  804. extension Treatments.StateModel {
  805. @MainActor func updateForecasts(with forecastData: Determination? = nil) async {
  806. guard isActive else {
  807. return
  808. debug(.bolusState, "updateForecasts not fired")
  809. }
  810. debug(.bolusState, "updateForecasts fired")
  811. if let forecastData = forecastData {
  812. simulatedDetermination = forecastData
  813. debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)")
  814. } else {
  815. simulatedDetermination = await Task { [self] in
  816. debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
  817. return await apsManager.simulateDetermineBasal(
  818. simulatedCarbsAmount: carbs,
  819. simulatedBolusAmount: amount,
  820. simulatedCarbsDate: date
  821. )
  822. }.value
  823. // Update evBG and minPredBG from simulated determination
  824. if let simDetermination = simulatedDetermination {
  825. evBG = Decimal(simDetermination.eventualBG ?? 0)
  826. minPredBG = simDetermination.minPredBGFromReason ?? 0
  827. debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)")
  828. }
  829. }
  830. predictionsForChart = simulatedDetermination?.predictions
  831. let nonEmptyArrays = [
  832. predictionsForChart?.iob,
  833. predictionsForChart?.zt,
  834. predictionsForChart?.cob,
  835. predictionsForChart?.uam
  836. ]
  837. .compactMap { $0 }
  838. .filter { !$0.isEmpty }
  839. guard !nonEmptyArrays.isEmpty else {
  840. minForecast = []
  841. maxForecast = []
  842. return
  843. }
  844. minCount = max(12, nonEmptyArrays.map(\.count).min() ?? 0)
  845. guard minCount > 0 else { return }
  846. async let minForecastResult = Task {
  847. await (0 ..< self.minCount).map { index in
  848. nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.min() ?? 0
  849. }
  850. }.value
  851. async let maxForecastResult = Task {
  852. await (0 ..< self.minCount).map { index in
  853. nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.max() ?? 0
  854. }
  855. }.value
  856. minForecast = await minForecastResult
  857. maxForecast = await maxForecastResult
  858. }
  859. }
  860. private extension Set where Element == Forecast {
  861. func extractValues(for type: String) -> [Int]? {
  862. let values = first { $0.type == type }?
  863. .forecastValues?
  864. .sorted { $0.index < $1.index }
  865. .compactMap { Int($0.value) }
  866. return values?.isEmpty ?? true ? nil : values
  867. }
  868. }
  869. private extension Predictions {
  870. var isEmpty: Bool {
  871. iob == nil && zt == nil && cob == nil && uam == nil
  872. }
  873. }
  874. // MARK: - Last Pump Bolus
  875. extension Treatments.StateModel {
  876. /// Mirrors `HomeStateModel.setupLastBolus` so the in-progress visualizer can show the
  877. /// running pump-bolus's amount as the denominator (not the user's pending entry).
  878. /// Filters out external boluses via `NSPredicate.lastPumpBolus`.
  879. func setupLastBolus() {
  880. Task {
  881. do {
  882. guard let id = try await fetchLastBolus() else { return }
  883. await updateLastBolus(with: id)
  884. } catch {
  885. debug(.default, "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)")
  886. }
  887. }
  888. }
  889. private func fetchLastBolus() async throws -> NSManagedObjectID? {
  890. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  891. ofType: PumpEventStored.self,
  892. onContext: pumpHistoryFetchContext,
  893. predicate: NSPredicate.lastPumpBolus,
  894. key: "timestamp",
  895. ascending: false,
  896. fetchLimit: 1
  897. )
  898. return try await pumpHistoryFetchContext.perform {
  899. guard let fetched = results as? [PumpEventStored] else {
  900. throw CoreDataError.fetchError(function: #function, file: #file)
  901. }
  902. return fetched.map(\.objectID).first
  903. }
  904. }
  905. @MainActor private func updateLastBolus(with id: NSManagedObjectID) {
  906. do {
  907. lastPumpBolus = try viewContext.existingObject(with: id) as? PumpEventStored
  908. } catch {
  909. debug(.default, "\(DebuggingIdentifiers.failed) updateLastBolus: \(error)")
  910. }
  911. }
  912. }