TreatmentsStateModel.swift 39 KB

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