diff --git a/Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents b/Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents index 5ac720436..e630df1b4 100644 --- a/Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents +++ b/Core_Data.xcdatamodeld/Core_Data.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -65,6 +65,7 @@ + diff --git a/FreeAPS.xcodeproj/project.pbxproj b/FreeAPS.xcodeproj/project.pbxproj index 45becc4cd..e11475cef 100644 --- a/FreeAPS.xcodeproj/project.pbxproj +++ b/FreeAPS.xcodeproj/project.pbxproj @@ -287,6 +287,11 @@ CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; }; CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; }; CD78BB94E43B249D60CC1A1B /* NotificationsConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */; }; + CE0295982BE65817003D5E97 /* OverrideStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0295972BE65817003D5E97 /* OverrideStorage.swift */; }; + CE02959B2BE65A40003D5E97 /* OverrideProfil.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE02959A2BE65A40003D5E97 /* OverrideProfil.swift */; }; + CE02959F2BE7A003003D5E97 /* TestCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE02959E2BE7A003003D5E97 /* TestCoreData.swift */; }; + CE0295A12BE7A4F9003D5E97 /* OverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0295A02BE7A4F9003D5E97 /* OverrideTests.swift */; }; + CE0BF4B52BEA6CAB004C00DD /* NightscoutExercice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0BF4B42BEA6CAB004C00DD /* NightscoutExercice.swift */; }; CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */; }; CE1F6DDB2BAE08B60064EB8D /* TidepoolManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */; }; CE1F6DE72BAF1A180064EB8D /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */; }; @@ -812,6 +817,11 @@ C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = ""; }; C8D1A7CA8C10C4403D4BBFA7 /* BolusDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusDataFlow.swift; sourceTree = ""; }; CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = ""; }; + CE0295972BE65817003D5E97 /* OverrideStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorage.swift; sourceTree = ""; }; + CE02959A2BE65A40003D5E97 /* OverrideProfil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideProfil.swift; sourceTree = ""; }; + CE02959E2BE7A003003D5E97 /* TestCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCoreData.swift; sourceTree = ""; }; + CE0295A02BE7A4F9003D5E97 /* OverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideTests.swift; sourceTree = ""; }; + CE0BF4B42BEA6CAB004C00DD /* NightscoutExercice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutExercice.swift; sourceTree = ""; }; CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManagerTests.swift; sourceTree = ""; }; CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolManager.swift; sourceTree = ""; }; CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; @@ -1602,6 +1612,7 @@ 19012CDB291D2CB900FB8210 /* LoopStats.swift */, FE41E4D329463C660047FD55 /* NightscoutStatistics.swift */, FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */, + CE0BF4B42BEA6CAB004C00DD /* NightscoutExercice.swift */, 191F62672AD6B05A004D7911 /* NightscoutSettings.swift */, 1967DFBD29D052C200759F30 /* Icons.swift */, 19D4E4EA29FC6A9F00351451 /* TIRforChart.swift */, @@ -1609,6 +1620,7 @@ 193F6CDC2A512C8F001240FD /* Loops.swift */, CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */, BDF530D72B40F8AC002CAF43 /* LockScreenView.swift */, + CE02959A2BE65A40003D5E97 /* OverrideProfil.swift */, ); path = Models; sourceTree = ""; @@ -1656,6 +1668,7 @@ 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */, CE82E02428E867BA00473A9C /* AlertStorage.swift */, 1956FB202AFF79E200C7B4FF /* CoreDataStorage.swift */, + CE0295972BE65817003D5E97 /* OverrideStorage.swift */, ); path = Storage; sourceTree = ""; @@ -1830,6 +1843,8 @@ 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */, CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */, CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */, + CE02959E2BE7A003003D5E97 /* TestCoreData.swift */, + CE0295A02BE7A4F9003D5E97 /* OverrideTests.swift */, ); path = FreeAPSTests; sourceTree = ""; @@ -2708,6 +2723,7 @@ 3883581C25EE79BB00E024B2 /* DecimalTextField.swift in Sources */, 6B1A8D2E2B156EEF00E76752 /* LiveActivityBridge.swift in Sources */, 38DAB28A260D349500F74C1A /* FetchGlucoseManager.swift in Sources */, + CE02959B2BE65A40003D5E97 /* OverrideProfil.swift in Sources */, 38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */, 3811DE3F25C9D4A100A708ED /* SettingsStateModel.swift in Sources */, CE7CA3582A064E2F004BE681 /* ListStateView.swift in Sources */, @@ -2747,6 +2763,7 @@ E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */, CE7CA3522A064973004BE681 /* ListTempPresetsIntent.swift in Sources */, 448B6FCB252BD4796E2960C0 /* PumpSettingsEditorDataFlow.swift in Sources */, + CE0BF4B52BEA6CAB004C00DD /* NightscoutExercice.swift in Sources */, 38E44536274E411700EC9A94 /* Disk.swift in Sources */, 2BE9A6FA20875F6F4F9CD461 /* PumpSettingsEditorProvider.swift in Sources */, 6B9625766B697D1C98E455A2 /* PumpSettingsEditorStateModel.swift in Sources */, @@ -2767,6 +2784,7 @@ FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */, 63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */, 38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */, + CE0295982BE65817003D5E97 /* OverrideStorage.swift in Sources */, CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */, 385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */, F90692AA274B7AAE0037068D /* HealthKitManager.swift in Sources */, @@ -2900,7 +2918,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CE02959F2BE7A003003D5E97 /* TestCoreData.swift in Sources */, CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */, + CE0295A12BE7A4F9003D5E97 /* OverrideTests.swift in Sources */, CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */, 38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */, ); diff --git a/FreeAPS/Sources/APS/APSManager.swift b/FreeAPS/Sources/APS/APSManager.swift index ac64f24ba..b813404ef 100644 --- a/FreeAPS/Sources/APS/APSManager.swift +++ b/FreeAPS/Sources/APS/APSManager.swift @@ -73,6 +73,7 @@ final class BaseAPSManager: APSManager, Injectable { @Injected() private var settingsManager: SettingsManager! @Injected() private var broadcaster: Broadcaster! @Injected() private var healthKitManager: HealthKitManager! + @Injected() private var overrideStorage: OverrideStorage! @Persisted(key: "lastAutotuneDate") private var lastAutotuneDate = Date() @Persisted(key: "lastStartLoopDate") private var lastStartLoopDate: Date = .distantPast @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast { @@ -359,10 +360,12 @@ final class BaseAPSManager: APSManager, Injectable { let now = Date() let temp = currentTemp(date: now) + let eventuelOverride: OverrideProfil? = overrideStorage.current() + let mainPublisher = makeProfiles() .flatMap { _ in self.autosens() } .flatMap { _ in self.dailyAutotune() } - .flatMap { _ in self.openAPS.determineBasal(currentTemp: temp, clock: now) } + .flatMap { _ in self.openAPS.determineBasal(currentTemp: temp, clock: now, override: eventuelOverride) } .map { suggestion -> Bool in if let suggestion = suggestion { DispatchQueue.main.async { diff --git a/FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift b/FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift index 5aefb7929..faeb6d0ba 100644 --- a/FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift +++ b/FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift @@ -15,7 +15,11 @@ final class OpenAPS { self.storage = storage } - func determineBasal(currentTemp: TempBasal, clock: Date = Date()) -> Future { + func determineBasal( + currentTemp: TempBasal, + clock: Date = Date(), + override: OverrideProfil? = nil + ) -> Future { Future { promise in self.processQueue.async { debug(.openAPS, "Start determineBasal") @@ -61,7 +65,7 @@ final class OpenAPS { let preferences = self.loadFileFromStorage(name: Settings.preferences) // oref2 - let oref2_variables = self.oref2() + let oref2_variables = self.oref2(override) let suggested = self.determineBasal( glucose: glucose, @@ -118,13 +122,13 @@ final class OpenAPS { } } - func oref2() -> RawJSON { + func oref2(_ override: OverrideProfil? = nil) -> RawJSON { coredataContext.performAndWait { let preferences = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self) var hbt_ = preferences?.halfBasalExerciseTarget ?? 160 let wp = preferences?.weightPercentage ?? 1 - let smbMinutes = (preferences?.maxSMBBasalMinutes ?? 30) as NSDecimalNumber - let uamMinutes = (preferences?.maxUAMSMBBasalMinutes ?? 30) as NSDecimalNumber + let smbMinutes = preferences?.maxSMBBasalMinutes ?? 30 + let uamMinutes = preferences?.maxUAMSMBBasalMinutes ?? 30 let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval) let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval) @@ -143,13 +147,6 @@ final class OpenAPS { // requestIsEnbled.fetchLimit = 1 try? sliderArray = coredataContext.fetch(requestIsEnbled) - var overrideArray = [Override]() - let requestOverrides = Override.fetchRequest() as NSFetchRequest - let sortOverride = NSSortDescriptor(key: "date", ascending: false) - requestOverrides.sortDescriptors = [sortOverride] - // requestOverrides.fetchLimit = 1 - try? overrideArray = coredataContext.fetch(requestOverrides) - var tempTargetsArray = [TempTargets]() let requestTempTargets = TempTargets.fetchRequest() as NSFetchRequest let sortTT = NSSortDescriptor(key: "date", ascending: false) @@ -167,11 +164,8 @@ final class OpenAPS { var temptargetActive = tempTargetsArray.first?.active ?? false let isPercentageEnabled = sliderArray.first?.enabled ?? false - var useOverride = overrideArray.first?.enabled ?? false - var overridePercentage = Decimal(overrideArray.first?.percentage ?? 100) - var unlimited = overrideArray.first?.indefinite ?? true - var disableSMBs = overrideArray.first?.smbIsOff ?? false - + let unlimited = override?.indefinite ?? true + let disableSMBs = override?.smbIsOff ?? false let currentTDD = (uniqueEvents.last?.tdd ?? 0) as Decimal if indeces == 0 { @@ -187,36 +181,22 @@ final class OpenAPS { let weight = wp let weighted_average = weight * average2hours + (1 - weight) * average14 + let useOverride = (override != nil) + var overridePercentage = Decimal(override?.percentage ?? 100) var duration: Decimal = 0 - var newDuration: Decimal = 0 var overrideTarget: Decimal = 0 + var smbMin: Decimal = smbMinutes + var uamMin: Decimal = uamMinutes if useOverride { - duration = (overrideArray.first?.duration ?? 0) as Decimal - overrideTarget = (overrideArray.first?.target ?? 0) as Decimal - let advancedSettings = overrideArray.first?.advancedSettings ?? false - let addedMinutes = Int(duration) - let date = overrideArray.first?.date ?? Date() - if date.addingTimeInterval(addedMinutes.minutes.timeInterval) < Date(), - !unlimited - { - useOverride = false - let saveToCoreData = Override(context: self.coredataContext) - saveToCoreData.enabled = false - saveToCoreData.date = Date() - saveToCoreData.duration = 0 - saveToCoreData.indefinite = false - saveToCoreData.percentage = 100 - try? self.coredataContext.save() + duration = override?.duration ?? 0 + overrideTarget = override?.target ?? 0 + if let sm = override?.smbMinutes { + smbMin = sm > 0 ? sm : smbMinutes + } + if let um = override?.uamMinutes { + uamMin = um > 0 ? um : uamMinutes } - } - - if !useOverride { - unlimited = true - overridePercentage = 100 - duration = 0 - overrideTarget = 0 - disableSMBs = false } if temptargetActive { @@ -255,15 +235,15 @@ final class OpenAPS { hbt: hbt_, overrideTarget: overrideTarget, smbIsOff: disableSMBs, - advancedSettings: overrideArray.first?.advancedSettings ?? false, - isfAndCr: overrideArray.first?.isfAndCr ?? false, - isf: overrideArray.first?.isf ?? false, - cr: overrideArray.first?.cr ?? false, - smbIsScheduledOff: overrideArray.first?.smbIsScheduledOff ?? false, - start: (overrideArray.first?.start ?? 0) as Decimal, - end: (overrideArray.first?.end ?? 0) as Decimal, - smbMinutes: (overrideArray.first?.smbMinutes ?? smbMinutes) as Decimal, - uamMinutes: (overrideArray.first?.uamMinutes ?? uamMinutes) as Decimal + advancedSettings: override?.advancedSettings ?? false, + isfAndCr: override?.isfAndCr ?? false, + isf: override?.isf ?? false, + cr: override?.cr ?? false, + smbIsScheduledOff: override?.smbIsScheduledOff ?? false, + start: override?.start ?? 0, + end: override?.end ?? 0, + smbMinutes: smbMin, + uamMinutes: uamMin ) storage.save(averages, as: OpenAPS.Monitor.oref2_variables) return self.loadFileFromStorage(name: Monitor.oref2_variables) @@ -283,15 +263,15 @@ final class OpenAPS { hbt: hbt_, overrideTarget: overrideTarget, smbIsOff: disableSMBs, - advancedSettings: overrideArray.first?.advancedSettings ?? false, - isfAndCr: overrideArray.first?.isfAndCr ?? false, - isf: overrideArray.first?.isf ?? false, - cr: overrideArray.first?.cr ?? false, - smbIsScheduledOff: overrideArray.first?.smbIsScheduledOff ?? false, - start: (overrideArray.first?.start ?? 0) as Decimal, - end: (overrideArray.first?.end ?? 0) as Decimal, - smbMinutes: (overrideArray.first?.smbMinutes ?? smbMinutes) as Decimal, - uamMinutes: (overrideArray.first?.uamMinutes ?? uamMinutes) as Decimal + advancedSettings: override?.advancedSettings ?? false, + isfAndCr: override?.isfAndCr ?? false, + isf: override?.isf ?? false, + cr: override?.cr ?? false, + smbIsScheduledOff: override?.smbIsScheduledOff ?? false, + start: override?.start ?? 0, + end: override?.end ?? 0, + smbMinutes: smbMin, + uamMinutes: uamMin ) storage.save(averages, as: OpenAPS.Monitor.oref2_variables) return self.loadFileFromStorage(name: Monitor.oref2_variables) diff --git a/FreeAPS/Sources/APS/Storage/OverrideStorage.swift b/FreeAPS/Sources/APS/Storage/OverrideStorage.swift new file mode 100644 index 000000000..7b49bb185 --- /dev/null +++ b/FreeAPS/Sources/APS/Storage/OverrideStorage.swift @@ -0,0 +1,329 @@ +import CoreData +import Foundation +import SwiftDate +import Swinject + +/// Observer to register to be informed by a change in the current override +protocol OverrideObserver { + func overrideDidUpdate(_ targets: [OverrideProfil?]) +} + +protocol OverrideStorage { + func storeOverride(_ targets: [OverrideProfil]) + func storeOverridePresets(_ targets: [OverrideProfil]) + func presets() -> [OverrideProfil] + func syncDate() -> Date + func recent() -> [OverrideProfil?] + // func nightscoutTretmentsNotUploaded() -> [NightscoutTreatment] + func current() -> OverrideProfil? + func cancelCurrentOverride() -> Decimal? + func applyOverridePreset(_ presetId: String) -> Date? + func deleteOverridePreset(_ presetId: String) +} + +/// Class to manage the store of override and override preset +final class BaseOverrideStorage: OverrideStorage, Injectable { + private let processQueue = DispatchQueue(label: "BaseOverrideStorage.processQueue") + @Injected() private var broadcaster: Broadcaster! + @Injected() private var settingsManager: SettingsManager! + + let coredataContext: NSManagedObjectContext + private var lastCurrentOverride: OverrideProfil? + + init( + resolver: Resolver, + managedObjectContext: NSManagedObjectContext = CoreDataStack.shared.persistentContainer.viewContext + ) { + coredataContext = managedObjectContext + injectServices(resolver) + } + + /// Convert a override Preset Core Data as a Override Profil + /// - Parameter preset: a override preset in Core Data + /// - Returns: A override in Override Profil structure + private func OverridePresetToOverrideProfil(_ preset: OverridePresets) -> OverrideProfil { + OverrideProfil( + id: preset.id ?? UUID().uuidString, + name: preset.name, + duration: preset.duration as Decimal?, + indefinite: preset.indefinite, + percentage: preset.percentage, + target: preset.target as Decimal?, + advancedSettings: preset.advancedSettings, + smbIsOff: preset.smbIsOff, + isfAndCr: preset.isfAndCr, + isf: preset.isf, + cr: preset.cr, + smbIsScheduledOff: preset.smbIsScheduledOff, + start: preset.start as Decimal?, + end: preset.end as Decimal?, + smbMinutes: preset.smbMinutes as Decimal?, + uamMinutes: preset.uamMinutes as Decimal?, + enteredBy: OverrideProfil.manual, + reason: "" + ) + } + + /// Convert a override Core Data as a Override Profil + /// - Parameter preset: a override in Core Data + /// - Returns: A override in Override Profil structure + private func OverrideToOverrideProfil(_ preset: Override) -> OverrideProfil { + OverrideProfil( + id: preset.id!, + name: preset.name == "" ? nil : preset.name, + createdAt: preset.date, + duration: preset.duration as Decimal?, + indefinite: preset.indefinite, + percentage: preset.percentage, + target: preset.target as Decimal?, + advancedSettings: preset.advancedSettings, + smbIsOff: preset.smbIsOff, + isfAndCr: preset.isfAndCr, + isf: preset.isf, + cr: preset.cr, + smbIsScheduledOff: preset.smbIsScheduledOff, + start: preset.start as Decimal?, + end: preset.end as Decimal?, + smbMinutes: preset.smbMinutes as Decimal?, + uamMinutes: preset.uamMinutes as Decimal?, + enteredBy: OverrideProfil.manual, + reason: "" + ) + } + + /// Fetch all override presets available in storage core data + /// - Returns: List of override Presets as Override Profil structure + func presets() -> [OverrideProfil] { + fetchOverridePreset().compactMap { + OverridePresetToOverrideProfil($0) + } + } + + /// Fetch all override presets available in storage core data + /// - Returns: List of override Presets in core data structure + private func fetchOverridePreset() -> [OverridePresets] { + coredataContext.performAndWait { + let requestPresets = OverridePresets.fetchRequest() as NSFetchRequest + let results = try? self.coredataContext.fetch(requestPresets) + return results ?? [] + } + } + + /// delete a preset override + /// - Parameter presetId: the identifier of the preset override + func deleteOverridePreset(_ presetId: String) { + coredataContext.performAndWait { + let requestPresets = OverridePresets.fetchRequest() as NSFetchRequest + requestPresets.predicate = NSPredicate( + format: "id == %@", presetId + ) + let results = try? self.coredataContext.fetch(requestPresets) + if let deleteObject = results?.first { + self.coredataContext.delete(deleteObject) + } + } + } + + /// Store new or updated override target + /// - Parameter targets: List of new or updated override + func storeOverride(_ targets: [OverrideProfil]) { + storeOverride(targets, isPresets: false) + } + + /// Store override preset in Core Data + /// - Parameter targets: List of new or updated override preset + func storeOverridePresets(_ targets: [OverrideProfil]) { + storeOverride(targets, isPresets: true) + } + + /// store overrides in Core Data and eventually update the current override event + /// - Parameters: + /// - targets: List of new or updated override as a preset or as a target + /// - isPresets: definied if targerts is a override preset (true). + private func storeOverride(_ targets: [OverrideProfil], isPresets: Bool) { + // store in preset override + // processQueue.sync { + if isPresets { + let listOverridePresets = fetchOverridePreset() + _ = targets.compactMap { preset in + // find if existing or create a new one + let save = listOverridePresets + .first(where: { $0.id == preset.id }) ?? OverridePresets(context: coredataContext) + save.id = preset.id + save.name = preset.name + save.end = preset.end as NSDecimalNumber? + save.start = preset.start as NSDecimalNumber? + save.advancedSettings = preset.advancedSettings ?? false + save.cr = preset.cr ?? false + save.duration = preset.duration as NSDecimalNumber? + save.indefinite = preset.indefinite ?? true + save.isf = preset.isf ?? false + save.isfAndCr = preset.isfAndCr ?? false + save.percentage = preset.percentage ?? 100.0 + save.smbIsScheduledOff = preset.smbIsScheduledOff ?? false + save.smbIsOff = preset.smbIsOff ?? false + save.smbMinutes = (preset.smbMinutes ?? settingsManager.preferences.maxSMBBasalMinutes) as NSDecimalNumber? + save.uamMinutes = (preset.uamMinutes ?? settingsManager.preferences.maxUAMSMBBasalMinutes) as NSDecimalNumber? + save.target = preset.target as NSDecimalNumber? + return save + } + + coredataContext.performAndWait { + try? coredataContext.save() + } + + } else { + _ = targets.compactMap { target in + // update if existing or create + let save = fetchOverrideById(id: target.id) ?? Override(context: coredataContext) + save.id = target.id + save.date = target.createdAt ?? Date() + save.name = target.name ?? "" + save.end = target.end as NSDecimalNumber? + save.start = target.start as NSDecimalNumber? + save.advancedSettings = target.advancedSettings ?? false + save.cr = target.cr ?? false + save.duration = target.duration as NSDecimalNumber? + save.indefinite = target.indefinite ?? true + save.isf = target.isf ?? false + save.isfAndCr = target.isfAndCr ?? false + save.percentage = target.percentage ?? 100.0 + save.smbIsScheduledOff = target.smbIsScheduledOff ?? false + save.smbIsOff = target.smbIsOff ?? false + save.smbMinutes = (target.smbMinutes ?? settingsManager.preferences.maxSMBBasalMinutes) as NSDecimalNumber? + save.uamMinutes = (target.uamMinutes ?? settingsManager.preferences.maxUAMSMBBasalMinutes) as NSDecimalNumber? + save.target = target.target as NSDecimalNumber? + save.enabled = false // # TODO: don't use the attribute - compatibility only + return save + } + + coredataContext.performAndWait { + try? coredataContext.save() + } + // update the previous current value + _ = current() + } + // } + } + + /// The start date of override data available by recent function + /// - Returns: the oldest date of data returned + func syncDate() -> Date { + Date().addingTimeInterval(-1.days.timeInterval) + } + + private func fetchNumberOfOverrides(numbers: Int) -> [Override]? { + coredataContext.performAndWait { + let requestOverrides = Override.fetchRequest() as NSFetchRequest + let sortOverride = NSSortDescriptor(key: "date", ascending: false) + requestOverrides.sortDescriptors = [sortOverride] + requestOverrides.fetchLimit = numbers + return try? self.coredataContext.fetch(requestOverrides) + } + } + + private func fetchOverrides(interval: Date) -> [Override]? { + var overrideArray = [Override]() + coredataContext.performAndWait { + let requestOverrides = Override.fetchRequest() as NSFetchRequest + let sortOverride = NSSortDescriptor(key: "date", ascending: false) + requestOverrides.sortDescriptors = [sortOverride] + requestOverrides.predicate = NSPredicate( + format: "date > %@", interval as NSDate + ) + try? overrideArray = self.coredataContext.fetch(requestOverrides) + } + return overrideArray + } + + private func fetchOverrideById(id: String) -> Override? { + coredataContext.performAndWait { + let requestOverrides = Override.fetchRequest() as NSFetchRequest + requestOverrides.predicate = NSPredicate( + format: "id == %@", id + ) + return try? self.coredataContext.fetch(requestOverrides).first + } + } + + /// Provides the last 24 hours override stored in the core data + /// - Returns: a array of override profil sorted by date + func recent() -> [OverrideProfil?] { + if let overrideRecent = fetchOverrides(interval: syncDate()) { + return overrideRecent.compactMap { + OverrideToOverrideProfil($0) + } + } else { + return [] + } + } + + /// Provides the current override or nil if no is current available + /// broadcast a observer overrideDidUpdate if the current override has changed since the last current function call + /// - Returns: A override profil currently in action + func current() -> OverrideProfil? { + var newCurrentOverride: OverrideProfil? + + if let overrideRecent = fetchNumberOfOverrides(numbers: 1), let overrideCurrent = overrideRecent.first { + if overrideCurrent.indefinite { + newCurrentOverride = OverrideToOverrideProfil(overrideCurrent) + + } else if + let duration = overrideCurrent.duration as Decimal?, + let date = overrideCurrent.date, + (Date().timeIntervalSinceReferenceDate - date.timeIntervalSinceReferenceDate).minutes < Double(duration), + date <= Date(), + duration != 0 + { + newCurrentOverride = OverrideToOverrideProfil(overrideCurrent) + } else { + newCurrentOverride = nil + } + } else { + newCurrentOverride = nil + } + + processQueue.sync { + if lastCurrentOverride != newCurrentOverride { + broadcaster.notify(OverrideObserver.self, on: processQueue) { + $0.overrideDidUpdate([newCurrentOverride]) + } + } + } + + lastCurrentOverride = newCurrentOverride + + return newCurrentOverride + } + + /// Cancel the current override + /// - Returns: the final duration of the event + func cancelCurrentOverride() -> Decimal? { + guard var currentOverride = current() else { return nil } + + currentOverride + .duration = + Decimal( + (Date().timeIntervalSinceReferenceDate - currentOverride.createdAt!.timeIntervalSinceReferenceDate) + .minutes + ) + + storeOverride([currentOverride]) + + return currentOverride.duration + } + + /// Apply a override preset as the current override + /// - Parameter presetId: the identifier of the preset override + /// - Returns: the date of the creation/start of the current override event + func applyOverridePreset(_ presetId: String) -> Date? { + guard var preset = presets().first(where: { $0.id == presetId }) else { return nil } + + // cancel the eventual current override + _ = cancelCurrentOverride() + + preset.createdAt = Date() + storeOverride([preset]) + return preset.createdAt + } +} diff --git a/FreeAPS/Sources/Assemblies/StorageAssembly.swift b/FreeAPS/Sources/Assemblies/StorageAssembly.swift index e025cede8..779395d67 100644 --- a/FreeAPS/Sources/Assemblies/StorageAssembly.swift +++ b/FreeAPS/Sources/Assemblies/StorageAssembly.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import Swinject @@ -15,5 +16,6 @@ final class StorageAssembly: Assembly { container.register(SettingsManager.self) { r in BaseSettingsManager(resolver: r) } container.register(Keychain.self) { _ in BaseKeychain() } container.register(AlertHistoryStorage.self) { r in BaseAlertHistoryStorage(resolver: r) } + container.register(OverrideStorage.self) { r in BaseOverrideStorage(resolver: r) } } } diff --git a/FreeAPS/Sources/Helpers/CoreDataStack.swift b/FreeAPS/Sources/Helpers/CoreDataStack.swift index e47d73216..61fe1d7a8 100644 --- a/FreeAPS/Sources/Helpers/CoreDataStack.swift +++ b/FreeAPS/Sources/Helpers/CoreDataStack.swift @@ -2,12 +2,20 @@ import CoreData import Foundation class CoreDataStack: ObservableObject { + public static let modelName = "Core_Data" + + public static let model: NSManagedObjectModel = { + // swiftlint:disable force_unwrapping + let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd")! + return NSManagedObjectModel(contentsOf: modelURL)! + }() + init() {} static let shared = CoreDataStack() lazy var persistentContainer: NSPersistentContainer = { - let container = NSPersistentContainer(name: "Core_Data") + let container = NSPersistentContainer(name: CoreDataStack.modelName) container.loadPersistentStores(completionHandler: { _, error in guard let error = error as NSError? else { return } diff --git a/FreeAPS/Sources/Models/NightscoutExercice.swift b/FreeAPS/Sources/Models/NightscoutExercice.swift new file mode 100644 index 000000000..0babffb2c --- /dev/null +++ b/FreeAPS/Sources/Models/NightscoutExercice.swift @@ -0,0 +1,32 @@ +import Foundation + +/// A structure to descrive a Override as a exercice for NightScout +struct NightscoutExercice: JSON, Hashable, Equatable { + var duration: Int? + var eventType: EventType + var createdAt: Date? + var enteredBy: String? + var notes: String? + + static let local = "Trio" + + static let empty = NightscoutExercice(from: "{}")! + + static func == (lhs: NightscoutExercice, rhs: NightscoutExercice) -> Bool { + (lhs.createdAt ?? Date()) == (rhs.createdAt ?? Date()) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(createdAt ?? Date()) + } +} + +extension NightscoutExercice { + private enum CodingKeys: String, CodingKey { + case duration + case eventType + case createdAt = "created_at" + case enteredBy + case notes + } +} diff --git a/FreeAPS/Sources/Models/OverrideProfil.swift b/FreeAPS/Sources/Models/OverrideProfil.swift new file mode 100644 index 000000000..996a72c02 --- /dev/null +++ b/FreeAPS/Sources/Models/OverrideProfil.swift @@ -0,0 +1,96 @@ +import Foundation + +struct OverrideProfil: JSON, Identifiable, Equatable, Hashable { + var id = UUID().uuidString + var name: String? = nil + var createdAt: Date? = nil + var duration: Decimal? = nil { + didSet { + indefinite = (duration == nil) + } + } + + var indefinite: Bool? = false + var percentage: Double? = 100 + var target: Decimal? = 0 + var advancedSettings: Bool? = false + var smbIsOff: Bool? = false + var isfAndCr: Bool? = false + var isf: Bool? = false + var cr: Bool? = false + var smbIsScheduledOff: Bool? = false + var start: Decimal? = 0 + var end: Decimal? = 0 + + var smbMinutes: Decimal? = nil + var uamMinutes: Decimal? = nil + var enteredBy: String? = OverrideProfil.manual + var reason: String? + + static let manual = "Trio" + static let custom = "Temp override" + static let cancel = "Cancel" + + var displayName: String { + if let name = name, name != "" { + return name + } else { + return OverrideProfil.custom + } + } + + static func == (lhs: OverrideProfil, rhs: OverrideProfil) -> Bool { + lhs.createdAt == rhs.createdAt && lhs.indefinite == rhs.indefinite && lhs.duration == rhs.duration + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func cancel(at date: Date) -> OverrideProfil { + OverrideProfil( + name: OverrideProfil.cancel, + createdAt: date, + duration: nil, + indefinite: false, + percentage: 100.0, + target: 0, + advancedSettings: false, + smbIsOff: false, + isfAndCr: false, + isf: false, + cr: false, + smbIsScheduledOff: false, + start: 0, + end: 0, + smbMinutes: nil, + uamMinutes: nil, + enteredBy: OverrideProfil.manual, + reason: OverrideProfil.cancel + ) + } +} + +extension OverrideProfil { + private enum CodingKeys: String, CodingKey { + case id = "_id" + case name + case createdAt + case advancedSettings + case cr + case duration + case end + case indefinite + case isf + case isfAndCr + case percentage + case smbIsScheduledOff + case smbIsOff + case smbMinutes + case start + case target + case uamMinutes + case enteredBy + case reason + } +} diff --git a/FreeAPS/Sources/Models/PumpHistoryEvent.swift b/FreeAPS/Sources/Models/PumpHistoryEvent.swift index e38f2082a..744439aec 100644 --- a/FreeAPS/Sources/Models/PumpHistoryEvent.swift +++ b/FreeAPS/Sources/Models/PumpHistoryEvent.swift @@ -70,6 +70,7 @@ enum EventType: String, JSON { case nsAnnouncement = "Announcement" case nsSensorChange = "Sensor Start" case nsExternalInsulin = "External Insulin" + case nsExercice = "Exercice" } enum TempType: String, JSON { diff --git a/FreeAPS/Sources/Modules/Home/HomeProvider.swift b/FreeAPS/Sources/Modules/Home/HomeProvider.swift index caef9d847..d4604f0d8 100644 --- a/FreeAPS/Sources/Modules/Home/HomeProvider.swift +++ b/FreeAPS/Sources/Modules/Home/HomeProvider.swift @@ -18,6 +18,12 @@ extension Home { storage.retrieve(OpenAPS.Enact.enacted, as: Suggestion.self) } + var profile: BGTargets { + storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) + ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets)) + ?? BGTargets(units: .mmolL, userPrefferedUnits: .mmolL, targets: []) + } + func heartbeatNow() { apsManager.heartbeat(date: Date()) } diff --git a/FreeAPS/Sources/Modules/Home/HomeStateModel.swift b/FreeAPS/Sources/Modules/Home/HomeStateModel.swift index efd90a187..28be69f4b 100644 --- a/FreeAPS/Sources/Modules/Home/HomeStateModel.swift +++ b/FreeAPS/Sources/Modules/Home/HomeStateModel.swift @@ -9,6 +9,7 @@ extension Home { @Injected() var broadcaster: Broadcaster! @Injected() var apsManager: APSManager! @Injected() var fetchGlucoseManager: FetchGlucoseManager! + @Injected() private var overrideStorage: OverrideStorage! private let timer = DispatchTimer(timeInterval: 5) private(set) var filteredHours = 24 @@ -59,6 +60,9 @@ extension Home { @Published var displayYgridLines: Bool = false @Published var thresholdLines: Bool = false @Published var cgmAvailable: Bool = false + @Published var currentOverride: OverrideProfil? + @Published var overrideHistory: [OverrideProfil?] = [] + @Published var targetBG: BGTargets? let coredataContext = CoreDataStack.shared.persistentContainer.viewContext @@ -73,6 +77,7 @@ extension Home { setupCarbs() setupBattery() setupReservoir() + setupOverride() suggestion = provider.suggestion enactedSuggestion = provider.enactedSuggestion @@ -95,6 +100,8 @@ extension Home { displayYgridLines = settingsManager.settings.yGridLines thresholdLines = settingsManager.settings.rulerMarks + targetBG = provider.profile + broadcaster.register(GlucoseObserver.self, observer: self) broadcaster.register(SuggestionObserver.self, observer: self) broadcaster.register(SettingsObserver.self, observer: self) @@ -106,6 +113,7 @@ extension Home { broadcaster.register(EnactedSuggestionObserver.self, observer: self) broadcaster.register(PumpBatteryObserver.self, observer: self) broadcaster.register(PumpReservoirObserver.self, observer: self) + broadcaster.register(OverrideObserver.self, observer: self) animatedBackground = settingsManager.settings.animatedBackground @@ -207,11 +215,15 @@ extension Home { } func cancelProfile() { - coredataContext.perform { [self] in - let profiles = Override(context: self.coredataContext) - profiles.enabled = false - profiles.date = Date() - try? self.coredataContext.save() + _ = overrideStorage.cancelCurrentOverride() + } + + func setupOverride() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.targetBG = self.provider.profile + self.currentOverride = self.overrideStorage.current() + self.overrideHistory = self.overrideStorage.recent() } } @@ -378,8 +390,13 @@ extension Home.StateModel: CarbsObserver, EnactedSuggestionObserver, PumpBatteryObserver, - PumpReservoirObserver + PumpReservoirObserver, + OverrideObserver { + func overrideDidUpdate(_: [OverrideProfil?]) { + setupOverride() + } + func glucoseDidUpdate(_: [BloodGlucose]) { setupGlucose() } diff --git a/FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift b/FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift index dc70707ee..bba031064 100644 --- a/FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift +++ b/FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift @@ -31,6 +31,7 @@ struct MainChartView: View { static let bolusScale: CGFloat = 2.5 static let carbsSize: CGFloat = 10 static let carbsScale: CGFloat = 0.3 + static let overrideNoTargetDefault: Decimal = 50 } @Binding var glucose: [BloodGlucose] @@ -53,6 +54,8 @@ struct MainChartView: View { @Binding var displayXgridLines: Bool @Binding var displayYgridLines: Bool @Binding var thresholdLines: Bool + @Binding var overrideHistory: [OverrideProfil?] + @Binding var targetBG: BGTargets? @State var didAppearTrigger = false @State private var glucoseDots: [CGRect] = [] @@ -63,6 +66,7 @@ struct MainChartView: View { @State private var tempBasalPath = Path() @State private var regularBasalPath = Path() @State private var tempTargetsPath = Path() + @State private var overridesPath = Path() @State private var suspensionsPath = Path() @State private var carbsDots: [DotInfo] = [] @State private var carbsPath = Path() @@ -155,6 +159,7 @@ struct MainChartView: View { ScrollViewReader { scroll in ZStack(alignment: .top) { tempTargetsView(fullSize: fullSize).drawingGroup() + overridesView(fullSize: fullSize).drawingGroup() basalView(fullSize: fullSize).drawingGroup() mainView(fullSize: fullSize).id(Config.endID) @@ -447,6 +452,24 @@ struct MainChartView: View { } } + private func overridesView(fullSize: CGSize) -> some View { + ZStack { + overridesPath + .fill(Color.purple.opacity(0.5)) + overridesPath + .stroke(Color.purple.opacity(0.5), lineWidth: 1) + } + .onChange(of: glucose) { _ in + calculateOverridesRects(fullSize: fullSize) + } + .onChange(of: overrideHistory) { _ in + calculateOverridesRects(fullSize: fullSize) + } + .onChange(of: didAppearTrigger) { _ in + calculateOverridesRects(fullSize: fullSize) + } + } + private func predictionsView(fullSize: CGSize) -> some View { Group { Path { path in @@ -805,6 +828,58 @@ extension MainChartView { } } + private func calculateOverridesRects(fullSize: CGSize) { + calculationQueue.async { + let rects = overrideHistory.enumerated().compactMap { (index, override) -> CGRect? in + if let override = override { + let x0 = timeToXCoordinate(override.createdAt!.timeIntervalSince1970, fullSize: fullSize) + var y0: CGFloat + if override.target != nil, override.target! > 0 { + y0 = glucoseToYCoordinate(Int(override.target!), fullSize: fullSize) + } else if let targetBG = targetBG { + // find the targetBG at the beginning + let bestOffset = override.createdAt!.hour * 60 + override.createdAt!.minute + let targetItemBG = targetBG.targets.sorted(by: { $0.offset < $1.offset }) + .last(where: { $0.offset < bestOffset })?.low ?? Config.overrideNoTargetDefault + y0 = glucoseToYCoordinate(Int(targetItemBG), fullSize: fullSize) + } else { + y0 = glucoseToYCoordinate(Int(Config.overrideNoTargetDefault), fullSize: fullSize) + } + + // only the first override could be indefinite + let x1 = override.indefinite! && index == 0 ? + timeToXCoordinate(Date().timeIntervalSince1970, fullSize: fullSize) + : + timeToXCoordinate( + override.createdAt!.timeIntervalSince1970 + Int(override.duration ?? 0).minutes.timeInterval, + fullSize: fullSize + ) + let widthRect = override.indefinite! && index == 0 ? + x1 - x0 + additionalWidth(viewWidth: fullSize.width) + : + x1 - x0 + + return CGRect( + x: x0, + y: y0 - 3, + width: widthRect, + height: 6 + ) + } else { + return nil + } + } + + let path = Path { path in + path.addRects(rects) + } + + DispatchQueue.main.async { + overridesPath = path + } + } + } + private func findRegularBasalPoints( timeBegin: TimeInterval, timeEnd: TimeInterval, diff --git a/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift b/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift index 25ebbf4f4..87d4357dd 100644 --- a/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift +++ b/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift @@ -15,28 +15,12 @@ extension Home { @Environment(\.managedObjectContext) var moc @Environment(\.colorScheme) var colorScheme - @FetchRequest( - entity: Override.entity(), - sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)] - ) var fetchedPercent: FetchedResults - - @FetchRequest( - entity: OverridePresets.entity(), - sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: NSPredicate( - format: "name != %@", "" as String - ) - ) var fetchedProfiles: FetchedResults - + // * TODO: To remove with #154 https://github.com/nightscout/Open-iAPS/issues/154 @FetchRequest( entity: TempTargets.entity(), sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)] ) var sliderTTpresets: FetchedResults - @FetchRequest( - entity: TempTargetsSlider.entity(), - sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)] - ) var enactedSliderTT: FetchedResults - private var numberFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal @@ -196,6 +180,7 @@ extension Home { let rawString = (tirFormatter.string(from: (tempTarget.targetBottom ?? 0) as NSNumber) ?? "") + " " + state.units .rawValue + // * TODO: To remove with #154 https://github.com/nightscout/Open-iAPS/issues/154 var string = "" if sliderTTpresets.first?.active ?? false { let hbt = sliderTTpresets.first?.hbt ?? 0 @@ -208,12 +193,12 @@ extension Home { } var overrideString: String? { - guard fetchedPercent.first?.enabled ?? false else { + guard state.currentOverride != nil else { return nil } - var percentString = "\((fetchedPercent.first?.percentage ?? 100).formatted(.number)) %" - var target = (fetchedPercent.first?.target ?? 100) as Decimal - let indefinite = (fetchedPercent.first?.indefinite ?? false) + var percentString = "\((state.currentOverride?.percentage ?? 100).formatted(.number)) %" + var target = (state.currentOverride?.target ?? 100) as Decimal + let indefinite = (state.currentOverride?.indefinite ?? false) let unit = state.units.rawValue if state.units == .mmolL { target = target.asMmolL @@ -222,16 +207,16 @@ extension Home { if tempTargetString != nil || target == 0 { targetString = "" } percentString = percentString == "100 %" ? "" : percentString - let duration = (fetchedPercent.first?.duration ?? 0) as Decimal + let duration = state.currentOverride?.duration ?? 0 let addedMinutes = Int(duration) - let date = fetchedPercent.first?.date ?? Date() + let date = state.currentOverride?.createdAt ?? Date() var newDuration: Decimal = 0 if date.addingTimeInterval(addedMinutes.minutes.timeInterval) > Date() { newDuration = Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes) } - var durationString = indefinite ? + let durationString = indefinite ? "" : newDuration >= 1 ? (newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) + " min") : ( @@ -246,12 +231,12 @@ extension Home { } let smbToggleString = ( - (fetchedPercent.first?.smbIsOff ?? false) || fetchedPercent.first?.smbIsScheduledOff ?? false + (state.currentOverride?.smbIsOff ?? false) || state.currentOverride?.smbIsScheduledOff ?? false ) ? " \u{20e0}" : "" - let smbScheduleString = (fetchedPercent.first?.smbIsScheduledOff ?? false) && - !(fetchedPercent.first?.smbIsOff ?? false) ? - " \(fetchedPercent.first?.start ?? 0)-\(fetchedPercent.first?.end ?? 0)" : "" + let smbScheduleString = (state.currentOverride?.smbIsScheduledOff ?? false) && + !(state.currentOverride?.smbIsOff ?? false) ? + " \(state.currentOverride?.start ?? 0)-\(state.currentOverride?.end ?? 0)" : "" let comma1 = (percentString == "" || (targetString == "" && durationString == "" && smbToggleString == "")) ? "" : " , " let comma2 = (targetString == "" || (durationString == "" && smbToggleString == "")) @@ -383,7 +368,9 @@ extension Home { screenHours: $state.screenHours, displayXgridLines: $state.displayXgridLines, displayYgridLines: $state.displayYgridLines, - thresholdLines: $state.thresholdLines + thresholdLines: $state.thresholdLines, + overrideHistory: $state.overrideHistory, + targetBG: $state.targetBG ) } .padding(.bottom) @@ -395,7 +382,7 @@ extension Home { // Rectangle().fill(colour).frame(maxHeight: 1) ZStack { Rectangle().fill(Color.gray.opacity(0.2)).frame(maxHeight: 40) - let cancel = fetchedPercent.first?.enabled ?? false + let cancel = state.currentOverride != nil HStack(spacing: cancel ? 25 : 15) { Text(selectedProfile().name).foregroundColor(.secondary) if cancel, selectedProfile().isOn { @@ -410,8 +397,8 @@ extension Home { Image(systemName: "person.3.sequence.fill") .symbolRenderingMode(.palette) .foregroundStyle( - !(fetchedPercent.first?.enabled ?? false) ? .green : .cyan, - !(fetchedPercent.first?.enabled ?? false) ? .cyan : .green, + !(state.currentOverride != nil) ? .green : .cyan, + !(state.currentOverride != nil) ? .cyan : .green, .purple ) } @@ -433,24 +420,21 @@ extension Home { var profileString = "" var display: Bool = false - let duration = (fetchedPercent.first?.duration ?? 0) as Decimal - let indefinite = fetchedPercent.first?.indefinite ?? false + let duration = (state.currentOverride?.duration ?? 0) as Decimal + let indefinite = state.currentOverride?.indefinite ?? false let addedMinutes = Int(duration) - let date = fetchedPercent.first?.date ?? Date() + let date = state.currentOverride?.createdAt ?? Date() if date.addingTimeInterval(addedMinutes.minutes.timeInterval) > Date() || indefinite { display.toggle() } - if fetchedPercent.first?.enabled ?? false, !(fetchedPercent.first?.isPreset ?? false), display { - profileString = NSLocalizedString("Custom Profile", comment: "Custom but unsaved Profile") - } else if !(fetchedPercent.first?.enabled ?? false) || !display { - profileString = NSLocalizedString("Normal Profile", comment: "Your normal Profile. Use a short string") + if let currentOverride = state.currentOverride, display { + profileString = currentOverride.name != nil ? currentOverride.displayName : NSLocalizedString( + "Custom Profile", + comment: "Custom but unsaved Profile" + ) } else { - let id_ = fetchedPercent.first?.id ?? "" - let profile = fetchedProfiles.filter({ $0.id == id_ }).first - if profile != nil { - profileString = profile?.name?.description ?? "" - } + profileString = NSLocalizedString("Normal Profile", comment: "Your normal Profile. Use a short string") } return (name: profileString, isOn: display) } diff --git a/FreeAPS/Sources/Modules/OverrideProfilesConfig/OverrideProfilesStateModel.swift b/FreeAPS/Sources/Modules/OverrideProfilesConfig/OverrideProfilesStateModel.swift index 9c9180ad1..3dea05c52 100644 --- a/FreeAPS/Sources/Modules/OverrideProfilesConfig/OverrideProfilesStateModel.swift +++ b/FreeAPS/Sources/Modules/OverrideProfilesConfig/OverrideProfilesStateModel.swift @@ -1,4 +1,3 @@ -import CoreData import SwiftUI extension OverrideProfilesConfig { @@ -13,8 +12,8 @@ extension OverrideProfilesConfig { @Published var id: String = "" @Published var profileName: String = "" @Published var isPreset: Bool = false - @Published var presets: [OverridePresets] = [] - @Published var selection: OverridePresets? + @Published var presets: [OverrideProfil] = [] +// @Published var selection: OverrideProfil? @Published var advancedSettings: Bool = false @Published var isfAndCr: Bool = true @Published var isf: Bool = true @@ -27,205 +26,108 @@ extension OverrideProfilesConfig { @Published var defaultSmbMinutes: Decimal = 0 @Published var defaultUamMinutes: Decimal = 0 + @Injected() private var overrideStorage: OverrideStorage! + var units: GlucoseUnits = .mmolL override func subscribe() { units = settingsManager.settings.units defaultSmbMinutes = settingsManager.preferences.maxSMBBasalMinutes defaultUamMinutes = settingsManager.preferences.maxUAMSMBBasalMinutes - presets = [OverridePresets(context: coredataContext)] + smbMinutes = defaultSmbMinutes + uamMinutes = defaultUamMinutes + presets = overrideStorage.presets() } - let coredataContext = CoreDataStack.shared.persistentContainer.viewContext - func saveSettings() { - coredataContext.perform { [self] in - let saveOverride = Override(context: self.coredataContext) - saveOverride.duration = self.duration as NSDecimalNumber - saveOverride.indefinite = self._indefinite - saveOverride.percentage = self.percentage - saveOverride.enabled = true - saveOverride.smbIsOff = self.smbIsOff - if self.isPreset { - saveOverride.isPreset = true - saveOverride.id = id - } else { saveOverride.isPreset = false } - saveOverride.date = Date() - if override_target { - if units == .mmolL { - target = target.asMgdL - } - saveOverride.target = target as NSDecimalNumber - } else { saveOverride.target = 0 } - - if advancedSettings { - saveOverride.advancedSettings = true - - if !isfAndCr { - saveOverride.isfAndCr = false - saveOverride.isf = isf - saveOverride.cr = cr - } else { saveOverride.isfAndCr = true } - if smbIsScheduledOff { - saveOverride.smbIsScheduledOff = true - saveOverride.start = start as NSDecimalNumber - saveOverride.end = end as NSDecimalNumber - } else { saveOverride.smbIsScheduledOff = false } - - saveOverride.smbMinutes = smbMinutes as NSDecimalNumber - saveOverride.uamMinutes = uamMinutes as NSDecimalNumber - } - try? self.coredataContext.save() - } + let overrideToSave = OverrideProfil( + name: profileName, + createdAt: Date(), + duration: _indefinite ? nil : duration, + indefinite: _indefinite, + percentage: percentage, + target: override_target ? (units == .mmolL ? target.asMgdL : target) : 0, + advancedSettings: advancedSettings, + smbIsOff: smbIsOff, + isfAndCr: isfAndCr, + isf: isfAndCr ? false : isf, + cr: isfAndCr ? false : cr, + smbIsScheduledOff: smbIsScheduledOff, + start: smbIsScheduledOff ? start : nil, + end: smbIsScheduledOff ? end : nil, + smbMinutes: smbMinutes, + uamMinutes: uamMinutes + ) + + overrideStorage.storeOverride([overrideToSave]) } func savePreset() { - coredataContext.perform { [self] in - let saveOverride = OverridePresets(context: self.coredataContext) - saveOverride.duration = self.duration as NSDecimalNumber - saveOverride.indefinite = self._indefinite - saveOverride.percentage = self.percentage - saveOverride.smbIsOff = self.smbIsOff - saveOverride.name = self.profileName - id = UUID().uuidString - self.isPreset.toggle() - saveOverride.id = id - saveOverride.date = Date() - if override_target { - saveOverride.target = ( - units == .mmolL - ? target.asMgdL - : target - ) as NSDecimalNumber - } else { saveOverride.target = 0 } - - if advancedSettings { - saveOverride.advancedSettings = true - - if !isfAndCr { - saveOverride.isfAndCr = false - saveOverride.isf = isf - saveOverride.cr = cr - } else { saveOverride.isfAndCr = true } - if smbIsScheduledOff { - saveOverride.smbIsScheduledOff = true - saveOverride.start = start as NSDecimalNumber - saveOverride.end = end as NSDecimalNumber - } else { smbIsScheduledOff = false } - - saveOverride.smbMinutes = smbMinutes as NSDecimalNumber - saveOverride.uamMinutes = uamMinutes as NSDecimalNumber - } - try? self.coredataContext.save() - } + let overridePresetToSave = OverrideProfil( + name: profileName, + duration: _indefinite ? nil : duration, + indefinite: _indefinite, + percentage: percentage, + target: override_target ? (units == .mmolL ? target.asMgdL : target) : 0, + advancedSettings: advancedSettings, + smbIsOff: smbIsOff, + isfAndCr: isfAndCr, + isf: isfAndCr ? false : isf, + cr: isfAndCr ? false : cr, + smbIsScheduledOff: smbIsScheduledOff, + start: smbIsScheduledOff ? start : nil, + end: smbIsScheduledOff ? end : nil, + smbMinutes: smbMinutes, + uamMinutes: uamMinutes + ) + + overrideStorage.storeOverridePresets([overridePresetToSave]) + presets = overrideStorage.presets() } func selectProfile(id_: String) { guard id_ != "" else { return } - coredataContext.performAndWait { - var profileArray = [OverridePresets]() - let requestProfiles = OverridePresets.fetchRequest() as NSFetchRequest - try? profileArray = coredataContext.fetch(requestProfiles) - - guard let profile = profileArray.filter({ $0.id == id_ }).first else { return } - - let saveOverride = Override(context: self.coredataContext) - saveOverride.duration = (profile.duration ?? 0) as NSDecimalNumber - saveOverride.indefinite = profile.indefinite - saveOverride.percentage = profile.percentage - saveOverride.enabled = true - saveOverride.smbIsOff = profile.smbIsOff - saveOverride.isPreset = true - saveOverride.date = Date() - saveOverride.target = profile.target - saveOverride.id = id_ - - if profile.advancedSettings { - saveOverride.advancedSettings = true - if !isfAndCr { - saveOverride.isfAndCr = false - saveOverride.isf = profile.isf - saveOverride.cr = profile.cr - } else { saveOverride.isfAndCr = true } - if profile.smbIsScheduledOff { - saveOverride.smbIsScheduledOff = true - saveOverride.start = profile.start - saveOverride.end = profile.end - } else { saveOverride.smbIsScheduledOff = false } - - saveOverride.smbMinutes = (profile.smbMinutes ?? 0) as NSDecimalNumber - saveOverride.uamMinutes = (profile.uamMinutes ?? 0) as NSDecimalNumber - } - try? self.coredataContext.save() - } + _ = overrideStorage.applyOverridePreset(id_) } func savedSettings() { - coredataContext.performAndWait { - var overrideArray = [Override]() - let requestEnabled = Override.fetchRequest() as NSFetchRequest - let sortIsEnabled = NSSortDescriptor(key: "date", ascending: false) - requestEnabled.sortDescriptors = [sortIsEnabled] - // requestEnabled.fetchLimit = 1 - try? overrideArray = coredataContext.fetch(requestEnabled) - isEnabled = overrideArray.first?.enabled ?? false - percentage = overrideArray.first?.percentage ?? 100 - _indefinite = overrideArray.first?.indefinite ?? true - duration = (overrideArray.first?.duration ?? 0) as Decimal - smbIsOff = overrideArray.first?.smbIsOff ?? false - advancedSettings = overrideArray.first?.advancedSettings ?? false - isfAndCr = overrideArray.first?.isfAndCr ?? true - smbIsScheduledOff = overrideArray.first?.smbIsScheduledOff ?? false - - if advancedSettings { - if !isfAndCr { - isf = overrideArray.first?.isf ?? false - cr = overrideArray.first?.cr ?? false - } - if smbIsScheduledOff { - start = (overrideArray.first?.start ?? 0) as Decimal - end = (overrideArray.first?.end ?? 0) as Decimal - } - - if (overrideArray[0].smbMinutes as Decimal?) != nil { - smbMinutes = (overrideArray.first?.smbMinutes ?? 30) as Decimal - } - - if (overrideArray[0].uamMinutes as Decimal?) != nil { - uamMinutes = (overrideArray.first?.uamMinutes ?? 30) as Decimal - } - } + guard let currentOverride = overrideStorage.current() else { + isEnabled = false + return + } - let overrideTarget = (overrideArray.first?.target ?? 0) as Decimal - - var newDuration = Double(duration) - if isEnabled { - let duration = overrideArray.first?.duration ?? 0 - let addedMinutes = Int(duration as Decimal) - let date = overrideArray.first?.date ?? Date() - if date.addingTimeInterval(addedMinutes.minutes.timeInterval) < Date(), !_indefinite { - isEnabled = false - } - newDuration = Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes - if overrideTarget != 0 { - override_target = true - target = units == .mmolL ? overrideTarget.asMmolL : overrideTarget - } + isEnabled = true + percentage = currentOverride.percentage ?? 100 + _indefinite = currentOverride.indefinite ?? true + duration = currentOverride.duration ?? 0 + smbIsOff = currentOverride.smbIsOff ?? false + advancedSettings = currentOverride.advancedSettings ?? false + isfAndCr = currentOverride.isfAndCr ?? true + smbIsScheduledOff = currentOverride.smbIsScheduledOff ?? false + + if advancedSettings { + if !isfAndCr { + isf = currentOverride.isf ?? false + cr = currentOverride.cr ?? false } - - if newDuration < 0 { newDuration = 0 } else { duration = Decimal(newDuration) } - - if !isEnabled { - _indefinite = true - percentage = 100 - duration = 0 - target = 0 - override_target = false - smbIsOff = false - advancedSettings = false - smbMinutes = defaultSmbMinutes - uamMinutes = defaultUamMinutes + if smbIsScheduledOff { + start = currentOverride.start ?? 0 + end = currentOverride.end ?? 0 } + + smbMinutes = currentOverride.smbMinutes ?? defaultSmbMinutes + uamMinutes = currentOverride.uamMinutes ?? defaultUamMinutes + } + + let overrideTarget = currentOverride.target ?? 0 + if overrideTarget != 0 { + override_target = true + target = units == .mmolL ? overrideTarget.asMmolL : overrideTarget + } + if !_indefinite { + let durationOverride = currentOverride.duration ?? 0 + let date = currentOverride.createdAt ?? Date() + duration = max(0, durationOverride + Decimal(Date().distance(to: date).minutes)) } } @@ -238,14 +140,14 @@ extension OverrideProfilesConfig { override_target = false smbIsOff = false advancedSettings = false - coredataContext.perform { [self] in - let profiles = Override(context: self.coredataContext) - profiles.enabled = false - profiles.date = Date() - try? self.coredataContext.save() - } smbMinutes = defaultSmbMinutes uamMinutes = defaultUamMinutes + + _ = overrideStorage.cancelCurrentOverride() + } + + func removeOverrideProfile(presetId: String) { + overrideStorage.deleteOverridePreset(presetId) } } } diff --git a/FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift b/FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift index 71c481919..d2b0424e7 100644 --- a/FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift +++ b/FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift @@ -1,4 +1,3 @@ -import CoreData import SwiftUI import Swinject @@ -14,14 +13,6 @@ extension OverrideProfilesConfig { @State var isSheetPresented: Bool = false @Environment(\.dismiss) var dismiss - @Environment(\.managedObjectContext) var moc - - @FetchRequest( - entity: OverridePresets.entity(), - sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: NSPredicate( - format: "name != %@", "" as String - ) - ) var fetchedProfiles: FetchedResults private var formatter: NumberFormatter { let formatter = NumberFormatter() @@ -52,7 +43,7 @@ extension OverrideProfilesConfig { state.savePreset() isSheetPresented = false } - .disabled(state.profileName.isEmpty || fetchedProfiles.filter({ $0.name == state.profileName }).isNotEmpty) + .disabled(state.profileName.isEmpty || state.presets.filter({ $0.name == state.profileName }).isNotEmpty) Button("Cancel") { isSheetPresented = false @@ -65,7 +56,7 @@ extension OverrideProfilesConfig { Form { if state.presets.isNotEmpty { Section { - ForEach(fetchedProfiles) { preset in + ForEach(state.presets) { preset in profilesView(for: preset) }.onDelete(perform: removeProfile) } @@ -228,7 +219,6 @@ extension OverrideProfilesConfig { Button("Cancel", role: .cancel) { state.isEnabled = false } Button("Start Profile", role: .destructive) { if state._indefinite { state.duration = 0 } - state.isEnabled.toggle() state.saveSettings() dismiss() } @@ -275,21 +265,22 @@ extension OverrideProfilesConfig { .navigationBarItems(leading: Button("Close", action: state.hideModal)) } - @ViewBuilder private func profilesView(for preset: OverridePresets) -> some View { - let target = state.units == .mmolL ? (((preset.target ?? 0) as NSDecimalNumber) as Decimal) - .asMmolL : (preset.target ?? 0) as Decimal - let duration = (preset.duration ?? 0) as Decimal + @ViewBuilder private func profilesView(for preset: OverrideProfil) -> some View { + let target = state.units == .mmolL ? (preset.target ?? 0).asMmolL : preset.target ?? 0 + let duration = preset.duration ?? 0 let name = ((preset.name ?? "") == "") || (preset.name?.isEmpty ?? true) ? "" : preset.name! - let percent = preset.percentage / 100 - let perpetual = preset.indefinite + let percent = (preset.percentage ?? 100) / 100 + let perpetual = preset.indefinite ?? false let durationString = perpetual ? "" : "\(formatter.string(from: duration as NSNumber)!)" - let scheduledSMBstring = (preset.smbIsOff && preset.smbIsScheduledOff) ? "Scheduled SMBs" : "" - let smbString = (preset.smbIsOff && scheduledSMBstring == "") ? "SMBs are off" : "" + let scheduledSMBstring = ((preset.smbIsOff ?? false) && (preset.smbIsScheduledOff ?? false)) ? "Scheduled SMBs" : "" + let smbString = ((preset.smbIsOff ?? false) && scheduledSMBstring == "") ? "SMBs are off" : "" let targetString = target != 0 ? "\(glucoseFormatter.string(from: target as NSNumber)!)" : "" - let maxMinutesSMB = (preset.smbMinutes as Decimal?) != nil ? (preset.smbMinutes ?? 0) as Decimal : 0 - let maxMinutesUAM = (preset.uamMinutes as Decimal?) != nil ? (preset.uamMinutes ?? 0) as Decimal : 0 - let isfString = preset.isf ? "ISF" : "" - let crString = preset.cr ? "CR" : "" + let eventualSmbMinutes = preset.smbMinutes != nil && preset.smbMinutes != state.defaultSmbMinutes ? preset + .smbMinutes : nil + let eventualUamMinutes = preset.uamMinutes != nil && preset.uamMinutes != state.defaultUamMinutes ? preset + .uamMinutes : nil + let isfString = (preset.isf ?? false) ? "ISF" : "" + let crString = (preset.cr ?? false) ? "CR" : "" let dash = crString != "" ? "/" : "" let isfAndCRstring = isfString + dash + crString @@ -309,9 +300,9 @@ extension OverrideProfilesConfig { if durationString != "" { Text(durationString + (perpetual ? "" : "min")) } if smbString != "" { Text(smbString).foregroundColor(.secondary).font(.caption) } if scheduledSMBstring != "" { Text(scheduledSMBstring) } - if preset.advancedSettings { - Text(maxMinutesSMB == 0 ? "" : maxMinutesSMB.formatted() + " SMB") - Text(maxMinutesUAM == 0 ? "" : maxMinutesUAM.formatted() + " UAM") + if let advanced = preset.advancedSettings, advanced { + Text(eventualSmbMinutes == nil ? "" : eventualSmbMinutes!.formatted() + "min SMB") + Text(eventualUamMinutes == nil ? "" : eventualUamMinutes!.formatted() + "min UAM") Text(isfAndCRstring) } Spacer() @@ -322,7 +313,7 @@ extension OverrideProfilesConfig { } .contentShape(Rectangle()) .onTapGesture { - state.selectProfile(id_: preset.id ?? "") + state.selectProfile(id_: preset.id) state.hideModal() } } @@ -341,13 +332,7 @@ extension OverrideProfilesConfig { private func removeProfile(at offsets: IndexSet) { for index in offsets { - let language = fetchedProfiles[index] - moc.delete(language) - } - do { - try moc.save() - } catch { - // To do: add error + state.removeOverrideProfile(presetId: state.presets[index].id) } } } diff --git a/FreeAPS/Sources/Services/Network/NightscoutAPI.swift b/FreeAPS/Sources/Services/Network/NightscoutAPI.swift index b5d5e5a5c..ee21eab30 100644 --- a/FreeAPS/Sources/Services/Network/NightscoutAPI.swift +++ b/FreeAPS/Sources/Services/Network/NightscoutAPI.swift @@ -243,6 +243,49 @@ extension NightscoutAPI { .eraseToAnyPublisher() } + /// fetch the overrides available in NS as a exercice since the date specified in the parameter + /// Limit to exercice with the attribute enteredBy = the name of local app (as defined in NightscoutExercice + /// - Parameter sinceDate: the oldest date to fetch exercices + /// - Returns: A publisher with a array of NightscoutExercice or error + func fetchOverrides(sinceDate: Date? = nil) -> AnyPublisher<[NightscoutExercice], Swift.Error> { + var components = URLComponents() + components.scheme = url.scheme + components.host = url.host + components.port = url.port + components.path = Config.treatmentsPath + components.queryItems = [ + URLQueryItem(name: "find[eventType]", value: "Exercice"), + URLQueryItem( + name: "find[enteredBy]", + value: NightscoutExercice.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + ) + ] + if let date = sinceDate { + let dateItem = URLQueryItem( + name: "find[created_at][$gt]", + value: Formatter.iso8601withFractionalSeconds.string(from: date) + ) + components.queryItems?.append(dateItem) + } + + var request = URLRequest(url: components.url!) + request.allowsConstrainedNetworkAccess = false + request.timeoutInterval = Config.timeout + + if let secret = secret { + request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret") + } + + return service.run(request) + .retry(Config.retryCount) + .decode(type: [NightscoutExercice].self, decoder: JSONCoding.decoder) + .catch { error -> AnyPublisher<[NightscoutExercice], Swift.Error> in + warning(.nightscout, "Override fetching error: \(error.localizedDescription)") + return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + func fetchAnnouncement(sinceDate: Date? = nil) -> AnyPublisher<[Announcement], Swift.Error> { var components = URLComponents() components.scheme = url.scheme @@ -421,6 +464,65 @@ extension NightscoutAPI { .map { _ in () } .eraseToAnyPublisher() } + + /// Upload old, new and updated overrides in NS as a exercice. + /// - Parameter overrides: a array of NightscoutExercice to upload + /// - Returns: A publisher with only error response. + func uploadOverrides(_ overrides: [NightscoutExercice]) -> AnyPublisher { + var components = URLComponents() + components.scheme = url.scheme + components.host = url.host + components.port = url.port + components.path = Config.treatmentsPath + + var request = URLRequest(url: components.url!) + request.allowsConstrainedNetworkAccess = false + request.timeoutInterval = Config.timeout + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + if let secret = secret { + request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret") + } + request.httpBody = try! JSONCoding.encoder.encode(overrides) + request.httpMethod = "POST" + + return service.run(request) + .retry(Config.retryCount) + .map { _ in () } + .eraseToAnyPublisher() + } + + /// delete a override in NS as exercice for a specific date + /// - Parameter date: the date of the override to delete + /// - Returns: A publisher with only error response. + func deleteOverride(at date: Date) -> AnyPublisher { + var components = URLComponents() + components.scheme = url.scheme + components.host = url.host + components.port = url.port + components.path = Config.treatmentsPath + components.queryItems = [ + URLQueryItem(name: "find[eventType]", value: "Exercice"), + URLQueryItem( + name: "find[created_at][$eq]", + value: Formatter.iso8601withFractionalSeconds.string(from: date) + ) + ] + + var request = URLRequest(url: components.url!) + request.allowsConstrainedNetworkAccess = false + request.timeoutInterval = Config.timeout + request.httpMethod = "DELETE" + + if let secret = secret { + request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret") + } + + return service.run(request) + .retry(Config.retryCount) + .map { _ in () } + .eraseToAnyPublisher() + } } private extension String { diff --git a/FreeAPS/Sources/Services/Network/NightscoutManager.swift b/FreeAPS/Sources/Services/Network/NightscoutManager.swift index f4c1d2ffb..161060d2d 100644 --- a/FreeAPS/Sources/Services/Network/NightscoutManager.swift +++ b/FreeAPS/Sources/Services/Network/NightscoutManager.swift @@ -15,6 +15,7 @@ protocol NightscoutManager: GlucoseSource { func uploadGlucose() func uploadPreferences(_ preferences: Preferences) func uploadProfileAndSettings(_: Bool) + var cgmURL: URL? { get } } @@ -30,6 +31,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { @Injected() private var broadcaster: Broadcaster! @Injected() private var reachabilityManager: ReachabilityManager! @Injected() var healthkitManager: HealthKitManager! + @Injected() private var overrideStorage: OverrideStorage! private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue") private var ping: TimeInterval? @@ -67,6 +69,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { broadcaster.register(PumpHistoryObserver.self, observer: self) broadcaster.register(CarbsObserver.self, observer: self) broadcaster.register(TempTargetsObserver.self, observer: self) + broadcaster.register(OverrideObserver.self, observer: self) _ = reachabilityManager.startListening(onQueue: processQueue) { status in debug(.nightscout, "Network status: \(status)") } @@ -161,6 +164,20 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { .eraseToAnyPublisher() } + /// Fetch all overrides available in NS as a exercice + /// Limit to exercice with the attribute enteredBy = the name of local app (as defined in NightscoutExercice + /// - Returns: a publisher of a array of NightscoutExercice. + func fetchOverride() -> AnyPublisher<[NightscoutExercice], Never> { + guard let nightscout = nightscoutAPI, isNetworkReachable else { + return Just([]).eraseToAnyPublisher() + } + + let since = overrideStorage.syncDate() + return nightscout.fetchOverrides(sinceDate: since) + .replaceError(with: []) + .eraseToAnyPublisher() + } + func fetchAnnouncements() -> AnyPublisher<[Announcement], Never> { guard let nightscout = nightscoutAPI, isNetworkReachable else { return Just([]).eraseToAnyPublisher() @@ -627,6 +644,55 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { .store(in: &self.lifetime) } } + + /// Upload all new and updated override as a exercice in NS + private func uploadOverride() { + let overrides = overrideStorage.recent() + guard !overrides.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else { + return + } + + processQueue.async { + let exercices = overrides.compactMap { override -> NightscoutExercice? in + if let override = override { + return NightscoutExercice( + duration: override + .indefinite! ? + Int(Date().timeIntervalSinceReferenceDate - override.createdAt!.timeIntervalSinceReferenceDate + 60) : + Int(override.duration ?? 0), + // by default if indefinite = + 60 minutes + eventType: EventType.nsExercice, + createdAt: override.createdAt, + enteredBy: NightscoutExercice.local, + notes: override.displayName + ) + } else { + return nil + } + } + + exercices.chunks(ofCount: 100) + .map { chunk -> AnyPublisher in + nightscout.uploadOverrides(Array(chunk)) + } + .reduce( + Just(()).setFailureType(to: Error.self) + .eraseToAnyPublisher() + ) { (result, next) -> AnyPublisher in + Publishers.Concatenate(prefix: result, suffix: next).eraseToAnyPublisher() + } + .dropFirst() + .sink { completion in + switch completion { + case .finished: + debug(.nightscout, "Overrides uploaded") + case let .failure(error): + debug(.nightscout, error.localizedDescription) + } + } receiveValue: {} + .store(in: &self.lifetime) + } + } } extension BaseNightscoutManager: PumpHistoryObserver { @@ -646,3 +712,9 @@ extension BaseNightscoutManager: TempTargetsObserver { uploadTempTargets() } } + +extension BaseNightscoutManager: OverrideObserver { + func overrideDidUpdate(_: [OverrideProfil?]) { + uploadOverride() + } +} diff --git a/FreeAPSTests/OverrideTests.swift b/FreeAPSTests/OverrideTests.swift new file mode 100644 index 000000000..223aad3be --- /dev/null +++ b/FreeAPSTests/OverrideTests.swift @@ -0,0 +1,160 @@ +// +// OverrideTests.swift +// FreeAPSTests +// +// Created by Pierre LAGARDE on 05/05/2024. +// +import CoreData +@testable import FreeAPS +import Swinject +import XCTest + +final class OverrideTests: XCTestCase, Injectable { + var overrideTestStorage: OverrideStorage! + let resolver = FreeAPSApp().resolver + var coreDataStack: CoreDataStack? + + override func setUp() { + coreDataStack = TestCoreData() + (resolver as! Container) + .register(OverrideStorage.self, name: "testOverrideStorage") { r in + BaseOverrideStorage(resolver: r, managedObjectContext: self.coreDataStack!.persistentContainer.viewContext) } + + overrideTestStorage = resolver.resolve(OverrideStorage.self, name: "testOverrideStorage") + injectServices(resolver) + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + overrideTestStorage = nil + coreDataStack = nil + } + + func testAddOverridePreset() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + + // new override preset : + let op = OverrideProfil(name: "test 1", percentage: 120, reason: "test 1") + + overrideTestStorage.storeOverridePresets([op]) + XCTAssertTrue(overrideTestStorage.presets().count == 1) + XCTAssertTrue(overrideTestStorage.presets().first?.percentage == 120) + XCTAssertNil(overrideTestStorage.presets().first?.date) + + let op2 = OverrideProfil(name: "test 2", percentage: 80, reason: "test 2") + let op3 = OverrideProfil(name: "test 3", percentage: 200, reason: "test 3") + overrideTestStorage.storeOverridePresets([op2, op3]) + XCTAssertTrue(overrideTestStorage.presets().count == 3) + } + + func testUpdateOverridePreset() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + + // new override preset : + let op = OverrideProfil(name: "test 1", percentage: 120, reason: "test 1") + + overrideTestStorage.storeOverridePresets([op]) + var opUpdate = overrideTestStorage.presets().first + opUpdate?.percentage = 150 + overrideTestStorage.storeOverridePresets([opUpdate!]) + XCTAssertTrue(overrideTestStorage.presets().count == 1) + XCTAssertTrue(overrideTestStorage.presets().first?.percentage == 150) + } + + func testRemoveOverridePreset() { + // new override preset : + let op = OverrideProfil(name: "test 1", percentage: 120, reason: "test 1") + + overrideTestStorage.storeOverridePresets([op]) + XCTAssertTrue(overrideTestStorage.presets().count == 1) + let id = overrideTestStorage.presets().first(where: { $0.name == "test 1" })?.id + overrideTestStorage.deleteOverridePreset(id!) + XCTAssertTrue(overrideTestStorage.presets().isEmpty) + } + + func testAddOverride() { + let op = OverrideProfil(createdAt: Date(), duration: 20, percentage: 110, reason: "test 1") + let op2 = OverrideProfil( + createdAt: Date().addingTimeInterval(-10.minutes), + duration: 10, + percentage: 120, + reason: "test 2" + ) + let op3 = OverrideProfil( + createdAt: Date().addingTimeInterval(-2.days.timeInterval), + percentage: 20, + reason: "test 3" + ) + + overrideTestStorage.storeOverride([op, op2, op3]) + + XCTAssertTrue(overrideTestStorage.recent().count == 2) + XCTAssertTrue(overrideTestStorage.recent().last!?.duration == 10) + XCTAssertTrue(overrideTestStorage.current()?.percentage == 110) + } + + func testUpdateOverride() { + let op = OverrideProfil(createdAt: Date(), duration: 20, percentage: 110, reason: "test 1") + + overrideTestStorage.storeOverride([op]) + var opUpdate = overrideTestStorage.current()! + opUpdate.duration = nil // force to be indefinate + overrideTestStorage.storeOverride([opUpdate]) + XCTAssertNil(overrideTestStorage.current()?.duration) + XCTAssertTrue(overrideTestStorage.current()?.indefinite == true) + } + + func testCancelOverride() { + let op = OverrideProfil( + createdAt: Date().addingTimeInterval(-10.minutes), + duration: 20, + percentage: 110, + reason: "test 1" + ) + + overrideTestStorage.storeOverride([op]) + let durationFinal = overrideTestStorage.cancelCurrentOverride()! + XCTAssertNil(overrideTestStorage.current()) + XCTAssertLessThan(durationFinal, 1) + } + + func testApplyOverrideProfil() { + let op = OverrideProfil(name: "test 1", indefinite: true, percentage: 120, reason: "test 1") + overrideTestStorage.storeOverridePresets([op]) + +// let ov = OverrideProfil(createdAt: Date(), indefinite: true, percentage: 10, reason: "test 2") +// overrideTestStorage.storeOverride([ov]) + + let presetId = overrideTestStorage.presets().first?.id + + let date: Date = overrideTestStorage.applyOverridePreset(presetId!)! + + XCTAssertTrue(overrideTestStorage.current()?.percentage == 120) + XCTAssertTrue(overrideTestStorage.current()?.createdAt == date) + + let op2 = OverrideProfil(name: "test 2", duration: 20, percentage: 10, reason: "test 2") + overrideTestStorage.storeOverridePresets([op2]) + + let presetId2 = overrideTestStorage.presets().first(where: { $0.name == "test 2" })!.id + + _ = overrideTestStorage.applyOverridePreset(presetId2) + + XCTAssertTrue(overrideTestStorage.recent().count == 2) + XCTAssertTrue(overrideTestStorage.recent().last??.indefinite == false) + if let duration = overrideTestStorage.recent().last??.duration { + XCTAssertLessThan(duration, 1) + } else { + XCTAssert(false) + } + XCTAssertTrue(overrideTestStorage.current()?.percentage == 10) + } +} diff --git a/FreeAPSTests/TestCoreData.swift b/FreeAPSTests/TestCoreData.swift new file mode 100644 index 000000000..f5e1bd62d --- /dev/null +++ b/FreeAPSTests/TestCoreData.swift @@ -0,0 +1,28 @@ +// +// TestCoreData.swift +// FreeAPSTests +// +// Created by Pierre LAGARDE on 05/05/2024. +// +import CoreData +@testable import FreeAPS +import Swinject +import XCTest + +final class TestCoreData: CoreDataStack { + override init() { + super.init() + let persistentStoreDescription = NSPersistentStoreDescription() + persistentStoreDescription.type = NSInMemoryStoreType + let container = NSPersistentContainer(name: CoreDataStack.modelName, managedObjectModel: CoreDataStack.model) + + container.persistentStoreDescriptions = [persistentStoreDescription] + + container.loadPersistentStores { _, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + persistentContainer = container + } +} diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index c49a0e761..5b45c2081 100644 --- a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -48,7 +48,7 @@ { "identity" : "swiftcharts", "kind" : "remoteSourceControl", - "location" : "https://github.com/ivanschuetz/SwiftCharts", + "location" : "https://github.com/ivanschuetz/SwiftCharts.git", "state" : { "branch" : "master", "revision" : "c354c1945bb35a1f01b665b22474f6db28cba4a2"