DataTableStateModel.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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) {
  87. Task {
  88. await deleteCarbs(treatmentObjectID)
  89. await MainActor.run {
  90. carbEntryDeleted = true
  91. waitForSuggestion = true
  92. }
  93. }
  94. }
  95. func deleteCarbs(_ treatmentObjectID: NSManagedObjectID) async {
  96. // Delete from Apple Health/Tidepool
  97. await deleteCarbsFromServices(treatmentObjectID)
  98. // Delete carbs from Core Data
  99. await carbsStorage.deleteCarbs(treatmentObjectID)
  100. // Perform a determine basal sync to update cob
  101. await apsManager.determineBasalSync()
  102. }
  103. func deleteCarbsFromServices(_ treatmentObjectID: NSManagedObjectID) async {
  104. let taskContext = CoreDataStack.shared.newTaskContext()
  105. taskContext.name = "deleteContext"
  106. taskContext.transactionAuthor = "deleteCarbsFromServices"
  107. var carbEntry: CarbEntryStored?
  108. // Delete carbs or FPUs from Nightscout
  109. await taskContext.perform {
  110. do {
  111. carbEntry = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
  112. guard let carbEntry = carbEntry else {
  113. debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)")
  114. return
  115. }
  116. if carbEntry.isFPU, let fpuID = carbEntry.fpuID {
  117. // Delete Fat and Protein entries from Nightscout
  118. self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString)
  119. // Delete Fat and Protein entries from Apple Health
  120. let healthObjectsToDelete: [HKSampleType?] = [
  121. AppleHealthConfig.healthFatObject,
  122. AppleHealthConfig.healthProteinObject
  123. ]
  124. for sampleType in healthObjectsToDelete {
  125. if let validSampleType = sampleType {
  126. self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType)
  127. }
  128. }
  129. } else {
  130. // Delete carbs from Nightscout
  131. if let id = carbEntry.id, let entryDate = carbEntry.date {
  132. self.provider.deleteCarbsFromNightscout(withID: id.uuidString)
  133. // Delete carbs from Apple Health
  134. if let sampleType = AppleHealthConfig.healthCarbObject {
  135. self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType)
  136. }
  137. self.provider.deleteCarbsFromTidepool(
  138. withSyncId: id,
  139. carbs: Decimal(carbEntry.carbs),
  140. at: entryDate,
  141. enteredBy: CarbsEntry.local
  142. )
  143. }
  144. }
  145. } catch {
  146. debugPrint(
  147. "\(DebuggingIdentifiers.failed) Error deleting carb entry from remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)"
  148. )
  149. }
  150. }
  151. }
  152. // Insulin deletion from history
  153. /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
  154. func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) {
  155. Task {
  156. await invokeInsulinDeletion(treatmentObjectID)
  157. await MainActor.run {
  158. insulinEntryDeleted = true
  159. waitForSuggestion = true
  160. }
  161. }
  162. }
  163. func invokeInsulinDeletion(_ treatmentObjectID: NSManagedObjectID) async {
  164. do {
  165. let authenticated = try await unlockmanager.unlock()
  166. guard authenticated else {
  167. debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Authentication Error")
  168. return
  169. }
  170. // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
  171. await deleteInsulinFromServices(with: treatmentObjectID)
  172. // Delete from Core Data
  173. await CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID)
  174. // Perform a determine basal sync to update iob
  175. await apsManager.determineBasalSync()
  176. } catch {
  177. debugPrint(
  178. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)"
  179. )
  180. }
  181. }
  182. func deleteInsulinFromServices(with treatmentObjectID: NSManagedObjectID) async {
  183. let taskContext = CoreDataStack.shared.newTaskContext()
  184. taskContext.name = "deleteContext"
  185. taskContext.transactionAuthor = "deleteInsulinFromServices"
  186. await taskContext.perform {
  187. do {
  188. guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored
  189. else {
  190. debug(.default, "Could not cast the object to PumpEventStored")
  191. return
  192. }
  193. if let id = treatmentToDelete.id, let timestamp = treatmentToDelete.timestamp,
  194. let bolus = treatmentToDelete.bolus, let bolusAmount = bolus.amount
  195. {
  196. self.provider.deleteInsulinFromNightscout(withID: id)
  197. self.provider.deleteInsulinFromHealth(withSyncID: id)
  198. self.provider.deleteInsulinFromTidepool(withSyncId: id, amount: bolusAmount as Decimal, at: timestamp)
  199. }
  200. taskContext.delete(treatmentToDelete)
  201. try taskContext.save()
  202. debug(.default, "Successfully deleted the treatment object.")
  203. } catch {
  204. debug(.default, "Failed to delete the treatment object: \(error.localizedDescription)")
  205. }
  206. }
  207. }
  208. // MARK: - Entry Management
  209. /// Updates a carb/FPU entry with new values and handles the necessary cleanup and recreation of FPU entries
  210. /// - Parameters:
  211. /// - treatmentObjectID: The ID of the entry to update
  212. /// - newCarbs: The new carbs value
  213. /// - newFat: The new fat value
  214. /// - newProtein: The new protein value
  215. /// - newNote: The new note text
  216. func updateEntry(
  217. _ treatmentObjectID: NSManagedObjectID,
  218. newCarbs: Decimal,
  219. newFat: Decimal,
  220. newProtein: Decimal,
  221. newNote: String
  222. ) {
  223. Task {
  224. let originalDate = await getOriginalEntryDate(treatmentObjectID)
  225. await updateEntryInCoreData(treatmentObjectID, newCarbs: newCarbs, newNote: newNote)
  226. await deleteOldAndCreateNewFPUEntry(
  227. treatmentObjectID: treatmentObjectID,
  228. originalDate: originalDate,
  229. newCarbs: newCarbs,
  230. newFat: newFat,
  231. newProtein: newProtein,
  232. newNote: newNote
  233. )
  234. await syncWithServices()
  235. }
  236. }
  237. /// Retrieves the original date of an entry and sets the isFPU flag
  238. /// - Parameter objectID: The ID of the entry
  239. /// - Returns: The original date or current date if not found
  240. private func getOriginalEntryDate(_ objectID: NSManagedObjectID) async -> Date {
  241. let context = CoreDataStack.shared.newTaskContext()
  242. context.name = "updateContext"
  243. context.transactionAuthor = "updateEntry"
  244. return await context.perform {
  245. do {
  246. guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored
  247. else { return Date() }
  248. /// Hacky workaround: Set isFPU flag to true before deletion
  249. /// This is necessary because the deleteCarbs function in the CarbsStorage will fail if the isFPU flag is false and the entry won't get deleted.
  250. entry.isFPU = true
  251. try context.save()
  252. return entry.date ?? Date()
  253. } catch {
  254. return Date()
  255. }
  256. }
  257. }
  258. /// Updates a carb entry in Core Data
  259. /// The FPU entries are deleted and recreated. We don't need to do this for the carb entries as we can simply update the carb entry in Core Data.
  260. /// - Parameters:
  261. /// - objectID: The ID of the entry to update
  262. /// - newCarbs: The new carbs value
  263. /// - newNote: The new note text
  264. private func updateEntryInCoreData(
  265. _ objectID: NSManagedObjectID,
  266. newCarbs: Decimal,
  267. newNote: String
  268. ) async {
  269. let context = CoreDataStack.shared.newTaskContext()
  270. await context.perform {
  271. do {
  272. let entry = try context.existingObject(with: objectID) as? CarbEntryStored
  273. entry?.carbs = Double(newCarbs)
  274. entry?.note = newNote
  275. try context.save()
  276. } catch {
  277. debugPrint("\(DebuggingIdentifiers.failed) Failed to update entry: \(error.localizedDescription)")
  278. }
  279. }
  280. }
  281. /// Deletes the old FPU entry and creates a new one with updated values
  282. /// - Parameters:
  283. /// - treatmentObjectID: The ID of the entry to delete
  284. /// - originalDate: The original date to preserve
  285. /// - newCarbs: The new carbs value
  286. /// - newFat: The new fat value
  287. /// - newProtein: The new protein value
  288. /// - newNote: The new note text
  289. private func deleteOldAndCreateNewFPUEntry(
  290. treatmentObjectID: NSManagedObjectID,
  291. originalDate: Date,
  292. newCarbs: Decimal,
  293. newFat: Decimal,
  294. newProtein: Decimal,
  295. newNote: String
  296. ) async {
  297. // Delete old FPU entry from Core Data and Remote Services and await this
  298. await deleteCarbs(treatmentObjectID)
  299. // Create new FPU entry
  300. let newEntry = CarbsEntry(
  301. id: UUID().uuidString,
  302. createdAt: Date(),
  303. actualDate: originalDate,
  304. carbs: newCarbs,
  305. fat: newFat,
  306. protein: newProtein,
  307. note: newNote,
  308. enteredBy: CarbsEntry.local,
  309. isFPU: true,
  310. fpuID: UUID().uuidString
  311. )
  312. await carbsStorage.storeCarbs([newEntry], areFetchedFromRemote: false)
  313. }
  314. /// Synchronizes the FPU/ Carb entry with all remote services in parallel
  315. private func syncWithServices() async {
  316. async let nightscoutUpload: () = provider.nightscoutManager.uploadCarbs()
  317. async let healthKitUpload: () = provider.healthkitManager.uploadCarbs()
  318. async let tidepoolUpload: () = provider.tidepoolManager.uploadCarbs()
  319. _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload]
  320. }
  321. // MARK: - Entry Loading
  322. /// Loads the values of a carb or FPU entry from Core Data
  323. /// - Parameter objectID: The ID of the entry to load
  324. /// - Returns: A tuple containing the entry's values, or nil if not found
  325. func loadEntryValues(from objectID: NSManagedObjectID) async
  326. -> (carbs: Decimal, fat: Decimal, protein: Decimal, note: String)?
  327. {
  328. let context = CoreDataStack.shared.persistentContainer.viewContext
  329. return await context.perform {
  330. do {
  331. guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored else { return nil }
  332. return (
  333. carbs: Decimal(entry.carbs),
  334. fat: Decimal(entry.fat),
  335. protein: Decimal(entry.protein),
  336. note: entry.note ?? ""
  337. )
  338. } catch {
  339. debugPrint("\(DebuggingIdentifiers.failed) Failed to load entry: \(error.localizedDescription)")
  340. return nil
  341. }
  342. }
  343. }
  344. // MARK: - FPU Entry Handling
  345. /// Handles the loading of FPU entries based on their type
  346. /// If the user taps on an FPU entry in the DataTable list, there are two cases:
  347. /// - the User has entered this FPU entry WITH carbs
  348. /// - the User has entered this FPU entry WITHOUT carbs
  349. /// In the first case, we simply need to load the corresponding carb entry. For this case THIS is the entry we want to edit.
  350. /// 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.
  351. /// - Parameter objectID: The ID of the FPU entry
  352. /// - Returns: A tuple containing the entry values and ID, or nil if not found
  353. func handleFPUEntry(_ objectID: NSManagedObjectID) async
  354. -> (entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String)?, entryID: NSManagedObjectID?)?
  355. {
  356. // Case 1: FPU entry WITH carbs
  357. if let correspondingCarbEntryID = await getCorrespondingCarbEntry(objectID) {
  358. if let values = await loadEntryValues(from: correspondingCarbEntryID) {
  359. return (values, correspondingCarbEntryID)
  360. }
  361. }
  362. // Case 2: FPU entry WITHOUT carbs
  363. else if let originalEntryID = await getZeroCarbNonFPUEntry(objectID) {
  364. if let values = await loadEntryValues(from: originalEntryID) {
  365. return (values, originalEntryID)
  366. }
  367. }
  368. return nil
  369. }
  370. /// Retrieves the original zero-carb non-FPU entry for a given FPU entry.
  371. /// This is used when the user has entered a FPU entry WITHOUT carbs.
  372. /// - Parameter treatmentObjectID: The ID of the FPU entry
  373. /// - Returns: The ID of the original entry, or nil if not found
  374. func getZeroCarbNonFPUEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
  375. let context = CoreDataStack.shared.newTaskContext()
  376. context.name = "fpuContext"
  377. return await context.perform {
  378. do {
  379. // Get the fpuID from the selected entry
  380. guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
  381. let fpuID = selectedEntry.fpuID
  382. else { return nil }
  383. // Fetch the original zero-carb entry (non-FPU) with the same fpuID
  384. let last24Hours = Date().addingTimeInterval(-60 * 60 * 24)
  385. let request = CarbEntryStored.fetchRequest()
  386. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
  387. NSPredicate(format: "date >= %@", last24Hours as NSDate),
  388. NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
  389. NSPredicate(format: "isFPU == NO"),
  390. NSPredicate(format: "carbs == 0")
  391. ])
  392. request.fetchLimit = 1
  393. let originalEntry = try context.fetch(request).first
  394. debugPrint("FPU fetch result: \(originalEntry != nil ? "Entry found" : "No entry found")")
  395. return originalEntry?.objectID
  396. } catch let error as NSError {
  397. debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch original FPU entry: \(error.userInfo)")
  398. return nil
  399. }
  400. }
  401. }
  402. /// Retrieves the corresponding carb entry for a given FPU entry.
  403. /// This is used when the user has entered a carb entry WITH FPUs all at once.
  404. /// - Parameter treatmentObjectID: The ID of the FPU entry
  405. /// - Returns: The ID of the corresponding carb entry, or nil if not found
  406. func getCorrespondingCarbEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? {
  407. let context = CoreDataStack.shared.newTaskContext()
  408. context.name = "carbContext"
  409. return await context.perform {
  410. do {
  411. // Get the fpuID from the selected entry
  412. guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored,
  413. let fpuID = selectedEntry.fpuID
  414. else { return nil }
  415. // Fetch the corresponding carb entry with the same fpuID
  416. let last24Hours = Date().addingTimeInterval(-24.hours.timeInterval)
  417. let request = CarbEntryStored.fetchRequest()
  418. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
  419. NSPredicate(format: "date >= %@", last24Hours as NSDate),
  420. NSPredicate(format: "fpuID == %@", fpuID as CVarArg),
  421. NSPredicate(format: "isFPU == NO"),
  422. NSPredicate(format: "(carbs > 0) OR (fat > 0) OR (protein > 0)")
  423. ])
  424. request.fetchLimit = 1
  425. let correspondingCarbEntry = try context.fetch(request).first
  426. debugPrint(
  427. "Corresponding carb entry fetch result: \(correspondingCarbEntry != nil ? "Entry found" : "No entry found")"
  428. )
  429. return correspondingCarbEntry?.objectID
  430. } catch let error as NSError {
  431. debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch corresponding carb entry: \(error.userInfo)")
  432. return nil
  433. }
  434. }
  435. }
  436. }
  437. }
  438. extension DataTable.StateModel: DeterminationObserver, SettingsObserver {
  439. func determinationDidUpdate(_: Determination) {
  440. DispatchQueue.main.async {
  441. self.waitForSuggestion = false
  442. }
  443. }
  444. func settingsDidChange(_: FreeAPSSettings) {
  445. units = settingsManager.settings.units
  446. }
  447. }