DataTableStateModel.swift 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import CoreData
  2. import HealthKit
  3. import Observation
  4. import SwiftUI
  5. extension DataTable {
  6. @Observable final class StateModel: BaseStateModel<Provider> {
  7. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  8. @ObservationIgnored @Injected() var apsManager: APSManager!
  9. @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
  10. @ObservationIgnored @Injected() private var storage: FileStorage!
  11. @ObservationIgnored @Injected() var pumpHistoryStorage: PumpHistoryStorage!
  12. @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
  13. @ObservationIgnored @Injected() var healthKitManager: HealthKitManager!
  14. @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
  15. let coredataContext = CoreDataStack.shared.newTaskContext()
  16. var mode: Mode = .treatments
  17. var treatments: [Treatment] = []
  18. var manualGlucose: Decimal = 0
  19. var waitForSuggestion: Bool = false
  20. var insulinEntryDeleted: Bool = false
  21. var carbEntryDeleted: Bool = false
  22. var units: GlucoseUnits = .mgdL
  23. var carbEntryToEdit: CarbEntryStored?
  24. var showCarbEntryEditor = false
  25. override func subscribe() {
  26. units = settingsManager.settings.units
  27. broadcaster.register(DeterminationObserver.self, observer: self)
  28. broadcaster.register(SettingsObserver.self, observer: self)
  29. }
  30. /// Checks if the glucose data is fresh based on the given date
  31. /// - Parameter glucoseDate: The date to check
  32. /// - Returns: Boolean indicating if the data is fresh
  33. func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool {
  34. glucoseStorage.isGlucoseDataFresh(glucoseDate)
  35. }
  36. /// Initiates the glucose deletion process asynchronously
  37. /// - Parameter treatmentObjectID: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
  38. func invokeGlucoseDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
  39. Task {
  40. await deleteGlucose(treatmentObjectID)
  41. }
  42. }
  43. func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
  44. // Delete from Apple Health/Tidepool
  45. await deleteGlucoseFromServices(treatmentObjectID)
  46. // Delete from Core Data
  47. await glucoseStorage.deleteGlucose(treatmentObjectID)
  48. }
  49. func deleteGlucoseFromServices(_ treatmentObjectID: NSManagedObjectID) async {
  50. let taskContext = CoreDataStack.shared.newTaskContext()
  51. taskContext.name = "deleteContext"
  52. taskContext.transactionAuthor = "deleteGlucoseFromServices"
  53. await taskContext.perform {
  54. do {
  55. let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
  56. guard let glucoseToDelete = result else {
  57. debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
  58. return
  59. }
  60. // Delete from Nightscout
  61. if let id = glucoseToDelete.id?.uuidString {
  62. self.provider.deleteManualGlucoseFromNightscout(withID: id)
  63. }
  64. // Delete from Apple Health
  65. if let id = glucoseToDelete.id?.uuidString {
  66. self.provider.deleteGlucoseFromHealth(withSyncID: id)
  67. }
  68. debugPrint(
  69. "\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from remote service(s) (Nightscout, Apple Health, Tidepool)"
  70. )
  71. } catch {
  72. debugPrint(
  73. "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)"
  74. )
  75. }
  76. }
  77. }
  78. func addManualGlucose() {
  79. // Always save value in mg/dL
  80. let glucose = units == .mmolL ? manualGlucose.asMgdL : manualGlucose
  81. let glucoseAsInt = Int(glucose)
  82. glucoseStorage.addManualGlucose(glucose: glucoseAsInt)
  83. }
  84. // Carb and FPU deletion from history
  85. /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
  86. func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) {
  87. Task {
  88. await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal)
  89. await MainActor.run {
  90. carbEntryDeleted = true
  91. waitForSuggestion = true
  92. }
  93. }
  94. }
  95. func deleteCarbs(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) async {
  96. // Delete from Nightscout/Apple Health/Tidepool
  97. if isFpuOrComplexMeal {
  98. await deleteCarbsAndFPUsFromServices(treatmentObjectID)
  99. } else {
  100. await deleteCarbsFromServices(treatmentObjectID)
  101. }
  102. // Delete carbs from Core Data
  103. await carbsStorage.deleteCarbsEntryStored(treatmentObjectID)
  104. // Perform a determine basal sync to update cob
  105. await apsManager.determineBasalSync()
  106. }
  107. func deleteCarbsFromServices(_ treatmentObjectID: NSManagedObjectID) async {
  108. let taskContext = CoreDataStack.shared.newTaskContext()
  109. taskContext.name = "deleteContext"
  110. taskContext.transactionAuthor = "deleteCarbsFromServices"
  111. var carbEntry: CarbEntryStored?
  112. // Delete carbs or FPUs from Nightscout
  113. await taskContext.perform {
  114. do {
  115. carbEntry = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
  116. guard let carbEntry = carbEntry else {
  117. debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)")
  118. return
  119. }
  120. if carbEntry.isFPU, let fpuID = carbEntry.fpuID {
  121. // Delete Fat and Protein entries from Nightscout
  122. self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
  123. // Delete Fat and Protein entries from Apple Health
  124. let healthObjectsToDelete: [HKSampleType?] = [
  125. AppleHealthConfig.healthFatObject,
  126. AppleHealthConfig.healthProteinObject
  127. ]
  128. for sampleType in healthObjectsToDelete {
  129. if let validSampleType = sampleType {
  130. self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType)
  131. }
  132. }
  133. } else {
  134. // Delete carbs from Nightscout
  135. if let id = carbEntry.id, let entryDate = carbEntry.date {
  136. self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
  137. // Delete carbs from Apple Health
  138. if let sampleType = AppleHealthConfig.healthCarbObject {
  139. self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
  140. }
  141. self.provider.deleteCarbsFromTidepool(
  142. withSyncId: id,
  143. carbs: Decimal(carbEntry.carbs),
  144. at: entryDate,
  145. enteredBy: CarbsEntry.local
  146. )
  147. }
  148. }
  149. } catch {
  150. debugPrint(
  151. "\(DebuggingIdentifiers.failed) Error deleting carb entry from remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)"
  152. )
  153. }
  154. }
  155. }
  156. func deleteCarbsAndFPUsFromServices(_ treatmentObjectID: NSManagedObjectID) async {
  157. let taskContext = CoreDataStack.shared.newTaskContext()
  158. taskContext.name = "deleteContext"
  159. taskContext.transactionAuthor = "deleteCarbsFromServices"
  160. var carbEntryFromCoreData: CarbEntryStored?
  161. guard let correspondingCarbEntry: (
  162. entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String)?,
  163. entryID: NSManagedObjectID?
  164. ) = await handleFPUEntry(treatmentObjectID),
  165. let nsManagedObjectID: NSManagedObjectID = correspondingCarbEntry.entryID else { return }
  166. // Delete carbs or FPUs from Nightscout
  167. await taskContext.perform {
  168. do {
  169. carbEntryFromCoreData = try taskContext.existingObject(with: nsManagedObjectID) as? CarbEntryStored
  170. guard let carbEntry = carbEntryFromCoreData else {
  171. debugPrint("FPU entry or corresponding carb entry for deletion not found. \(DebuggingIdentifiers.failed)")
  172. return
  173. }
  174. if let fpuID = carbEntry.fpuID {
  175. // Delete Fat and Protein entries from Nightscout
  176. self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
  177. // Delete Fat and Protein entries from Apple Health
  178. let healthObjectsToDelete: [HKSampleType?] = [
  179. AppleHealthConfig.healthFatObject,
  180. AppleHealthConfig.healthProteinObject
  181. ]
  182. for sampleType in healthObjectsToDelete {
  183. if let validSampleType = sampleType {
  184. self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType)
  185. }
  186. }
  187. // if entry has carbs AND fat and/or protein, we also need to remove carbs here
  188. if carbEntry.carbs > 0, let id = carbEntry.id {
  189. self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
  190. // Delete carbs from Apple Health
  191. if let sampleType = AppleHealthConfig.healthCarbObject {
  192. self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
  193. }
  194. }
  195. }
  196. // entry has no fpuID
  197. // => it's a carb-only entry. use its ID to for all deletion operations
  198. else if let id = carbEntry.id, let entryDate = carbEntry.date {
  199. self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
  200. // Delete carbs from Apple Health
  201. if let sampleType = AppleHealthConfig.healthCarbObject {
  202. self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
  203. }
  204. self.provider.deleteCarbsFromTidepool(
  205. withSyncId: id,
  206. carbs: Decimal(carbEntry.carbs),
  207. at: entryDate,
  208. enteredBy: CarbsEntry.local
  209. )
  210. }
  211. } catch {
  212. debugPrint(
  213. "\(DebuggingIdentifiers.failed) Error deleting carb entry (and associated fpu entries) from remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)"
  214. )
  215. }
  216. }
  217. }
  218. // Insulin deletion from history
  219. /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
  220. func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
  221. Task {
  222. await invokeInsulinDeletion(treatmentObjectID)
  223. await MainActor.run {
  224. insulinEntryDeleted = true
  225. waitForSuggestion = true
  226. }
  227. }
  228. }
  229. func invokeInsulinDeletion(_ treatmentObjectID: NSManagedObjectID) async {
  230. do {
  231. let authenticated = try await unlockmanager.unlock()
  232. guard authenticated else {
  233. debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Authentication Error")
  234. return
  235. }
  236. // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
  237. await deleteInsulinFromServices(with: treatmentObjectID)
  238. // Delete from Core Data
  239. await CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID)
  240. // Perform a determine basal sync to update iob
  241. await apsManager.determineBasalSync()
  242. } catch {
  243. debugPrint(
  244. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)"
  245. )
  246. }
  247. }
  248. func deleteInsulinFromServices(with treatmentObjectID: NSManagedObjectID) async {
  249. let taskContext = CoreDataStack.shared.newTaskContext()
  250. taskContext.name = "deleteContext"
  251. taskContext.transactionAuthor = "deleteInsulinFromServices"
  252. await taskContext.perform {
  253. do {
  254. guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored
  255. else {
  256. debug(.default, "Could not cast the object to PumpEventStored")
  257. return
  258. }
  259. if let id = treatmentToDelete.id, let timestamp = treatmentToDelete.timestamp,
  260. let bolus = treatmentToDelete.bolus, let bolusAmount = bolus.amount
  261. {
  262. self.provider.deleteInsulinFromNightscout(withID: id)
  263. self.provider.deleteInsulinFromHealth(withSyncID: id)
  264. self.provider.deleteInsulinFromTidepool(withSyncId: id, amount: bolusAmount as Decimal, at: timestamp)
  265. }
  266. taskContext.delete(treatmentToDelete)
  267. try taskContext.save()
  268. debug(.default, "Successfully deleted the treatment object.")
  269. } catch {
  270. debug(.default, "Failed to delete the treatment object: \(error.localizedDescription)")
  271. }
  272. }
  273. }
  274. // MARK: - Entry Management
  275. /// Updates a carb/FPU entry with new values and handles the necessary cleanup and recreation of FPU entries
  276. /// - Parameters:
  277. /// - treatmentObjectID: The ID of the entry to update
  278. /// - newCarbs: The new carbs value
  279. /// - newFat: The new fat value
  280. /// - newProtein: The new protein value
  281. /// - newNote: The new note text
  282. func updateEntry(
  283. _ treatmentObjectID: NSManagedObjectID,
  284. newCarbs: Decimal,
  285. newFat: Decimal,
  286. newProtein: Decimal,
  287. newNote: String
  288. ) {
  289. Task {
  290. // Get original date from entry to re-create the entry later with the updated values and the same date
  291. guard let originalEntry = await getOriginalEntryValues(treatmentObjectID) else { return }
  292. // TODO: theoretically we now already have the original entry here => we maybe don't need to perform all the fetching in the deleteServices method 2 levels down 👀
  293. // Deletion logic for carb and FPU entries
  294. await deleteOldEntries(
  295. treatmentObjectID,
  296. originalEntry: originalEntry,
  297. newCarbs: newCarbs,
  298. newFat: newFat,
  299. newProtein: newProtein,
  300. newNote: newNote
  301. )
  302. await createNewEntries(
  303. originalDate: originalEntry.entryValues?.date ?? Date(),
  304. // TODO: should we add this to the guard or is nullish coalesce safe enough?
  305. newCarbs: newCarbs,
  306. newFat: newFat,
  307. newProtein: newProtein,
  308. newNote: newNote
  309. )
  310. await syncWithServices()
  311. }
  312. }
  313. private func createNewEntries(
  314. originalDate: Date,
  315. newCarbs: Decimal,
  316. newFat: Decimal,
  317. newProtein: Decimal,
  318. newNote: String
  319. ) async {
  320. let newEntry = CarbsEntry(
  321. id: UUID().uuidString,
  322. createdAt: Date(),
  323. actualDate: originalDate,
  324. carbs: newCarbs,
  325. fat: newFat,
  326. protein: newProtein,
  327. note: newNote,
  328. enteredBy: CarbsEntry.local,
  329. isFPU: false,
  330. fpuID: newFat > 0 || newProtein > 0 ? UUID().uuidString : nil
  331. )
  332. // Handles internally whether to create fake carbs or not based on whether fat > 0 or protein > 0
  333. await carbsStorage.storeCarbs([newEntry], areFetchedFromRemote: false)
  334. }
  335. /// Deletes the old carb/ FPU entries and creates new ones with updated values
  336. /// - Parameters:
  337. /// - treatmentObjectID: The ID of the entry to delete
  338. /// - originalDate: The original date to preserve
  339. /// - newCarbs: The new carbs value
  340. /// - newFat: The new fat value
  341. /// - newProtein: The new protein value
  342. /// - newNote: The new note text
  343. private func deleteOldEntries(
  344. _ treatmentObjectID: NSManagedObjectID,
  345. originalEntry: (
  346. entryValues: (date: Date, oldCarbs: Double, oldFat: Double, oldProtein: Double)?,
  347. entryId: NSManagedObjectID
  348. ),
  349. newCarbs _: Decimal,
  350. newFat _: Decimal,
  351. newProtein _: Decimal,
  352. newNote _: String
  353. ) async {
  354. // TODO: cleanup
  355. // TODO: maybe pass originalEntry instead of treatmentObjectId down?
  356. if ((originalEntry.entryValues?.oldCarbs ?? 0) == 0 && (originalEntry.entryValues?.oldFat ?? 0) > 0) ||
  357. ((originalEntry.entryValues?.oldCarbs ?? 0) == 0 && (originalEntry.entryValues?.oldProtein ?? 0) > 0)
  358. {
  359. // Delete the zero-carb-entry and all its carb equivalents connected by the same fpuID from remote services and Core Data
  360. // Use fpuID
  361. await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true)
  362. } else if ((originalEntry.entryValues?.oldCarbs ?? 0) > 0 && (originalEntry.entryValues?.oldFat ?? 0) > 0) ||
  363. ((originalEntry.entryValues?.oldCarbs ?? 0) > 0 && (originalEntry.entryValues?.oldProtein ?? 0) > 0)
  364. {
  365. // Delete carb entry and carb equivalents that are all connected by the same fpuID from remote services and Core Data
  366. // Use fpuID
  367. await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true)
  368. } else {
  369. // Delete just the carb entry since there are no carb equivalents
  370. // Use NSManagedObjectID
  371. await deleteCarbs(treatmentObjectID)
  372. }
  373. }
  374. /// Retrieves the original entry values
  375. /// - Parameter objectID: The ID of the entry
  376. /// - Returns: A tuple of the old entry values and its original date and the objectID or nil
  377. private func getOriginalEntryValues(_ objectID: NSManagedObjectID) async
  378. -> (entryValues: (date: Date, oldCarbs: Double, oldFat: Double, oldProtein: Double)?, entryId: NSManagedObjectID)?
  379. {
  380. let context = CoreDataStack.shared.newTaskContext()
  381. context.name = "updateContext"
  382. context.transactionAuthor = "updateEntry"
  383. // TODO: possibly extend this by id and fpuID to not having to fetch again later on
  384. return await context.perform {
  385. do {
  386. guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored, let entryDate = entry.date
  387. else { return nil }
  388. return (
  389. entryValues: (date: entryDate, oldCarbs: entry.carbs, oldFat: entry.fat, oldProtein: entry.protein),
  390. entryId: entry.objectID
  391. )
  392. } catch let error as NSError {
  393. debugPrint("\(DebuggingIdentifiers.failed) Failed to get original date with error: \(error.userInfo)")
  394. return nil
  395. }
  396. }
  397. }
  398. /// Synchronizes the FPU/ Carb entry with all remote services in parallel
  399. private func syncWithServices() async {
  400. async let nightscoutUpload: () = provider.nightscoutManager.uploadCarbs()
  401. async let healthKitUpload: () = provider.healthkitManager.uploadCarbs()
  402. async let tidepoolUpload: () = provider.tidepoolManager.uploadCarbs()
  403. _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload]
  404. }
  405. // MARK: - Entry Loading
  406. /// Loads the values of a carb or FPU entry from Core Data
  407. /// - Parameter objectID: The ID of the entry to load
  408. /// - Returns: A tuple containing the entry's values, or nil if not found
  409. func loadEntryValues(from objectID: NSManagedObjectID) async
  410. -> (carbs: Decimal, fat: Decimal, protein: Decimal, note: String)?
  411. {
  412. let context = CoreDataStack.shared.persistentContainer.viewContext
  413. return await context.perform {
  414. do {
  415. guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored else { return nil }
  416. return (
  417. carbs: Decimal(entry.carbs),
  418. fat: Decimal(entry.fat),
  419. protein: Decimal(entry.protein),
  420. note: entry.note ?? ""
  421. )
  422. } catch {
  423. debugPrint("\(DebuggingIdentifiers.failed) Failed to load entry: \(error.localizedDescription)")
  424. return nil
  425. }
  426. }
  427. }
  428. // MARK: - FPU Entry Handling
  429. /// Handles the loading of FPU entries based on their type
  430. /// If the user taps on an FPU entry in the DataTable list, there are two cases:
  431. /// - the User has entered this FPU entry WITH carbs
  432. /// - the User has entered this FPU entry WITHOUT carbs
  433. /// In the first case, we simply need to load the corresponding carb entry. For this case THIS is the entry we want to edit.
  434. /// In the second case, we need to load the zero-carb entry that actually holds the FPU values (and the carbs). For this case THIS is the entry we want to edit.
  435. /// - Parameter objectID: The ID of the FPU entry
  436. /// - Returns: A tuple containing the entry values and ID, or nil if not found
  437. func handleFPUEntry(_ objectID: NSManagedObjectID) async
  438. -> (entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String)?, entryID: NSManagedObjectID?)?
  439. {
  440. // Case 1: FPU entry WITH carbs
  441. if let correspondingCarbEntryID = await getCorrespondingCarbEntry(objectID) {
  442. if let values = await loadEntryValues(from: correspondingCarbEntryID) {
  443. return (values, correspondingCarbEntryID)
  444. }
  445. }
  446. // Case 2: FPU entry WITHOUT carbs
  447. else if let originalEntryID = await getZeroCarbNonFPUEntry(objectID) {
  448. if let values = await loadEntryValues(from: originalEntryID) {
  449. return (values, originalEntryID)
  450. }
  451. }
  452. return nil
  453. }
  454. /// Retrieves the original zero-carb non-FPU entry for a given FPU entry.
  455. /// This is used when the user has entered a FPU entry WITHOUT carbs.
  456. /// - Parameter treatmentObjectID: The ID of the FPU entry
  457. /// - Returns: The ID of the original entry, or nil if not found
  458. func getZeroCarbNonFPUEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
  459. let context = CoreDataStack.shared.newTaskContext()
  460. context.name = "fpuContext"
  461. return await context.perform {
  462. do {
  463. // Get the fpuID from the selected entry
  464. guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
  465. let fpuID = selectedEntry.fpuID
  466. else { return nil }
  467. // Fetch the original zero-carb entry (non-FPU) with the same fpuID
  468. let last24Hours = Date().addingTimeInterval(-60 * 60 * 24)
  469. let request = CarbEntryStored.fetchRequest()
  470. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
  471. NSPredicate(format: "date >= %@", last24Hours as NSDate),
  472. NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
  473. NSPredicate(format: "isFPU == NO"),
  474. NSPredicate(format: "carbs == 0")
  475. ])
  476. request.fetchLimit = 1
  477. let originalEntry = try context.fetch(request).first
  478. debugPrint("FPU fetch result: \(originalEntry != nil ? "Entry found" : "No entry found")")
  479. return originalEntry?.objectID
  480. } catch let error as NSError {
  481. debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch original FPU entry: \(error.userInfo)")
  482. return nil
  483. }
  484. }
  485. }
  486. /// Retrieves the corresponding carb entry for a given FPU entry.
  487. /// This is used when the user has entered a carb entry WITH FPUs all at once.
  488. /// - Parameter treatmentObjectID: The ID of the FPU entry
  489. /// - Returns: The ID of the corresponding carb entry, or nil if not found
  490. func getCorrespondingCarbEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
  491. let context = CoreDataStack.shared.newTaskContext()
  492. context.name = "carbContext"
  493. return await context.perform {
  494. do {
  495. // Get the fpuID from the selected entry
  496. guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
  497. let fpuID = selectedEntry.fpuID
  498. else { return nil }
  499. // Fetch the corresponding carb entry with the same fpuID
  500. let last24Hours = Date().addingTimeInterval(-24.hours.timeInterval)
  501. let request = CarbEntryStored.fetchRequest()
  502. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
  503. NSPredicate(format: "date >= %@", last24Hours as NSDate),
  504. NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
  505. NSPredicate(format: "isFPU == NO"),
  506. NSPredicate(format: "(carbs > 0) OR (fat > 0) OR (protein > 0)")
  507. ])
  508. request.fetchLimit = 1
  509. let correspondingCarbEntry = try context.fetch(request).first
  510. debugPrint(
  511. "Corresponding carb entry fetch result: \(correspondingCarbEntry != nil ? "Entry found" : "No entry found")"
  512. )
  513. return correspondingCarbEntry?.objectID
  514. } catch let error as NSError {
  515. debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch corresponding carb entry: \(error.userInfo)")
  516. return nil
  517. }
  518. }
  519. }
  520. }
  521. }
  522. extension DataTable.StateModel: DeterminationObserver, SettingsObserver {
  523. func determinationDidUpdate(_: Determination) {
  524. DispatchQueue.main.async {
  525. self.waitForSuggestion = false
  526. }
  527. }
  528. func settingsDidChange(_: FreeAPSSettings) {
  529. units = settingsManager.settings.units
  530. }
  531. }