import CoreData import HealthKit import Observation import SwiftUI extension DataTable { @Observable final class StateModel: BaseStateModel { @ObservationIgnored @Injected() var broadcaster: Broadcaster! @ObservationIgnored @Injected() var apsManager: APSManager! @ObservationIgnored @Injected() var unlockmanager: UnlockManager! @ObservationIgnored @Injected() private var storage: FileStorage! @ObservationIgnored @Injected() var pumpHistoryStorage: PumpHistoryStorage! @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage! @ObservationIgnored @Injected() var healthKitManager: HealthKitManager! @ObservationIgnored @Injected() var carbsStorage: CarbsStorage! let coredataContext = CoreDataStack.shared.newTaskContext() var mode: Mode = .treatments var treatments: [Treatment] = [] var manualGlucose: Decimal = 0 var waitForSuggestion: Bool = false var insulinEntryDeleted: Bool = false var carbEntryDeleted: Bool = false var units: GlucoseUnits = .mgdL var carbEntryToEdit: CarbEntryStored? var showCarbEntryEditor = false override func subscribe() { units = settingsManager.settings.units broadcaster.register(DeterminationObserver.self, observer: self) broadcaster.register(SettingsObserver.self, observer: self) } /// Checks if the glucose data is fresh based on the given date /// - Parameter glucoseDate: The date to check /// - Returns: Boolean indicating if the data is fresh func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool { glucoseStorage.isGlucoseDataFresh(glucoseDate) } /// Initiates the glucose deletion process asynchronously /// - Parameter treatmentObjectID: NSManagedObjectID to be able to transfer the object safely from one thread to another thread func invokeGlucoseDeletionTask(_ treatmentObjectID: NSManagedObjectID) { Task { await deleteGlucose(treatmentObjectID) } } func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async { // Delete from Apple Health/Tidepool await deleteGlucoseFromServices(treatmentObjectID) // Delete from Core Data await glucoseStorage.deleteGlucose(treatmentObjectID) } func deleteGlucoseFromServices(_ treatmentObjectID: NSManagedObjectID) async { let taskContext = CoreDataStack.shared.newTaskContext() taskContext.name = "deleteContext" taskContext.transactionAuthor = "deleteGlucoseFromServices" await taskContext.perform { do { let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored guard let glucoseToDelete = result else { debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data") return } // Delete from Nightscout if let id = glucoseToDelete.id?.uuidString { self.provider.deleteManualGlucoseFromNightscout(withID: id) } // Delete from Apple Health if let id = glucoseToDelete.id?.uuidString { self.provider.deleteGlucoseFromHealth(withSyncID: id) } debugPrint( "\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from remote service(s) (Nightscout, Apple Health, Tidepool)" ) } catch { debugPrint( "\(#file) \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose remote service(s) (Nightscout, Apple Health, Tidepool) with error: \(error.localizedDescription)" ) } } } func addManualGlucose() { // Always save value in mg/dL let glucose = units == .mmolL ? manualGlucose.asMgdL : manualGlucose let glucoseAsInt = Int(glucose) glucoseStorage.addManualGlucose(glucose: glucoseAsInt) } // Carb and FPU deletion from history /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) { Task { await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal) await MainActor.run { carbEntryDeleted = true waitForSuggestion = true } } } func deleteCarbs(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) async { // Delete from Nightscout/Apple Health/Tidepool await deleteFromServices(treatmentObjectID, isFPUDeletion: isFpuOrComplexMeal) // Delete carbs from Core Data await carbsStorage.deleteCarbsEntryStored(treatmentObjectID) // Perform a determine basal sync to update cob await apsManager.determineBasalSync() } /// Deletes carb and FPU entries from all connected services (Nightscout, HealthKit, Tidepool) /// - Parameters: /// - treatmentObjectID: The Core Data object ID of the entry to delete /// - isFPUDeletion: Flag indicating if this is a FPU deletion that requires special handling /// - If true: Will first fetch the corresponding carb entry and then delete both FPU and carb entries /// - If false: Will delete the entry directly as a standard carb deletion /// - Note: This function handles three scenarios: /// 1. Standard carb deletion (isFPUDeletion = false) /// 2. FPU-only deletion (isFPUDeletion = true) /// 3. Combined carb+FPU deletion (isFPUDeletion = true) func deleteFromServices(_ treatmentObjectID: NSManagedObjectID, isFPUDeletion: Bool = false) async { let taskContext = CoreDataStack.shared.newTaskContext() taskContext.name = "deleteContext" taskContext.transactionAuthor = "deleteCarbsFromServices" var carbEntry: CarbEntryStored? var objectIDToDelete = treatmentObjectID // For FPU deletions, first get the corresponding carb entry if isFPUDeletion { guard let correspondingEntry: ( entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?, entryID: NSManagedObjectID? ) = await handleFPUEntry(treatmentObjectID), let nsManagedObjectID = correspondingEntry.entryID else { return } objectIDToDelete = nsManagedObjectID } // Delete entries from all services await taskContext.perform { do { carbEntry = try taskContext.existingObject(with: objectIDToDelete) as? CarbEntryStored guard let carbEntry = carbEntry else { debugPrint("Carb entry for deletion not found. \(DebuggingIdentifiers.failed)") return } // Delete FPU related entries if they exist if let fpuID = carbEntry.fpuID { // Delete Fat and Protein entries from Nightscout self.provider.deleteCarbsFromNightscout(withID: fpuID.uuidString) // Delete Fat and Protein entries from Apple Health let healthObjectsToDelete: [HKSampleType?] = [ AppleHealthConfig.healthFatObject, AppleHealthConfig.healthProteinObject ] for sampleType in healthObjectsToDelete { if let validSampleType = sampleType { self.provider.deleteMealDataFromHealth(byID: fpuID.uuidString, sampleType: validSampleType) } } } // Delete carb entries if they exist if let id = carbEntry.id, let entryDate = carbEntry.date { self.provider.deleteCarbsFromNightscout(withID: id.uuidString) // Delete carbs from Apple Health if let sampleType = AppleHealthConfig.healthCarbObject { self.provider.deleteMealDataFromHealth(byID: id.uuidString, sampleType: sampleType) } self.provider.deleteCarbsFromTidepool( withSyncId: id, carbs: Decimal(carbEntry.carbs), at: entryDate, enteredBy: CarbsEntry.local ) } } catch { debugPrint("\(DebuggingIdentifiers.failed) Error deleting entries: \(error.localizedDescription)") } } } // Insulin deletion from history /// - **Parameter**: NSManagedObjectID to be able to transfer the object safely from one thread to another thread func invokeInsulinDeletionTask(_ treatmentObjectID: NSManagedObjectID) { Task { await invokeInsulinDeletion(treatmentObjectID) await MainActor.run { insulinEntryDeleted = true waitForSuggestion = true } } } func invokeInsulinDeletion(_ treatmentObjectID: NSManagedObjectID) async { do { let authenticated = try await unlockmanager.unlock() guard authenticated else { debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Authentication Error") return } // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool) await deleteInsulinFromServices(with: treatmentObjectID) // Delete from Core Data await CoreDataStack.shared.deleteObject(identifiedBy: treatmentObjectID) // Perform a determine basal sync to update iob await apsManager.determineBasalSync() } catch { debugPrint( "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)" ) } } func deleteInsulinFromServices(with treatmentObjectID: NSManagedObjectID) async { let taskContext = CoreDataStack.shared.newTaskContext() taskContext.name = "deleteContext" taskContext.transactionAuthor = "deleteInsulinFromServices" await taskContext.perform { do { guard let treatmentToDelete = try taskContext.existingObject(with: treatmentObjectID) as? PumpEventStored else { debug(.default, "Could not cast the object to PumpEventStored") return } if let id = treatmentToDelete.id, let timestamp = treatmentToDelete.timestamp, let bolus = treatmentToDelete.bolus, let bolusAmount = bolus.amount { self.provider.deleteInsulinFromNightscout(withID: id) self.provider.deleteInsulinFromHealth(withSyncID: id) self.provider.deleteInsulinFromTidepool(withSyncId: id, amount: bolusAmount as Decimal, at: timestamp) } taskContext.delete(treatmentToDelete) try taskContext.save() debug(.default, "Successfully deleted the treatment object.") } catch { debug(.default, "Failed to delete the treatment object: \(error.localizedDescription)") } } } // MARK: - Entry Management /// Updates a carb/FPU entry with new values and handles the necessary cleanup and recreation of FPU entries /// - Parameters: /// - treatmentObjectID: The ID of the entry to update /// - newCarbs: The new carbs value /// - newFat: The new fat value /// - newProtein: The new protein value /// - newNote: The new note text /// - newDate: The new date for the entry func updateEntry( _ treatmentObjectID: NSManagedObjectID, newCarbs: Decimal, newFat: Decimal, newProtein: Decimal, newNote: String, newDate: Date ) { Task { // Get original date from entry to re-create the entry later with the updated values and the same date guard let originalEntry = await getOriginalEntryValues(treatmentObjectID) else { return } // Deletion logic for carb and FPU entries await deleteOldEntries( treatmentObjectID, originalEntry: originalEntry, newCarbs: newCarbs, newFat: newFat, newProtein: newProtein, newNote: newNote ) await createNewEntries( originalDate: newDate, newCarbs: newCarbs, newFat: newFat, newProtein: newProtein, newNote: newNote ) await syncWithServices() } } private func createNewEntries( originalDate: Date, newCarbs: Decimal, newFat: Decimal, newProtein: Decimal, newNote: String ) async { let newEntry = CarbsEntry( id: UUID().uuidString, createdAt: Date(), actualDate: originalDate, carbs: newCarbs, fat: newFat, protein: newProtein, note: newNote, enteredBy: CarbsEntry.local, isFPU: false, fpuID: newFat > 0 || newProtein > 0 ? UUID().uuidString : nil ) // Handles internally whether to create fake carbs or not based on whether fat > 0 or protein > 0 await carbsStorage.storeCarbs([newEntry], areFetchedFromRemote: false) } /// Deletes the old carb/ FPU entries and creates new ones with updated values /// - Parameters: /// - treatmentObjectID: The ID of the entry to delete /// - originalDate: The original date to preserve /// - newCarbs: The new carbs value /// - newFat: The new fat value /// - newProtein: The new protein value /// - newNote: The new note text private func deleteOldEntries( _ treatmentObjectID: NSManagedObjectID, originalEntry: ( entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?, entryId: NSManagedObjectID ), newCarbs _: Decimal, newFat _: Decimal, newProtein _: Decimal, newNote _: String ) async { if ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.fat ?? 0) > 0) || ((originalEntry.entryValues?.carbs ?? 0) == 0 && (originalEntry.entryValues?.protein ?? 0) > 0) { // Delete the zero-carb-entry and all its carb equivalents connected by the same fpuID from remote services and Core Data // Use fpuID await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true) } else if ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.fat ?? 0) > 0) || ((originalEntry.entryValues?.carbs ?? 0) > 0 && (originalEntry.entryValues?.protein ?? 0) > 0) { // Delete carb entry and carb equivalents that are all connected by the same fpuID from remote services and Core Data // Use fpuID await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: true) } else { // Delete just the carb entry since there are no carb equivalents // Use NSManagedObjectID await deleteCarbs(treatmentObjectID) } } /// Retrieves the original entry values /// - Parameter objectID: The ID of the entry /// - Returns: A tuple of the old entry values and its original date and the objectID or nil private func getOriginalEntryValues(_ objectID: NSManagedObjectID) async -> (entryValues: (date: Date, carbs: Double, fat: Double, protein: Double)?, entryId: NSManagedObjectID)? { let context = CoreDataStack.shared.newTaskContext() context.name = "updateContext" context.transactionAuthor = "updateEntry" return await context.perform { do { guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored, let entryDate = entry.date else { return nil } return ( entryValues: (date: entryDate, carbs: entry.carbs, fat: entry.fat, protein: entry.protein), entryId: entry.objectID ) } catch let error as NSError { debugPrint("\(DebuggingIdentifiers.failed) Failed to get original date with error: \(error.userInfo)") return nil } } } /// Synchronizes the FPU/ Carb entry with all remote services in parallel private func syncWithServices() async { async let nightscoutUpload: () = provider.nightscoutManager.uploadCarbs() async let healthKitUpload: () = provider.healthkitManager.uploadCarbs() async let tidepoolUpload: () = provider.tidepoolManager.uploadCarbs() _ = await [nightscoutUpload, healthKitUpload, tidepoolUpload] } // MARK: - Entry Loading /// Loads the values of a carb or FPU entry from Core Data /// - Parameter objectID: The ID of the entry to load /// - Returns: A tuple containing the entry's values, or nil if not found func loadEntryValues(from objectID: NSManagedObjectID) async -> (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)? { let context = CoreDataStack.shared.persistentContainer.viewContext return await context.perform { do { guard let entry = try context.existingObject(with: objectID) as? CarbEntryStored, let entryDate = entry.date else { return nil } return ( carbs: Decimal(entry.carbs), fat: Decimal(entry.fat), protein: Decimal(entry.protein), note: entry.note ?? "", date: entryDate ) } catch { debugPrint("\(DebuggingIdentifiers.failed) Failed to load entry: \(error.localizedDescription)") return nil } } } // MARK: - FPU Entry Handling /// Handles the loading of FPU entries based on their type /// If the user taps on an FPU entry in the DataTable list, there are two cases: /// - the User has entered this FPU entry WITH carbs /// - the User has entered this FPU entry WITHOUT carbs /// In the first case, we simply need to load the corresponding carb entry. For this case THIS is the entry we want to edit. /// 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. /// - Parameter objectID: The ID of the FPU entry /// - Returns: A tuple containing the entry values and ID, or nil if not found func handleFPUEntry(_ objectID: NSManagedObjectID) async -> ( entryValues: (carbs: Decimal, fat: Decimal, protein: Decimal, note: String, date: Date)?, entryID: NSManagedObjectID? )? { // Case 1: FPU entry WITH carbs if let correspondingCarbEntryID = await getCorrespondingCarbEntry(objectID) { if let values = await loadEntryValues(from: correspondingCarbEntryID) { return (values, correspondingCarbEntryID) } } // Case 2: FPU entry WITHOUT carbs else if let originalEntryID = await getZeroCarbNonFPUEntry(objectID) { if let values = await loadEntryValues(from: originalEntryID) { return (values, originalEntryID) } } return nil } /// Retrieves the original zero-carb non-FPU entry for a given FPU entry. /// This is used when the user has entered a FPU entry WITHOUT carbs. /// - Parameter treatmentObjectID: The ID of the FPU entry /// - Returns: The ID of the original entry, or nil if not found func getZeroCarbNonFPUEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? { let context = CoreDataStack.shared.newTaskContext() context.name = "fpuContext" return await context.perform { do { // Get the fpuID from the selected entry guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored, let fpuID = selectedEntry.fpuID else { return nil } // Fetch the original zero-carb entry (non-FPU) with the same fpuID let last24Hours = Date().addingTimeInterval(-60 * 60 * 24) let request = CarbEntryStored.fetchRequest() request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "date >= %@", last24Hours as NSDate), NSPredicate(format: "fpuID == %@", fpuID as CVarArg), NSPredicate(format: "isFPU == NO"), NSPredicate(format: "carbs == 0") ]) request.fetchLimit = 1 let originalEntry = try context.fetch(request).first debugPrint("FPU fetch result: \(originalEntry != nil ? "Entry found" : "No entry found")") return originalEntry?.objectID } catch let error as NSError { debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch original FPU entry: \(error.userInfo)") return nil } } } /// Retrieves the corresponding carb entry for a given FPU entry. /// This is used when the user has entered a carb entry WITH FPUs all at once. /// - Parameter treatmentObjectID: The ID of the FPU entry /// - Returns: The ID of the corresponding carb entry, or nil if not found func getCorrespondingCarbEntry(_ treatmentObjectID: NSManagedObjectID) async -> NSManagedObjectID? { let context = CoreDataStack.shared.newTaskContext() context.name = "carbContext" return await context.perform { do { // Get the fpuID from the selected entry guard let selectedEntry = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored, let fpuID = selectedEntry.fpuID else { return nil } // Fetch the corresponding carb entry with the same fpuID let last24Hours = Date().addingTimeInterval(-24.hours.timeInterval) let request = CarbEntryStored.fetchRequest() request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "date >= %@", last24Hours as NSDate), NSPredicate(format: "fpuID == %@", fpuID as CVarArg), NSPredicate(format: "isFPU == NO"), NSPredicate(format: "(carbs > 0) OR (fat > 0) OR (protein > 0)") ]) request.fetchLimit = 1 let correspondingCarbEntry = try context.fetch(request).first debugPrint( "Corresponding carb entry fetch result: \(correspondingCarbEntry != nil ? "Entry found" : "No entry found")" ) return correspondingCarbEntry?.objectID } catch let error as NSError { debugPrint("\(DebuggingIdentifiers.failed) Failed to fetch corresponding carb entry: \(error.userInfo)") return nil } } } } } extension DataTable.StateModel: DeterminationObserver, SettingsObserver { func determinationDidUpdate(_: Determination) { DispatchQueue.main.async { self.waitForSuggestion = false } } func settingsDidChange(_: FreeAPSSettings) { units = settingsManager.settings.units } }